record controllers...

This commit is contained in:
Michael Shanks 2020-04-09 16:42:55 +01:00 committed by Martin McKeaveney
parent ebc1c44343
commit 84c3e287d5
16 changed files with 146 additions and 306 deletions

View File

@ -22,10 +22,10 @@ const validateAllFieldParse = (record, model) =>
}, []),
])
const validateAllTypeConstraints = async (record, model) => {
const validateAllTypeConstraints = (record, model) => {
const errors = []
for (const field of model.fields) {
$(await validateTypeConstraints(field, record), [
$(validateTypeConstraints(field, record), [
filter(isNonEmptyString),
map(m => ({ message: m, fields: [field.name] })),
each(e => errors.push(e)),
@ -55,8 +55,8 @@ const runRecordValidationRules = (record, model) => {
])
}
export const validateRecord = async (schema, record) => {
const model = schema.findModel(record.modelId)
export const validateRecord = (schema, record) => {
const model = schema.findModel(record._modelId)
const fieldParseFails = validateAllFieldParse(record, model)
// non parsing would cause further issues - exit here
@ -65,7 +65,7 @@ export const validateRecord = async (schema, record) => {
}
const recordValidationRuleFails = runRecordValidationRules(record, model)
const typeContraintFails = await validateAllTypeConstraints(record, model)
const typeContraintFails = validateAllTypeConstraints(record, model)
if (
isEmpty(fieldParseFails) &&

View File

@ -47,11 +47,11 @@ const options = {
const typeConstraints = [
makerule(
async (val, opts) => val === null || val.length >= opts.minLength,
(val, opts) => val === null || val.length >= opts.minLength,
(val, opts) => `must choose ${opts.minLength} or more options`
),
makerule(
async (val, opts) => val === null || val.length <= opts.maxLength,
(val, opts) => val === null || val.length <= opts.maxLength,
(val, opts) => `cannot choose more than ${opts.maxLength} options`
),
]

View File

@ -6,7 +6,12 @@ import {
parsedSuccess,
getDefaultExport,
} from "./typeHelpers"
import { switchCase, defaultCase, isOneOf, toBoolOrNull } from "../../common/index.mjs"
import {
switchCase,
defaultCase,
isOneOf,
toBoolOrNull,
} from "../../common/index.mjs"
const boolFunctions = typeFunctions({
default: constant(null),
@ -31,7 +36,7 @@ const options = {
const typeConstraints = [
makerule(
async (val, opts) => opts.allowNulls === true || val !== null,
(val, opts) => opts.allowNulls === true || val !== null,
() => "field cannot be null"
),
]

View File

@ -50,7 +50,7 @@ const options = {
const typeConstraints = [
makerule(
async (val, opts) =>
(val, opts) =>
val === null || isNullOrEmpty(opts.minValue) || val >= opts.minValue,
(val, opts) =>
`value (${val.toString()}) must be greater than or equal to ${
@ -58,7 +58,7 @@ const typeConstraints = [
}`
),
makerule(
async (val, opts) =>
(val, opts) =>
val === null || isNullOrEmpty(opts.maxValue) || val <= opts.maxValue,
(val, opts) =>
`value (${val.toString()}) must be less than or equal to ${

View File

@ -5,7 +5,13 @@ import {
parsedSuccess,
getDefaultExport,
} from "./typeHelpers"
import { switchCase, defaultCase, none, $, splitKey } from "../../common/index.mjs"
import {
switchCase,
defaultCase,
none,
$,
splitKey,
} from "../../common/index.mjs"
const illegalCharacters = "*?\\/:<>|\0\b\f\v"

View File

@ -67,8 +67,8 @@ export const validateFieldParse = (field, record) =>
export const getDefaultOptions = type => getType(type).getDefaultOptions()
export const validateTypeConstraints = async (field, record) =>
await getType(field.type).validateTypeConstraints(field, record)
export const validateTypeConstraints = (field, record) =>
getType(field.type).validateTypeConstraints(field, record)
export const detectType = value => {
if (isString(value)) return string

View File

@ -1,65 +1,30 @@
import { isString, isObjectLike, isNull, has } from "lodash/fp"
import {
typeFunctions,
parsedSuccess,
getDefaultExport,
parsedFailed,
} from "./typeHelpers"
import { isString, isUndefined, isNull } from "lodash/fp"
import { typeFunctions, parsedSuccess, getDefaultExport } from "./typeHelpers"
import {
switchCase,
defaultCase,
isNonEmptyString,
isArrayOfString,
} from "../../common/index.mjs"
const linkNothing = () => ({ key: "" })
const linkNothing = () => ""
const linkFunctions = typeFunctions({
default: linkNothing,
})
const hasStringValue = (ob, path) => has(path)(ob) && isString(ob[path])
const isObjectWithKey = v => isObjectLike(v) && hasStringValue(v, "key")
const tryParseFromString = s => {
try {
const asObj = JSON.parse(s)
if (isObjectWithKey) {
return parsedSuccess(asObj)
}
} catch (_) {
// EMPTY
}
return parsedFailed(s)
}
const linkTryParse = v =>
switchCase(
[isObjectWithKey, parsedSuccess],
[isString, tryParseFromString],
[isString, s => parsedSuccess(s)],
[isNull, () => parsedSuccess(linkNothing())],
[defaultCase, parsedFailed]
[isUndefined, () => parsedSuccess(linkNothing())],
[defaultCase, s => parsedSuccess(s.toString())]
)(v)
const options = {
indexNodeKey: {
defaultValue: null,
isValid: isNonEmptyString,
requirementDescription: "must be a non-empty string",
parse: s => s,
},
displayValue: {
modelId: {
defaultValue: "",
isValid: isNonEmptyString,
requirementDescription: "must be a non-empty string",
parse: s => s,
},
reverseIndexNodeKeys: {
defaultValue: null,
isValid: v => isArrayOfString(v) && v.length > 0,
requirementDescription: "must be a non-empty array of strings",
requirementDescription: "must choose a model",
parse: s => s,
},
}
@ -72,6 +37,6 @@ export default getDefaultExport(
linkFunctions,
options,
typeConstraints,
{ key: "key", value: "value" },
"abcd1234",
JSON.stringify
)

View File

@ -58,7 +58,7 @@ const getDecimalPlaces = val => {
const typeConstraints = [
makerule(
async (val, opts) =>
(val, opts) =>
val === null || opts.minValue === null || val >= opts.minValue,
(val, opts) =>
`value (${val.toString()}) must be greater than or equal to ${
@ -66,7 +66,7 @@ const typeConstraints = [
}`
),
makerule(
async (val, opts) =>
(val, opts) =>
val === null || opts.maxValue === null || val <= opts.maxValue,
(val, opts) =>
`value (${val.toString()}) must be less than or equal to ${
@ -74,7 +74,7 @@ const typeConstraints = [
} options`
),
makerule(
async (val, opts) =>
(val, opts) =>
val === null || opts.decimalPlaces >= getDecimalPlaces(val),
(val, opts) =>
`value (${val.toString()}) must have ${

View File

@ -50,12 +50,12 @@ const options = {
const typeConstraints = [
makerule(
async (val, opts) =>
(val, opts) =>
val === null || opts.maxLength === null || val.length <= opts.maxLength,
(val, opts) => `value exceeds maximum length of ${opts.maxLength}`
),
makerule(
async (val, opts) =>
(val, opts) =>
val === null ||
opts.allowDeclaredValuesOnly === false ||
includes(val)(opts.values),

View File

@ -46,19 +46,16 @@ export const typeFunctions = specificFunctions =>
specificFunctions
)
export const validateTypeConstraints = validationRules => async (
field,
record
) => {
export const validateTypeConstraints = validationRules => (field, record) => {
const fieldValue = record[field.name]
const validateRule = async r =>
!(await r.isValid(fieldValue, field.typeOptions))
const validateRule = r =>
!r.isValid(fieldValue, field.typeOptions)
? r.getMessage(fieldValue, field.typeOptions)
: ""
const errors = []
for (const r of validationRules) {
const err = await validateRule(r)
const err = validateRule(r)
if (isNotEmpty(err)) errors.push(err)
}

View File

@ -2,10 +2,7 @@ import { newModel } from "../src/schema/models.mjs"
import { newView } from "../src/schema/views.mjs"
import { getNewField } from "../src/schema/fields.mjs"
import { fullSchema } from "../src/schema/fullSchema.mjs"
import {
recordValidationRules,
commonRecordValidationRules,
} from "../src/schema/recordValidationRules.mjs"
import { commonRecordValidationRules } from "../src/records/recordValidationRules.mjs"
export function testSchema() {
const addFieldToModel = (model, { type, name }) => {
@ -21,9 +18,10 @@ export function testSchema() {
addFieldToModel(contactModel, { name: "Name" })
addFieldToModel(contactModel, { name: "Is Active", type: "bool" })
addFieldToModel(contactModel, { name: "Created", type: "datetime" })
addFieldToModel(contactModel, { name: "Status", type: "string" })
contactModel.validationRules.push(
recordValidationRules(commonRecordValidationRules.fieldNotEmpty)
commonRecordValidationRules.fieldNotEmpty("Name")
)
const activeContactsView = newView(contactModel.id)

View File

@ -1,10 +1,8 @@
import { addHours } from "date-fns"
import { events } from "../src/common"
import { testSchema } from "./testSchema.mjs"
import { validateRecord } from "../src/records/validateRecord.mjs"
import { getNewRecord } from "../src/records/getNewRecord.mjs"
describe("recordApi > validate", () => {
describe("validateRecord", () => {
it("should return errors when any fields do not parse", () => {
const schema = testSchema()
const record = getNewRecord(schema, "Contact")
@ -34,7 +32,7 @@ describe("recordApi > validate", () => {
const schema = testSchema()
schema.findField("Contact", "Name").typeOptions.maxLength = 5
const record = getNewRecord(schema, "Contact")
record.name = "more than 5 characters"
record.Name = "more than 5 characters"
const validationResult = validateRecord(schema, record)
expect(validationResult.isValid).toBe(false)
@ -47,213 +45,93 @@ describe("recordApi > validate", () => {
const record = getNewRecord(schema, "Deal")
record["Estimated Value"] = 10
const validationResult = recordApi.validate(schema, record)
const validationResult = validateRecord(schema, record)
expect(validationResult.isValid).toBe(false)
expect(validationResult.errors.length).toBe(1)
})
it("should return error when number field is < minValue", async () => {
const withFieldWithMaxLength = hierarchy => {
const age = find(hierarchy.customerRecord.fields, f => f.name === "age")
age.typeOptions.minValue = 5
}
it("should return error when number field is < minValue", () => {
const schema = testSchema()
schema.findField("Deal", "Estimated Value").typeOptions.minValue = 5
const record = getNewRecord(schema, "Deal")
record["Estimated Value"] = 1
const hierarchyCreator = hierarchyFactory(
withFields,
withFieldWithMaxLength
)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const tooYoungRecord = recordApi.getNew("/customers", "customer")
tooYoungRecord.age = 3
const tooYoungResult = await recordApi.validate(tooYoungRecord)
expect(tooYoungResult.isValid).toBe(false)
expect(tooYoungResult.errors.length).toBe(1)
})
it("should return error when number has too many decimal places", async () => {
const withFieldWithMaxLength = (hierarchy, templateApi) => {
const age = find(hierarchy.customerRecord.fields, f => f.name === "age")
age.typeOptions.decimalPlaces = 2
}
const hierarchyCreator = hierarchyFactory(
withFields,
withFieldWithMaxLength
)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const record = recordApi.getNew("/customers", "customer")
record.age = 3.123
const validationResult = await recordApi.validate(record)
expect(validationResult.isValid).toBe(false)
expect(validationResult.errors.length).toBe(1)
})
it("should return error when datetime field is > maxValue", async () => {
const withFieldWithMaxLength = hierarchy => {
const createddate = find(
hierarchy.customerRecord.fields,
f => f.name === "createddate"
)
createddate.typeOptions.maxValue = new Date()
}
const hierarchyCreator = hierarchyFactory(
withFields,
withFieldWithMaxLength
)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const record = recordApi.getNew("/customers", "customer")
record.createddate = addHours(new Date(), 1)
const result = await recordApi.validate(record)
const result = validateRecord(schema, record)
expect(result.isValid).toBe(false)
expect(result.errors.length).toBe(1)
})
it("should return error when number field is < minValue", async () => {
const withFieldWithMaxLength = hierarchy => {
const createddate = find(
hierarchy.customerRecord.fields,
f => f.name === "createddate"
)
createddate.typeOptions.minValue = addHours(new Date(), 1)
}
it("should return error when number has too many decimal places", () => {
const schema = testSchema()
schema.findField("Deal", "Estimated Value").typeOptions.decimalPlaces = 2
const record = getNewRecord(schema, "Deal")
record["Estimated Value"] = 1.123
const hierarchyCreator = hierarchyFactory(
withFields,
withFieldWithMaxLength
)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const record = recordApi.getNew("/customers", "customer")
record.createddate = new Date()
const result = await recordApi.validate(record)
const result = validateRecord(schema, record)
expect(result.isValid).toBe(false)
expect(result.errors.length).toBe(1)
})
it("should return error when string IS NOT one of declared values, and only declared values are allowed", async () => {
const withFieldWithMaxLength = hierarchy => {
const surname = find(
hierarchy.customerRecord.fields,
f => f.name === "surname"
)
surname.typeOptions.allowDeclaredValuesOnly = true
surname.typeOptions.values = ["thedog"]
}
it("should return error when datetime field is > maxValue", () => {
const schema = testSchema()
schema.findField("Contact", "Created").typeOptions.maxValue = new Date(2020, 1, 1)
const record = getNewRecord(schema, "Contact")
record.Name = "Bob"
record.Created = new Date(2020, 1, 2)
const hierarchyCreator = hierarchyFactory(
withFields,
withFieldWithMaxLength
)
const { recordApi } = await setupApphierarchy(hierarchyCreator)
const record = recordApi.getNew("/customers", "customer")
record.surname = "zeecat"
const result = await recordApi.validate(record)
const result = validateRecord(schema, record)
expect(result.isValid).toBe(false)
expect(result.errors.length).toBe(1)
})
it("should not return error when string IS one of declared values, and only declared values are allowed", async () => {
const withFieldWithMaxLength = hierarchy => {
const surname = find(
hierarchy.customerRecord.fields,
f => f.name === "surname"
)
surname.typeOptions.allowDeclaredValuesOnly = true
surname.typeOptions.values = ["thedog"]
}
it("should return error when number field is < minValue", () => {
const schema = testSchema()
schema.findField("Contact", "Created").typeOptions.minValue = new Date(2020, 1, 2)
const record = getNewRecord(schema, "Contact")
record.Name = "Bob"
record.Created = new Date(2020, 1, 1)
const hierarchyCreator = hierarchyFactory(
withFields,
withFieldWithMaxLength
)
const { recordApi, appHierarchy } = await setupApphierarchy(
hierarchyCreator
)
const result = validateRecord(schema, record)
expect(result.isValid).toBe(false)
expect(result.errors.length).toBe(1)
})
const record = recordApi.getNew("/customers", "customer")
record.surname = "thedog"
it("should return error when string IS NOT one of declared values, and only declared values are allowed", () => {
const schema = testSchema()
schema.findField("Contact", "Status").typeOptions.allowDeclaredValuesOnly = true
schema.findField("Contact", "Status").typeOptions.values = ["thedog"]
const record = getNewRecord(schema, "Contact")
record.Status = "not allowed"
record.Name = "Bob"
const result = await recordApi.validate(record)
const result = validateRecord(schema, record)
expect(result.isValid).toBe(false)
expect(result.errors.length).toBe(1)
})
it("should not return error when string IS one of declared values, and only declared values are allowed", () => {
const schema = testSchema()
schema.findField("Contact", "Status").typeOptions.allowDeclaredValuesOnly = true
schema.findField("Contact", "Status").typeOptions.values = ["thedog"]
const record = getNewRecord(schema, "Contact")
record.Status = "thedog"
record.Name = "Bob"
const result = validateRecord(schema, record)
expect(result.isValid).toBe(true)
expect(result.errors.length).toBe(0)
})
it("should not return error when string IS NOT one of declared values, but any values are allowed", async () => {
const withFieldWithMaxLength = (hierarchy, templateApi) => {
const surname = find(
hierarchy.customerRecord.fields,
f => f.name === "surname"
)
surname.typeOptions.allowDeclaredValuesOnly = false
surname.typeOptions.values = ["thedog"]
}
it("should not return error when string IS NOT one of declared values, but any values are allowed", () => {
const schema = testSchema()
schema.findField("Contact", "Status").typeOptions.allowDeclaredValuesOnly = false
schema.findField("Contact", "Status").typeOptions.values = ["thedog"]
const record = getNewRecord(schema, "Contact")
record.Status = "not one of the values"
record.Name = "Bob"
const hierarchyCreator = hierarchyFactory(
withFields,
withFieldWithMaxLength
)
const { recordApi, appHierarchy } = await setupApphierarchy(
hierarchyCreator
)
const record = recordApi.getNew("/customers", "customer")
record.surname = "zeecat"
const result = await recordApi.validate(record)
const result = validateRecord(schema, record)
expect(result.isValid).toBe(true)
expect(result.errors.length).toBe(0)
})
it("should return error when reference field does not exist in options index", async () => {
const { recordApi, appHierarchy } = await setupApphierarchy(
basicAppHierarchyCreator_WithFields_AndIndexes
)
const partner = recordApi.getNew("/partners", "partner")
partner.businessName = "ACME Inc"
await recordApi.save(partner)
const customer = recordApi.getNew("/customers", "customer")
customer.partner = { key: "incorrect key", name: partner.businessName }
const result = await await recordApi.validate(customer)
expect(result.isValid).toBe(false)
expect(result.errors.length).toBe(1)
})
it("should publish invalid events", async () => {
const withValidationRule = (hierarchy, templateApi) => {
templateApi.addRecordValidationRule(hierarchy.customerRecord)(
templateApi.commonRecordValidationRules.fieldNotEmpty("surname")
)
}
const hierarchyCreator = hierarchyFactory(withFields, withValidationRule)
const { recordApi, subscribe } = await setupApphierarchy(hierarchyCreator)
const handler = stubEventHandler()
subscribe(events.recordApi.save.onInvalid, handler.handle)
const record = recordApi.getNew("/customers", "customer")
record.surname = ""
try {
await recordApi.save(record)
} catch (e) {}
const onInvalid = handler.getEvents(events.recordApi.save.onInvalid)
expect(onInvalid.length).toBe(1)
expect(onInvalid[0].context.record).toBeDefined()
expect(onInvalid[0].context.record.key).toBe(record.key)
expect(onInvalid[0].context.validationResult).toBeDefined()
})
})

View File

@ -15,17 +15,6 @@ const _events = {
uploadFile: common(),
downloadFile: common(),
},
indexApi: {
buildIndex: common(),
listItems: common(),
delete: common(),
aggregates: common(),
},
collectionApi: {
getAllowedRecordTypes: common(),
initialise: common(),
delete: common(),
},
authApi: {
authenticate: common(),
authenticateTemporaryAccess: common(),

View File

@ -0,0 +1,12 @@
export function allModelsViewName(modelId) {
return `all_${modelId}`
}
export function allModelsDesignDocName(modelId) {
return `all_${modelId}`
}
export function instanceDatabaseId (clientId, instanceId) {
return `instance:${clientId}:${instanceId}`
}
export function clientDatabaseId(clientId) {
return `client:${clientId}`
}

View File

@ -1,11 +1,15 @@
const couchdb = require("../../db")
const { cloneDeep, mapValues, keyBy, filter, includes } = require("lodash/fp")
const { cloneDeep, mapValues, keyBy } = require("lodash/fp")
const {
validateRecord,
} = require("../../../common/src/records/validateRecord.mjs")
const { events } = require("../../../common/src/common/events.mjs")
const { $, isNonEmptyString } = require("../../../common/src/common")
const { $ } = require("../../../common/src/common")
import { safeParseField } from "../../../common/src/schema/types"
import {
allModelsViewName,
allModelsDesignDocName,
} from "./couchdbNamingConventions"
async function save(ctx) {
const db = couchdb.use(ctx.databaseId)
@ -19,7 +23,7 @@ async function save(ctx) {
const validationResult = await validateRecord(ctx.schema, record)
if (!validationResult.isValid) {
await app.publish(events.recordApi.save.onInvalid, {
await ctx.publish(events.recordApi.save.onInvalid, {
record,
validationResult,
})
@ -30,13 +34,13 @@ async function save(ctx) {
if (!record._rev) {
await db.insert(record)
await app.publish(events.recordApi.save.onRecordCreated, {
await ctx.publish(events.recordApi.save.onRecordCreated, {
record: record,
})
} else {
const oldRecord = await _findRecord(db, ctx.schema, record._id)
await db.insert(record)
await app.publish(events.recordApi.save.onRecordUpdated, {
await ctx.publish(events.recordApi.save.onRecordUpdated, {
old: oldRecord,
new: record,
})
@ -49,16 +53,23 @@ async function save(ctx) {
async function fetch(ctx) {
const db = couchdb.db.use(ctx.params.databaseId)
ctx.body = await db.view("database", "all_somemodel", {
include_docs: true,
key: ["app"]
})
const model = ctx.schema.findModel(ctx.modelName)
ctx.body = db.viewAsStream(
allModelsDesignDocName(model.id),
allModelsViewName(model.id),
{
include_docs: true,
}
)
}
async function find(ctx) {
const db = couchdb.db.use(ctx.params.databaseId)
const { body, status } = await _findRecord(db, ctx.schema, ctx.params.id)
const { body, status } = await _findRecord(
db,
ctx.schema,
ctx.params.recordId
)
ctx.status = status
ctx.body = body
}
@ -78,28 +89,6 @@ async function _findRecord(db, schema, id) {
mapValues(f => safeParseField(f, storedData)),
])
const links = $(model.fields, [
filter(
f => f.type === "reference" && isNonEmptyString(loadedRecord[f.name].key)
),
map(f => ({
promise: _findRecord(db, schema, loadedRecord[f.name]._id),
index: getNode(app.hierarchy, f.typeOptions.indexNodeKey),
field: f,
})),
])
if (links.length > 0) {
const refRecords = await Promise.all(map(p => p.promise)(links))
for (const ref of links) {
loadedRecord[ref.field.name] = mapRecord(
refRecords[links.indexOf(ref)],
ref.index
)
}
}
loadedRecord._rev = storedData._rev
loadedRecord._id = storedData._id
loadedRecord._modelId = storedData._modelId
@ -112,4 +101,4 @@ async function destroy(ctx) {
ctx.body = await database.destroy(ctx.params.recordId);
}
module.exports = {dave, fetch, destroy, find};
module.exports = { save, fetch, destroy, find }

View File

@ -4,8 +4,9 @@ const controller = require("../../controllers/record");
const router = Router();
router
.get("/api/:databaseId/records", controller.fetch)
.post("/api/:databaseId/records", controller.save)
.delete("/api/:databaseId/records/:recordId", controller.destroy)
.get("/api/:databaseId/records/:modelname", controller.fetch)
.post("/api/:databaseId/record", controller.save)
.get("/api/:databaseId/record/:recordId", controller.find)
.delete("/api/:databaseId/record/:recordId", controller.destroy)
module.exports = router;