From 75ceaec4a711549a1f5de065fd178fd70d0d3e01 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Thu, 1 Aug 2024 11:25:41 -0400 Subject: [PATCH 01/21] Remove script restrictions; Move unique property out of restrictions These are breaking changes to the meta schema that need to be published as a major release. A document should be added to the repo describing these changes. --- apps/server/src/services/dictionaryService.ts | 22 +- apps/server/src/services/schemaService.ts | 40 +-- .../test/fixtures/schemas/references.ts | 7 - apps/server/test/functional/normalize.spec.ts | 35 -- generated/DictionaryMetaSchema.json | 307 +++++++++--------- .../src/metaSchema/dictionarySchemas.ts | 11 +- packages/dictionary/test/diff.spec.ts | 4 +- .../dictionary/test/fixtures/diff/initial.ts | 2 +- .../dictionary/test/fixtures/diff/updated.ts | 2 +- .../empty_references_section/input.ts | 2 +- .../empty_references_section/output.ts | 2 +- .../references/no_references_section/input.ts | 2 +- .../no_references_section/output.ts | 2 +- .../references/script_references/input.ts | 42 --- .../references/script_references/output.ts | 39 --- packages/dictionary/test/references.spec.ts | 6 - .../src/validateField/FieldRestrictionRule.ts | 1 - .../src/validateSchema/validateSchema.ts | 2 +- .../schemaRestrictions/fieldStringUnique.ts | 2 +- .../fieldStringUniqueArray.ts | 2 +- 20 files changed, 173 insertions(+), 359 deletions(-) delete mode 100644 apps/server/test/functional/normalize.spec.ts delete mode 100644 packages/dictionary/test/fixtures/references/script_references/input.ts delete mode 100644 packages/dictionary/test/fixtures/references/script_references/output.ts diff --git a/apps/server/src/services/dictionaryService.ts b/apps/server/src/services/dictionaryService.ts index 8b3d9145..3c77d285 100644 --- a/apps/server/src/services/dictionaryService.ts +++ b/apps/server/src/services/dictionaryService.ts @@ -29,7 +29,7 @@ import * as immer from 'immer'; import { omit } from 'lodash'; import logger from '../config/logger'; import * as DictionaryRepo from '../db/dictionary'; -import { normalizeSchema, validate } from '../services/schemaService'; +import { validateDictionarySchema } from '../services/schemaService'; import type { DictionaryDocument, DictionaryDocumentSummary } from '../db/dbTypes'; /** @@ -127,17 +127,15 @@ export const create = async (newDict: Dictionary): Promise => { // Verify schemas match dictionary newDict.schemas.forEach((e) => { - const result = validate(e, newDict.references || {}); + const result = validateDictionarySchema(e, newDict.references || {}); if (!result.valid) throw new BadRequestError(JSON.stringify(result.errors)); }); - const normalizedSchemas = newDict.schemas.map((schema) => normalizeSchema(schema)); - // Save new dictionary version const result = await DictionaryRepo.addDictionary({ name: newDict.name, version: newDict.version, - schemas: normalizedSchemas, + schemas: newDict.schemas, references: newDict.references || {}, }); return result; @@ -159,18 +157,16 @@ export const addSchema = async (id: string, schema: Schema): Promise throw new BadRequestError('Dictionary that you are trying to update is not the latest version.'); } - const result = validate(schema, existingDictionary.references || {}); + const result = validateDictionarySchema(schema, existingDictionary.references || {}); if (!result.valid) throw new BadRequestError(JSON.stringify(result.errors)); if (existingDictionary.schemas.some((s) => s.name === schema.name)) { throw new ConflictError('Schema with this name already exists.'); } - const normalizedSchema = normalizeSchema(schema); - const updatedDictionary = immer.produce(existingDictionary, (draft) => { draft.version = VersionUtils.incrementMajor(draft.version); - draft.schemas = [...draft.schemas, normalizedSchema]; + draft.schemas = [...draft.schemas, schema]; }); // Save new dictionary version @@ -191,7 +187,7 @@ export const updateSchema = async (id: string, schema: Schema, major: boolean): await checkLatest(existingDictionary); - const result = validate(schema, existingDictionary.references || {}); + const result = validateDictionarySchema(schema, existingDictionary.references || {}); if (!result.valid) throw new BadRequestError(JSON.stringify(result.errors)); // Ensure it exists @@ -202,9 +198,7 @@ export const updateSchema = async (id: string, schema: Schema, major: boolean): // Filter out one to update const schemas = existingDictionary.schemas.filter((s) => !(s['name'] === schema['name'])); - const normalizedSchema = normalizeSchema(schema); - - schemas.push(normalizedSchema); + schemas.push(schema); // Increment Version const nextVersion = major @@ -212,7 +206,7 @@ export const updateSchema = async (id: string, schema: Schema, major: boolean): : VersionUtils.incrementMinor(existingDictionary.version); const updatedDictionary = immer.produce(existingDictionary, (draft) => { const filteredSchemas = draft.schemas.filter((s) => !(s['name'] === schema['name'])); - draft.schemas = [...filteredSchemas, normalizedSchema]; + draft.schemas = [...filteredSchemas, schema]; draft.version = nextVersion; }); diff --git a/apps/server/src/services/schemaService.ts b/apps/server/src/services/schemaService.ts index 2bbae874..59b3f5b3 100644 --- a/apps/server/src/services/schemaService.ts +++ b/apps/server/src/services/schemaService.ts @@ -18,10 +18,12 @@ */ import { References, replaceSchemaReferences, Schema } from '@overture-stack/lectern-dictionary'; -import * as immer from 'immer'; import { ZodError } from 'zod'; -export function validate(schema: Schema, references: References): { valid: boolean; errors?: ZodError } { +export function validateDictionarySchema( + schema: Schema, + references: References, +): { valid: boolean; errors?: ZodError } { const schemaWithReplacements = replaceSchemaReferences(schema, references); // Ensure schema is still valid after reference replacement @@ -30,37 +32,3 @@ export function validate(schema: Schema, references: References): { valid: boole return parseResult.success ? { valid: true } : { valid: false, errors: parseResult.error }; } - -/** - * String formatting of values provided as scripts. This will normalize the formatting of newline characters, - * All instances of `/r/n` will be converted to `/n` - * @param script - * @returns - */ -function normalizeScript(input: string | string[]) { - const normalize = (script: string) => script.replace(/\r\n/g, '\n'); - - if (typeof input === 'string') { - return normalize(input); - } else { - return input.map(normalize); - } -} - -export function normalizeSchema(schema: Schema): Schema { - const normalizedFields = schema.fields.map((baseField) => - immer.produce(baseField, (field) => { - if ( - field.valueType !== 'boolean' && - field.restrictions !== undefined && - field.restrictions.script !== undefined - ) { - field.restrictions.script = normalizeScript(field.restrictions.script); - } - }), - ); - - return immer.produce(schema, (draft) => { - draft.fields = normalizedFields; - }); -} diff --git a/apps/server/test/fixtures/schemas/references.ts b/apps/server/test/fixtures/schemas/references.ts index abb4f016..b1f3485d 100644 --- a/apps/server/test/fixtures/schemas/references.ts +++ b/apps/server/test/fixtures/schemas/references.ts @@ -35,12 +35,5 @@ export default { codeList: '#/listA', }, }, - { - name: 'script_as_reference', - valueType: 'number', - restrictions: { - script: '#/scriptA', - }, - }, ], } satisfies Schema; diff --git a/apps/server/test/functional/normalize.spec.ts b/apps/server/test/functional/normalize.spec.ts deleted file mode 100644 index b4358339..00000000 --- a/apps/server/test/functional/normalize.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved - * - * This program and the accompanying materials are made available under the terms of - * the GNU Affero General Public License v3.0. You should have received a copy of the - * GNU Affero General Public License along with this program. - * If not, see . - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY - * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES - * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT - * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED - * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; - * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER - * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN - * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -import { expect } from 'chai'; -import { normalizeSchema } from '../../src/services/schemaService'; -import DICTIONARY_RN_LINEBREAKS from '../fixtures/dictionaries/linebreak/rnLinebreaks'; -import DICTIONARY_N_LINEBREAKS from '../fixtures/dictionaries/linebreak/nLinebreaks'; -import DICTIONARY_NORMALIZED_LINEBREAKS from '../fixtures/dictionaries/linebreak/normalizedLinebreaks'; - -describe('New Line symbol normalization', () => { - it('Should convert \\r\\n to \\n in scripts', () => { - const normalizedSchema = normalizeSchema(DICTIONARY_N_LINEBREAKS.schemas[0]); - expect(normalizedSchema).to.deep.eq(DICTIONARY_NORMALIZED_LINEBREAKS.schemas[0]); - }); - it('Should not alter already formatted files', () => { - const normalizedSchema = normalizeSchema(DICTIONARY_RN_LINEBREAKS.schemas[0]); - expect(normalizedSchema).to.deep.eq(DICTIONARY_NORMALIZED_LINEBREAKS.schemas[0]); - }); -}); diff --git a/generated/DictionaryMetaSchema.json b/generated/DictionaryMetaSchema.json index a43a52d4..21f012ae 100644 --- a/generated/DictionaryMetaSchema.json +++ b/generated/DictionaryMetaSchema.json @@ -167,73 +167,62 @@ "meta": { "$ref": "#/definitions/Meta" }, + "unique": { + "type": "boolean" + }, "valueType": { "type": "string", "const": "string" }, "restrictions": { - "type": "object", - "properties": { - "codeList": { - "anyOf": [ - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "minItems": 1 - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "required": { - "type": "boolean" - }, - "script": { - "anyOf": [ - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "codeList": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "minItems": 1 + "minItems": 1 + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "regex": { - "anyOf": [ - { - "type": "string" + "required": { + "type": "boolean" }, - { - "$ref": "#/definitions/ReferenceTag" + "regex": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] } - ] + }, + "additionalProperties": false }, - "unique": { - "type": "boolean" + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaField/anyOf/0/properties/restrictions/anyOf/0" + } } - }, - "additionalProperties": false + ] } }, "required": [ @@ -257,63 +246,63 @@ "meta": { "$ref": "#/definitions/SchemaField/anyOf/0/properties/meta" }, + "unique": { + "$ref": "#/definitions/SchemaField/anyOf/0/properties/unique" + }, "valueType": { "type": "string", "const": "number" }, "restrictions": { - "type": "object", - "properties": { - "codeList": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "number" - }, - "minItems": 1 - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "required": { - "type": "boolean" - }, - "script": { - "anyOf": [ - { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/restrictions/properties/script/anyOf/0" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "range": { + "anyOf": [ + { "type": "object", "properties": { - "exclusiveMax": { - "type": "number" - }, - "exclusiveMin": { - "type": "number" + "codeList": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1 + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] }, - "max": { - "type": "number" + "required": { + "type": "boolean" }, - "min": { - "type": "number" + "range": { + "type": "object", + "properties": { + "exclusiveMax": { + "type": "number" + }, + "exclusiveMin": { + "type": "number" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "unique": { - "type": "boolean" + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaField/anyOf/1/properties/restrictions/anyOf/0" + } } - }, - "additionalProperties": false + ] } }, "required": [ @@ -337,63 +326,63 @@ "meta": { "$ref": "#/definitions/SchemaField/anyOf/0/properties/meta" }, + "unique": { + "$ref": "#/definitions/SchemaField/anyOf/0/properties/unique" + }, "valueType": { "type": "string", "const": "integer" }, "restrictions": { - "type": "object", - "properties": { - "codeList": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "integer" - }, - "minItems": 1 - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "required": { - "type": "boolean" - }, - "script": { - "anyOf": [ - { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/restrictions/properties/script/anyOf/0" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "range": { + "anyOf": [ + { "type": "object", "properties": { - "exclusiveMax": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/properties/codeList/anyOf/0/items" - }, - "exclusiveMin": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/properties/codeList/anyOf/0/items" + "codeList": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1 + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] }, - "max": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/properties/codeList/anyOf/0/items" + "required": { + "type": "boolean" }, - "min": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/properties/codeList/anyOf/0/items" + "range": { + "type": "object", + "properties": { + "exclusiveMax": { + "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" + }, + "exclusiveMin": { + "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" + }, + "max": { + "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" + }, + "min": { + "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "unique": { - "type": "boolean" + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0" + } } - }, - "additionalProperties": false + ] } }, "required": [ @@ -417,31 +406,31 @@ "meta": { "$ref": "#/definitions/SchemaField/anyOf/0/properties/meta" }, + "unique": { + "$ref": "#/definitions/SchemaField/anyOf/0/properties/unique" + }, "valueType": { "type": "string", "const": "boolean" }, "restrictions": { - "type": "object", - "properties": { - "required": { - "type": "boolean" - }, - "script": { - "anyOf": [ - { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/restrictions/properties/script/anyOf/0" - }, - { - "$ref": "#/definitions/ReferenceTag" + "anyOf": [ + { + "type": "object", + "properties": { + "required": { + "type": "boolean" } - ] + }, + "additionalProperties": false }, - "unique": { - "type": "boolean" + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaField/anyOf/3/properties/restrictions/anyOf/0" + } } - }, - "additionalProperties": false + ] } }, "required": [ diff --git a/packages/dictionary/src/metaSchema/dictionarySchemas.ts b/packages/dictionary/src/metaSchema/dictionarySchemas.ts index 1e159b3d..80c60440 100644 --- a/packages/dictionary/src/metaSchema/dictionarySchemas.ts +++ b/packages/dictionary/src/metaSchema/dictionarySchemas.ts @@ -70,9 +70,7 @@ export const StringFieldRestrictions = zod .object({ codeList: RestrictionCodeListString.or(ReferenceTag), required: zod.boolean(), - script: RestrictionScript.or(ReferenceTag), regex: RestrictionRegex.or(ReferenceTag), - unique: zod.boolean(), }) .partial(); export type StringFieldRestrictions = zod.infer; @@ -81,9 +79,7 @@ export const NumberFieldRestrictions = zod .object({ codeList: RestrictionCodeListNumber.or(ReferenceTag), required: zod.boolean(), - script: RestrictionScript.or(ReferenceTag), range: RestrictionNumberRange, - unique: zod.boolean(), }) .partial(); export type NumberFieldRestrictions = zod.infer; @@ -92,16 +88,12 @@ export const IntegerFieldRestrictions = zod .object({ codeList: RestrictionCodeListInteger.or(ReferenceTag), required: zod.boolean(), - script: RestrictionScript.or(ReferenceTag), range: RestrictionIntegerRange, - unique: zod.boolean(), }) .partial(); export type IntegerFieldRestrictions = zod.infer; -export const BooleanFieldRestrictions = zod - .object({ required: zod.boolean(), script: RestrictionScript.or(ReferenceTag), unique: zod.boolean() }) - .partial(); +export const BooleanFieldRestrictions = zod.object({ required: zod.boolean() }).partial(); export type BooleanFieldRestrictions = zod.infer; /* ***************** * @@ -113,6 +105,7 @@ export const SchemaFieldBase = zod description: zod.string().optional(), isArray: zod.boolean().optional(), meta: DictionaryMeta.optional(), + unique: zod.boolean().optional(), }) .strict(); export type SchemaFieldBase = zod.infer; diff --git a/packages/dictionary/test/diff.spec.ts b/packages/dictionary/test/diff.spec.ts index 9a305dc4..6ca1745a 100644 --- a/packages/dictionary/test/diff.spec.ts +++ b/packages/dictionary/test/diff.spec.ts @@ -30,9 +30,9 @@ describe('Compute diff report between dictionary versions', () => { expect(diffReport.get('donor.donor_submitter_id')?.diff).to.deep.eq({ meta: { displayName: { type: 'deleted', data: 'Submitter Donor ID' } }, restrictions: { - script: { + regex: { type: 'updated', - data: { added: ['(field)=>field.length > 6'], deleted: ['(field)=>field.length > 5'] }, + data: '^[\\w]*$', }, }, }); diff --git a/packages/dictionary/test/fixtures/diff/initial.ts b/packages/dictionary/test/fixtures/diff/initial.ts index 54e0923c..42332273 100644 --- a/packages/dictionary/test/fixtures/diff/initial.ts +++ b/packages/dictionary/test/fixtures/diff/initial.ts @@ -36,7 +36,7 @@ const DIFF_DICTIONARY_INITIAL: Dictionary = { key: true, }, restrictions: { - script: ['(field)=>field.length > 5'], + regex: '^[\\w]+$', }, }, { diff --git a/packages/dictionary/test/fixtures/diff/updated.ts b/packages/dictionary/test/fixtures/diff/updated.ts index 8186c54f..664769bf 100644 --- a/packages/dictionary/test/fixtures/diff/updated.ts +++ b/packages/dictionary/test/fixtures/diff/updated.ts @@ -35,7 +35,7 @@ const DIFF_DICTIONARY_UPDATED: Dictionary = { key: true, }, restrictions: { - script: ['(field)=>field.length > 6'], + regex: '^[\\w]*$', }, }, { diff --git a/packages/dictionary/test/fixtures/references/empty_references_section/input.ts b/packages/dictionary/test/fixtures/references/empty_references_section/input.ts index 865206ab..0a7b3485 100644 --- a/packages/dictionary/test/fixtures/references/empty_references_section/input.ts +++ b/packages/dictionary/test/fixtures/references/empty_references_section/input.ts @@ -17,7 +17,7 @@ const content: Dictionary = { key: true, }, restrictions: { - script: ['() => field.length > 5'], + regex: '^[\\w]*$', }, }, { diff --git a/packages/dictionary/test/fixtures/references/empty_references_section/output.ts b/packages/dictionary/test/fixtures/references/empty_references_section/output.ts index d0a85b21..f59a1365 100644 --- a/packages/dictionary/test/fixtures/references/empty_references_section/output.ts +++ b/packages/dictionary/test/fixtures/references/empty_references_section/output.ts @@ -17,7 +17,7 @@ const content: Dictionary = { key: true, }, restrictions: { - script: ['() => field.length > 5'], + regex: '^[\\w]*$', }, }, { diff --git a/packages/dictionary/test/fixtures/references/no_references_section/input.ts b/packages/dictionary/test/fixtures/references/no_references_section/input.ts index 174b9d3a..873c2075 100644 --- a/packages/dictionary/test/fixtures/references/no_references_section/input.ts +++ b/packages/dictionary/test/fixtures/references/no_references_section/input.ts @@ -17,7 +17,7 @@ const content: Dictionary = { key: true, }, restrictions: { - script: ['() => field.length > 5'], + regex: '^[\\w]*$', }, }, { diff --git a/packages/dictionary/test/fixtures/references/no_references_section/output.ts b/packages/dictionary/test/fixtures/references/no_references_section/output.ts index d0a85b21..f59a1365 100644 --- a/packages/dictionary/test/fixtures/references/no_references_section/output.ts +++ b/packages/dictionary/test/fixtures/references/no_references_section/output.ts @@ -17,7 +17,7 @@ const content: Dictionary = { key: true, }, restrictions: { - script: ['() => field.length > 5'], + regex: '^[\\w]*$', }, }, { diff --git a/packages/dictionary/test/fixtures/references/script_references/input.ts b/packages/dictionary/test/fixtures/references/script_references/input.ts deleted file mode 100644 index f386584d..00000000 --- a/packages/dictionary/test/fixtures/references/script_references/input.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Dictionary } from '../../../../src'; - -const content: Dictionary = { - name: 'Test Dictionary', - version: '1.2', - schemas: [ - { - name: 'donor', - description: 'Donor Entity', - fields: [ - { - name: 'count', - valueType: 'number', - restrictions: { - script: '#/IS_EVEN', - }, - }, - { - name: 'score', - valueType: 'string', - description: 'Donor Biological Sex', - restrictions: { - script: ['(value) => value/1000 > 9', '#/IS_EVEN'], - }, - }, - { - name: 'ethnicity', - valueType: 'string', - description: 'Self described', - meta: { - default: 'Unknown', - }, - restrictions: {}, - }, - ], - }, - ], - references: { - IS_EVEN: '(value) => value % 2', - }, -}; -export default content; diff --git a/packages/dictionary/test/fixtures/references/script_references/output.ts b/packages/dictionary/test/fixtures/references/script_references/output.ts deleted file mode 100644 index 169b1914..00000000 --- a/packages/dictionary/test/fixtures/references/script_references/output.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Dictionary } from '../../../../src'; - -const content: Dictionary = { - name: 'Test Dictionary', - version: '1.2', - schemas: [ - { - name: 'donor', - description: 'Donor Entity', - fields: [ - { - name: 'count', - valueType: 'number', - restrictions: { - script: ['(value) => value % 2'], - }, - }, - { - name: 'score', - valueType: 'string', - description: 'Donor Biological Sex', - restrictions: { - script: ['(value) => value/1000 > 9', '(value) => value % 2'], - }, - }, - { - name: 'ethnicity', - valueType: 'string', - description: 'Self described', - meta: { - default: 'Unknown', - }, - restrictions: {}, - }, - ], - }, - ], -}; -export default content; diff --git a/packages/dictionary/test/references.spec.ts b/packages/dictionary/test/references.spec.ts index 823d8ba9..57cc25fd 100644 --- a/packages/dictionary/test/references.spec.ts +++ b/packages/dictionary/test/references.spec.ts @@ -30,8 +30,6 @@ import codeListReferencesInput from './fixtures/references/codeList_references/i import codeListReferencesOutput from './fixtures/references/codeList_references/output'; import referencesWithinReferencesInput from './fixtures/references/references_within_references/input'; import referencesWithinReferencesOutput from './fixtures/references/references_within_references/output'; -import scriptReferencesInput from './fixtures/references/script_references/input'; -import scriptReferencesOutput from './fixtures/references/script_references/output'; import regexReferencesInput from './fixtures/references/regex_reference/input'; import regexReferencesOutput from './fixtures/references/regex_reference/output'; import regexArrayReferencesInput from './fixtures/references/regex_reference/input_with_array'; @@ -61,10 +59,6 @@ describe('Replace References', () => { const output = replaceReferences(referencesWithinReferencesInput); expect(output).to.deep.eq(referencesWithinReferencesOutput); }); - it('Should return the schema where references inside scripts arrays are replaced', () => { - const output = replaceReferences(scriptReferencesInput); - expect(output).to.deep.eq(scriptReferencesOutput); - }); it('Regex Reference replaced successfully', () => { const output = replaceReferences(regexReferencesInput); expect(output).to.deep.eq(regexReferencesOutput); diff --git a/packages/validation/src/validateField/FieldRestrictionRule.ts b/packages/validation/src/validateField/FieldRestrictionRule.ts index 41117cb7..e27a8261 100644 --- a/packages/validation/src/validateField/FieldRestrictionRule.ts +++ b/packages/validation/src/validateField/FieldRestrictionRule.ts @@ -22,7 +22,6 @@ import type { RestrictionCodeList, RestrictionRange, RestrictionRegex, - RestrictionScript, } from '@overture-stack/lectern-dictionary'; export type FieldRestrictionRuleCodeList = { diff --git a/packages/validation/src/validateSchema/validateSchema.ts b/packages/validation/src/validateSchema/validateSchema.ts index 96a0831c..f42ee488 100644 --- a/packages/validation/src/validateSchema/validateSchema.ts +++ b/packages/validation/src/validateSchema/validateSchema.ts @@ -49,7 +49,7 @@ export const validateSchema = (records: Array, schema: Schema): Test const uniqueFieldMaps = new Map>(); schema.fields.forEach((field) => { - if (field.restrictions?.unique) { + if (field.unique) { uniqueFieldMaps.set(field.name, generateDataSetHashMap(records, [field.name])); } }); diff --git a/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUnique.ts b/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUnique.ts index 364b358b..9f44cf56 100644 --- a/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUnique.ts +++ b/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUnique.ts @@ -3,5 +3,5 @@ import type { SchemaStringField } from '@overture-stack/lectern-dictionary'; export const fieldStringUnique = { name: 'unique-string', valueType: 'string', - restrictions: { unique: true }, + unique: true, } as const satisfies SchemaStringField; diff --git a/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUniqueArray.ts b/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUniqueArray.ts index 3b486aac..a0500b4c 100644 --- a/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUniqueArray.ts +++ b/packages/validation/test/fixtures/fields/schemaRestrictions/fieldStringUniqueArray.ts @@ -4,5 +4,5 @@ export const fieldStringUniqueArray = { name: 'unique-string-array', valueType: 'string', isArray: true, - restrictions: { unique: true }, + unique: true, } as const satisfies SchemaStringField; From 062a7b3b278fbc3c0eb7a893261ec0840677a13f Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Thu, 1 Aug 2024 11:04:34 -0400 Subject: [PATCH 02/21] Field restrictions can be an array of restriction objects --- .../src/metaSchema/dictionarySchemas.ts | 8 +- packages/dictionary/src/references.ts | 49 +++++----- packages/dictionary/src/utils/schemaUtils.ts | 9 +- .../parseValues/matchCodeListFormatting.ts | 12 ++- .../validateField/resolveFieldRestrictions.ts | 95 +++++++------------ 5 files changed, 78 insertions(+), 95 deletions(-) diff --git a/packages/dictionary/src/metaSchema/dictionarySchemas.ts b/packages/dictionary/src/metaSchema/dictionarySchemas.ts index 80c60440..285d4312 100644 --- a/packages/dictionary/src/metaSchema/dictionarySchemas.ts +++ b/packages/dictionary/src/metaSchema/dictionarySchemas.ts @@ -113,7 +113,7 @@ export type SchemaFieldBase = zod.infer; export const SchemaStringField = SchemaFieldBase.merge( zod.object({ valueType: zod.literal(SchemaFieldValueType.Values.string), - restrictions: StringFieldRestrictions.optional(), + restrictions: StringFieldRestrictions.or(StringFieldRestrictions.array()).optional(), }), ).strict(); export type SchemaStringField = zod.infer; @@ -121,7 +121,7 @@ export type SchemaStringField = zod.infer; export const SchemaNumberField = SchemaFieldBase.merge( zod.object({ valueType: zod.literal(SchemaFieldValueType.Values.number), - restrictions: NumberFieldRestrictions.optional(), + restrictions: NumberFieldRestrictions.or(NumberFieldRestrictions.array()).optional(), }), ).strict(); export type SchemaNumberField = zod.infer; @@ -129,7 +129,7 @@ export type SchemaNumberField = zod.infer; export const SchemaIntegerField = SchemaFieldBase.merge( zod.object({ valueType: zod.literal(SchemaFieldValueType.Values.integer), - restrictions: IntegerFieldRestrictions.optional(), + restrictions: IntegerFieldRestrictions.or(IntegerFieldRestrictions.array()).optional(), }), ).strict(); export type SchemaIntegerField = zod.infer; @@ -137,7 +137,7 @@ export type SchemaIntegerField = zod.infer; export const SchemaBooleanField = SchemaFieldBase.merge( zod.object({ valueType: zod.literal(SchemaFieldValueType.Values.boolean), - restrictions: BooleanFieldRestrictions.optional(), + restrictions: BooleanFieldRestrictions.or(BooleanFieldRestrictions.array()).optional(), }), ).strict(); export type SchemaBooleanField = zod.infer; diff --git a/packages/dictionary/src/references.ts b/packages/dictionary/src/references.ts index 8c3a9df3..3d2e716a 100644 --- a/packages/dictionary/src/references.ts +++ b/packages/dictionary/src/references.ts @@ -66,10 +66,10 @@ const internalReplaceSchemaReferences = ( // Process Field Restrictions: if (field.restrictions !== undefined) { // reusable functions to simplify converting - const resolveRestriction = (value: string | string[]) => + const resolveSingleRestrictionReferences = (value: string | string[]) => resolveAllReferences(value, references, discovered, visited); const resolveNoArrays = (value: string | string[], restrictionName: string) => { - const output = resolveRestriction(value); + const output = resolveSingleRestrictionReferences(value); if (Array.isArray(output)) { throw new InvalidReferenceError( `Field '${field.name}' has restriction '${restrictionName}' with a reference '${value}' that resolves to an array. This restriction must be a string.`, @@ -77,33 +77,34 @@ const internalReplaceSchemaReferences = ( } return output; }; + switch (field.valueType) { - // Each field type has different allowed restriction types, we need to handle the reference replacement rules carefully - // to ensure the output schema adhers to the type rules. - // All the checking for undefined prevents us from adding properties with value undefined into the field's ouput JSON - case 'string': - if (field.restrictions.codeList !== undefined) { - field.restrictions.codeList = TypeUtils.asArray(resolveRestriction(field.restrictions.codeList)); - } - if (field.restrictions.regex !== undefined) { - field.restrictions.regex = resolveNoArrays(field.restrictions.regex, 'regex'); - } - if (field.restrictions.script !== undefined) { - field.restrictions.script = TypeUtils.asArray(resolveRestriction(field.restrictions.script)); - } + // - Each field type has different allowed restriction types, we need to handle the reference replacement rules carefully + // to ensure the output schema adhers to the type rules. + // - All the checking for undefined prevents us from adding properties with value undefined into the field's ouput JSON + // - Since the restrictions could be an array, we need to apply these changes to each restrictions object + case 'string': { + const restrictions = TypeUtils.asArray(field.restrictions).map((restrictions) => { + if (restrictions.codeList !== undefined) { + restrictions.codeList = TypeUtils.asArray(resolveSingleRestrictionReferences(restrictions.codeList)); + } + if (restrictions.regex !== undefined) { + restrictions.regex = resolveNoArrays(restrictions.regex, 'regex'); + } + return restrictions; + }); + field.restrictions = Array.isArray(field.restrictions) ? restrictions : restrictions[0]; break; - case 'number': - if (field.restrictions.script !== undefined) { - field.restrictions.script = TypeUtils.asArray(resolveRestriction(field.restrictions.script)); - } + } + case 'number': { break; - case 'integer': - if (field.restrictions.script !== undefined) { - field.restrictions.script = TypeUtils.asArray(resolveRestriction(field.restrictions.script)); - } + } + case 'integer': { break; - case 'boolean': + } + case 'boolean': { break; + } } } }); diff --git a/packages/dictionary/src/utils/schemaUtils.ts b/packages/dictionary/src/utils/schemaUtils.ts index 7f6b5208..0d12aae9 100644 --- a/packages/dictionary/src/utils/schemaUtils.ts +++ b/packages/dictionary/src/utils/schemaUtils.ts @@ -1,3 +1,4 @@ +import { TypeUtils } from '.'; import type { Schema, SchemaField } from '../metaSchema'; /** @@ -6,7 +7,9 @@ import type { Schema, SchemaField } from '../metaSchema'; * @returns */ export const getRequiredFields = (schema: Schema): SchemaField[] => - schema.fields.filter((field) => field.restrictions?.required); + schema.fields.filter((field) => + TypeUtils.asArray(field.restrictions).some((restrictionObject) => restrictionObject?.required), + ); /** * Get an array of fields from this schema that are optional, @@ -15,4 +18,6 @@ export const getRequiredFields = (schema: Schema): SchemaField[] => * @returns */ export const getOptionalFields = (schema: Schema): SchemaField[] => - schema.fields.filter((field) => !field.restrictions?.required); + schema.fields.filter((field) => + TypeUtils.asArray(field.restrictions).every((restrictionObject) => !restrictionObject?.required), + ); diff --git a/packages/validation/src/parseValues/matchCodeListFormatting.ts b/packages/validation/src/parseValues/matchCodeListFormatting.ts index 72767463..322f281d 100644 --- a/packages/validation/src/parseValues/matchCodeListFormatting.ts +++ b/packages/validation/src/parseValues/matchCodeListFormatting.ts @@ -17,7 +17,7 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { SchemaField } from '@overture-stack/lectern-dictionary'; +import { TypeUtils, type SchemaField } from '@overture-stack/lectern-dictionary'; /** * Given a string value, look for any matching values in code list restrictions and return that @@ -36,10 +36,14 @@ import type { SchemaField } from '@overture-stack/lectern-dictionary'; * @returns */ export function matchCodeListFormatting(value: string, fieldDefinition: SchemaField): string { - const { valueType, restrictions } = fieldDefinition; - + const { valueType } = fieldDefinition; if (valueType === 'string') { - const codeList = restrictions?.codeList; + // find all possible codeLists in the restrictions + const codeList = TypeUtils.asArray(fieldDefinition.restrictions) + .map((restrictionObject) => restrictionObject?.codeList) + .filter(TypeUtils.isDefined) + .flat(); + if (Array.isArray(codeList)) { // We have found a code list to compare to! diff --git a/packages/validation/src/validateField/resolveFieldRestrictions.ts b/packages/validation/src/validateField/resolveFieldRestrictions.ts index 1f6952ed..d36ad8fa 100644 --- a/packages/validation/src/validateField/resolveFieldRestrictions.ts +++ b/packages/validation/src/validateField/resolveFieldRestrictions.ts @@ -17,9 +17,41 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { DataRecord, DataRecordValue, SchemaField } from '@overture-stack/lectern-dictionary'; +import { + TypeUtils, + type DataRecord, + type DataRecordValue, + type Defined, + type SchemaField, + type SchemaRestrictions, +} from '@overture-stack/lectern-dictionary'; import type { FieldRestrictionRule } from './FieldRestrictionRule'; +const extractRulesFromRestrictionObject = (restrictions: Defined): FieldRestrictionRule[] => { + const output: FieldRestrictionRule[] = []; + if ('required' in restrictions) { + if (restrictions.required) { + output.push({ type: 'required', rule: restrictions.required }); + } + } + if ('codeList' in restrictions) { + if (Array.isArray(restrictions.codeList)) { + output.push({ type: 'codeList', rule: restrictions.codeList }); + } + } + if ('range' in restrictions) { + if (restrictions.range) { + output.push({ type: 'range', rule: restrictions.range }); + } + } + if ('regex' in restrictions) { + if (restrictions.regex) { + output.push({ type: 'regex', rule: restrictions.regex }); + } + } + return output; +}; + /** * Convert the restrictions found in a SchemaField definition into a list of rules that apply for this specific value * and DataRecord. @@ -35,64 +67,5 @@ export const resolveFieldRestrictions = ( return []; } - switch (field.valueType) { - case 'boolean': { - const output: FieldRestrictionRule[] = []; - if (field.restrictions.required) { - output.push({ type: 'required', rule: field.restrictions.required }); - } - // if (field.restrictions.script) { - // output.push({ type: 'script', rule: asArray(field.restrictions.script) }); - // } - return output; - } - case 'integer': { - const output: FieldRestrictionRule[] = []; - if (Array.isArray(field.restrictions.codeList)) { - output.push({ type: 'codeList', rule: field.restrictions.codeList }); - } - if (field.restrictions.range) { - output.push({ type: 'range', rule: field.restrictions.range }); - } - if (field.restrictions.required) { - output.push({ type: 'required', rule: field.restrictions.required }); - } - // if (field.restrictions.script) { - // output.push({ type: 'script', rule: asArray(field.restrictions.script) }); - // } - return output; - } - case 'number': { - const output: FieldRestrictionRule[] = []; - if (Array.isArray(field.restrictions.codeList)) { - output.push({ type: 'codeList', rule: field.restrictions.codeList }); - } - if (field.restrictions.range) { - output.push({ type: 'range', rule: field.restrictions.range }); - } - if (field.restrictions.required) { - output.push({ type: 'required', rule: field.restrictions.required }); - } - // if (field.restrictions.script) { - // output.push({ type: 'script', rule: asArray(field.restrictions.script) }); - // } - return output; - } - case 'string': { - const output: FieldRestrictionRule[] = []; - if (Array.isArray(field.restrictions.codeList)) { - output.push({ type: 'codeList', rule: field.restrictions.codeList }); - } - if (field.restrictions.regex) { - output.push({ type: 'regex', rule: field.restrictions.regex }); - } - if (field.restrictions.required) { - output.push({ type: 'required', rule: field.restrictions.required }); - } - // if (field.restrictions.script) { - // output.push({ type: 'script', rule: asArray(field.restrictions.script) }); - // } - return output; - } - } + return TypeUtils.asArray(field.restrictions).flatMap(extractRulesFromRestrictionObject); }; From 0164bb268e58fe66c2d08d6000c0d3dd28eb20f9 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Thu, 1 Aug 2024 17:08:41 -0400 Subject: [PATCH 03/21] Add tests for field restrictions of all forms --- .../dictionary/test/dictionaryTypes.spec.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/packages/dictionary/test/dictionaryTypes.spec.ts b/packages/dictionary/test/dictionaryTypes.spec.ts index 2637f8ad..9d5fff43 100644 --- a/packages/dictionary/test/dictionaryTypes.spec.ts +++ b/packages/dictionary/test/dictionaryTypes.spec.ts @@ -125,6 +125,119 @@ describe('Dictionary Types', () => { expect(BooleanFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; }); }); + + describe('Fields', () => { + it('Can have no restrictions', () => { + const fieldString: SchemaField = { + name: 'some-name', + valueType: 'string', + }; + expect(SchemaField.safeParse(fieldString).success, 'String field invalid.').true; + const fieldNumber: SchemaField = { + name: 'some-name', + valueType: 'number', + }; + expect(SchemaField.safeParse(fieldNumber).success, 'Number field invalid.').true; + const fieldInteger: SchemaField = { + name: 'some-name', + valueType: 'integer', + }; + expect(SchemaField.safeParse(fieldInteger).success, 'Integer field invalid.').true; + const fieldBoolean: SchemaField = { + name: 'some-name', + valueType: 'boolean', + }; + expect(SchemaField.safeParse(fieldBoolean).success, 'Boolean field invalid.').true; + }); + it('Can have a single object restriction', () => { + const fieldString: SchemaField = { + name: 'some-name', + valueType: 'string', + restrictions: { + codeList: ['a', 'b', 'c'], + }, + }; + expect(SchemaField.safeParse(fieldString).success, 'String field invalid.').true; + const fieldInteger: SchemaField = { + name: 'some-name', + valueType: 'integer', + restrictions: { + required: true, + }, + }; + expect(SchemaField.safeParse(fieldInteger).success, 'Integer field invalid.').true; + const fieldNumber: SchemaField = { + name: 'some-name', + valueType: 'number', + restrictions: { + required: true, + }, + }; + expect(SchemaField.safeParse(fieldNumber).success, 'Number field invalid.').true; + const fieldBoolean: SchemaField = { + name: 'some-name', + valueType: 'boolean', + restrictions: { + required: true, + }, + }; + expect(SchemaField.safeParse(fieldBoolean).success, 'Boolean field invalid.').true; + }); + it('Can have an array of object restrictions', () => { + const fieldString: SchemaField = { + name: 'some-name', + valueType: 'string', + restrictions: [ + { + regex: '^[\\w]+$', + }, + { + regex: 'hello', + }, + ], + }; + expect(SchemaField.safeParse(fieldString).success, 'String field invalid.').true; + const fieldInteger: SchemaField = { + name: 'some-name', + valueType: 'integer', + restrictions: [ + { + required: true, + }, + { + codeList: [1, 2, 3], + }, + ], + }; + expect(SchemaField.safeParse(fieldInteger).success, 'Integer field invalid.').true; + const fieldNumber: SchemaField = { + name: 'some-name', + valueType: 'number', + restrictions: [ + { + required: true, + }, + { + codeList: [1, 2, 3], + }, + ], + }; + expect(SchemaField.safeParse(fieldNumber).success, 'Number field invalid.').true; + const fieldBoolean: SchemaField = { + name: 'some-name', + valueType: 'boolean', + restrictions: [ + { + required: true, + }, + { + required: false, + }, + ], + }; + expect(SchemaField.safeParse(fieldBoolean).success, 'Boolean field invalid.').true; + }); + }); describe('Schema', () => { it("Can't have repeated field names", () => { const sharedName = 'schemaName'; From 99d27660a6e4c4a13130b12c9365ca2bd6b5b976 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Thu, 1 Aug 2024 18:58:38 -0400 Subject: [PATCH 04/21] Test validation library uses all restrictions in array --- .../fieldStringArrayMultipleRegex.ts | 22 +++++++++ .../fixtures/restrictions/regexFixtures.ts | 5 +- .../resolveFieldRestrictions.spec.ts | 46 +++++++++++++++++++ .../test/validateField/validateField.spec.ts | 16 +++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 packages/validation/test/fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex.ts create mode 100644 packages/validation/test/validateField/resolveFieldRestrictions.spec.ts diff --git a/packages/validation/test/fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex.ts b/packages/validation/test/fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex.ts new file mode 100644 index 00000000..998ddc84 --- /dev/null +++ b/packages/validation/test/fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex.ts @@ -0,0 +1,22 @@ +import type { SchemaStringField } from '@overture-stack/lectern-dictionary'; +import { regexAlphaOnly, regexRepeatedText } from '../../restrictions/regexFixtures'; + +/** + * Example field using an array of multiple restriction objects. + */ +export const fieldStringArrayMultipleRegex = { + name: 'array-multiple-regex-rules', + valueType: 'string', + description: 'String field that must pass multiple regex tests', + meta: { + examples: ['hello', 'byebye', 'thisandthat'], + }, + restrictions: [ + { + regex: regexRepeatedText, + }, + { + regex: regexAlphaOnly, + }, + ], +} as const satisfies SchemaStringField; diff --git a/packages/validation/test/fixtures/restrictions/regexFixtures.ts b/packages/validation/test/fixtures/restrictions/regexFixtures.ts index 61b9178f..b674232b 100644 --- a/packages/validation/test/fixtures/restrictions/regexFixtures.ts +++ b/packages/validation/test/fixtures/restrictions/regexFixtures.ts @@ -1,5 +1,6 @@ import type { RestrictionRegex } from '@overture-stack/lectern-dictionary'; - -export const regexYearMonthDay: RestrictionRegex = '^([12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01]))$'; // Example: "1999-01-31" +export const regexAlphaOnly: RestrictionRegex = '^[A-Za-z]*$'; +export const regexRepeatedText: RestrictionRegex = '(\\w+).*\\1'; export const regexMTGMana: RestrictionRegex = '^({((([WUBRG]|([0-9]|[1-9][0-9]*))(/[WUBRG])?)|(X)|([WUBRG](/[WUBRG])?/[P]))})+$'; +export const regexYearMonthDay: RestrictionRegex = '^([12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01]))$'; // Example: "1999-01-31" diff --git a/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts b/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts new file mode 100644 index 00000000..0eba65e4 --- /dev/null +++ b/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { resolveFieldRestrictions } from '../../src/validateField/resolveFieldRestrictions'; +import { fieldStringManyRestrictions } from '../fixtures/fields/multipleRestrictions/fieldStringManyRestrictions'; +import { fieldStringNoRestriction } from '../fixtures/fields/noRestrictions/fieldStringNoRestriction'; +import { fieldStringArrayMultipleRegex } from '../fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex'; + +describe('Field - resolveFieldRestrictions', () => { + it('Returns empty array when there are no restrictions', () => { + const restrictions = resolveFieldRestrictions(undefined, {}, fieldStringNoRestriction); + expect(restrictions.length).equal(0); + }); + it('Returns array with rules matching restrictions in a single restrictions object', () => { + const restrictions = resolveFieldRestrictions(undefined, {}, fieldStringManyRestrictions); + expect(restrictions.length).equal(3); + const regexRestriction = restrictions.find((restriction) => restriction.type === 'regex'); + expect(regexRestriction).not.undefined; + const requiredRestriction = restrictions.find((restriction) => restriction.type === 'required'); + expect(requiredRestriction).not.undefined; + const codeListRestriction = restrictions.find((restriction) => restriction.type === 'codeList'); + expect(codeListRestriction).not.undefined; + }); + it('Returns array with rules from all objects in restrictions array', () => { + const restrictions = resolveFieldRestrictions(undefined, {}, fieldStringArrayMultipleRegex); + expect(restrictions.length).equal(2); + expect(restrictions.every((restriction) => restriction.type === 'regex')).true; + }); +}); diff --git a/packages/validation/test/validateField/validateField.spec.ts b/packages/validation/test/validateField/validateField.spec.ts index ecaafa94..2078967e 100644 --- a/packages/validation/test/validateField/validateField.spec.ts +++ b/packages/validation/test/validateField/validateField.spec.ts @@ -32,6 +32,7 @@ import { fieldStringArrayRequired } from '../fixtures/fields/simpleRestrictions/ import { fieldStringCodeList } from '../fixtures/fields/simpleRestrictions/string/fieldStringCodeList'; import { fieldStringRegex } from '../fixtures/fields/simpleRestrictions/string/fieldStringRegex'; import { fieldStringRequired } from '../fixtures/fields/simpleRestrictions/string/fieldStringRequired'; +import { fieldStringArrayMultipleRegex } from '../fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex'; const emptyDataRecord = {}; @@ -395,5 +396,20 @@ describe('Field - validateField', () => { expect(codeListError).exist; }); }); + + describe('String with array of restrictions, multiple regex', () => { + it('Valid with value that matches both regexs', () => { + // from examples in field: ['hello', 'byebye', 'thisandthat'] + expect(validateField('hello', emptyDataRecord, fieldStringArrayMultipleRegex).valid).true; + expect(validateField('byebye', emptyDataRecord, fieldStringArrayMultipleRegex).valid).true; + expect(validateField('thisandthat', emptyDataRecord, fieldStringArrayMultipleRegex).valid).true; + }); + it('Invalid with value that one or both regexes', () => { + // has non alpha characters + expect(validateField('hello123', emptyDataRecord, fieldStringArrayMultipleRegex).valid).false; + // no repeated characters + expect(validateField('asdf', emptyDataRecord, fieldStringArrayMultipleRegex).valid).false; + }); + }); }); }); From 33c8a49f41983d5ffc9d7d06002ff2e57bf84225 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Thu, 1 Aug 2024 19:47:34 -0400 Subject: [PATCH 05/21] Add document to detail major version changes --- docs/lectern-2.0-changes.md | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/lectern-2.0-changes.md diff --git a/docs/lectern-2.0-changes.md b/docs/lectern-2.0-changes.md new file mode 100644 index 00000000..467a5a01 --- /dev/null +++ b/docs/lectern-2.0-changes.md @@ -0,0 +1,46 @@ +# Lectern Version 2 Changes + +The release of Lectern 2.0 brings some important upgrades to the Lectern service, published tooling, and importantly the Lectern Dictionary specification. Most of these changes are backwards compatible but some breaking changes have been introduced. A section at the end describes a process for upgrading from Lectern version 1 to 2. + +## Summary of Changes + +### Meta-Schema Updates + +- Script restrictions have been removed. +- The `unique` restriction has been moved to be a property of the field, not a restriction. +- Conditional restrictions have been added. +- Fields now accept an array of restrictions, allowing multiple regex and codeList restrictions applied to a specific field. + +### Lectern JS Client Updates + +- The client package has been moved into the same organization as other Overture software. + - Now published at `@overture-stack/lectern-client`. + - Old package located at `@overturebio-stack/lectern-client` is marked as deprecated. +- API Changes + - Processing functions renamed to match validation and parsing functions + - Updated interface for Lectern Server REST client + - Exposes dictionary meta-schema validation, data parsing, and data validation functions + +### New Published Lectern TS Packages + +- [Lectern Dictionary](../packages/dictionary/) + - Meta-Schema for validating Lectern Dictionaries + - Functions to calculate diffs between dictionaries + - Functions to manage dictionary refernces +- [Lectern Validation](../packages/validation/) + - Parse raw string values into typed data to match a Lectern Dictionary + - Validate data using a Lectern Dictionary + +## Upgrading from Lectern 1 + +### Lectern Server Migration + +Placeholder + +### Updating Lectern Dictionaries + +Placeholder + +### Upgrading Lectern Client + +Placeholder From 448ad5fb2f1c9206517f2ccc5db7927227310d64 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 4 Aug 2024 15:52:17 -0400 Subject: [PATCH 06/21] Adds conditional restrictions to dictionary restriction schema - performs code list and regex reference replacement recursively through conditional restrictions - performs reference replacement recursively through meta objects - applies conditional restriction checks when resolving restriction rules for each field - WIP: still requires many tests --- docs/dictionary-reference.md | 38 +- docs/lectern-2.0-changes.md | 2 + .../src/metaSchema/dictionarySchemas.ts | 63 ++-- .../src/metaSchema/referenceSchemas.ts | 2 +- .../src/metaSchema/restrictionsSchemas.ts | 110 +++++- packages/dictionary/src/references.ts | 343 ++++++++++++------ .../src/utils/resolveRestrictions.ts | 51 --- packages/dictionary/src/utils/schemaUtils.ts | 16 +- packages/dictionary/src/utils/typeUtils.ts | 77 ++++ .../dictionary/test/dictionaryTypes.spec.ts | 2 + packages/dictionary/test/references.spec.ts | 75 ++-- .../parseValues/matchCodeListFormatting.ts | 25 +- .../validation/src/parseValues/parseValues.ts | 4 +- .../validation/src/utils/isValidValueType.ts | 4 +- .../src/utils/resultForArayTestCase.ts | 29 ++ packages/validation/src/utils/typeUtils.ts | 95 ----- .../src/validateField/conditions/index.ts | 20 + .../conditions/testConditionalRestriction.ts | 111 ++++++ .../conditions/testMatchCodeList.ts | 49 +++ .../conditions/testMatchCount.ts | 48 +++ .../conditions/testMatchExists.ts | 60 +++ .../conditions/testMatchRange.ts | 34 ++ .../conditions/testMatchRegex.ts | 35 ++ .../conditions/testMatchValue.ts | 63 ++++ .../src/validateField/restrictions/index.ts | 1 + .../resolveFieldRestrictions.ts | 64 +++- .../validateField/restrictions/testRegex.ts | 19 +- .../src/validateField/validateField.ts | 2 +- .../conditions/testMatchExists.spec.ts | 67 ++++ .../resolveFieldRestrictions.spec.ts | 4 +- 30 files changed, 1154 insertions(+), 359 deletions(-) delete mode 100644 packages/dictionary/src/utils/resolveRestrictions.ts create mode 100644 packages/validation/src/utils/resultForArayTestCase.ts delete mode 100644 packages/validation/src/utils/typeUtils.ts create mode 100644 packages/validation/src/validateField/conditions/index.ts create mode 100644 packages/validation/src/validateField/conditions/testConditionalRestriction.ts create mode 100644 packages/validation/src/validateField/conditions/testMatchCodeList.ts create mode 100644 packages/validation/src/validateField/conditions/testMatchCount.ts create mode 100644 packages/validation/src/validateField/conditions/testMatchExists.ts create mode 100644 packages/validation/src/validateField/conditions/testMatchRange.ts create mode 100644 packages/validation/src/validateField/conditions/testMatchRegex.ts create mode 100644 packages/validation/src/validateField/conditions/testMatchValue.ts rename packages/validation/src/validateField/{ => restrictions}/resolveFieldRestrictions.ts (50%) create mode 100644 packages/validation/test/validateField/conditions/testMatchExists.spec.ts diff --git a/docs/dictionary-reference.md b/docs/dictionary-reference.md index 64bf0176..d065e061 100644 --- a/docs/dictionary-reference.md +++ b/docs/dictionary-reference.md @@ -96,16 +96,16 @@ The restrictions property of a field can have a value that is either a single re The full list of available restrictions are: -| Restriction | Used with Field Types | Type | Description | Examples | -| ----------- | ----------------------------- | --------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `codeList` | `integer`, `number`, `string` | Array of type of the field | An array of values of the type matching this field. Data provided for this field must have one of the values in this list. | `["Weak", "Average", "Strong"]` | -| `compare` | all | [ComparedFieldsRule](#comparedfieldsrule-data-structure) object | Enforces that this field has a value based on the provided value in another field. Examples would be to ensure that the two values are not equal, or for numeric values ensure one is greater than the other. | `{ "fields": ["age_at_diagnosis"], "relation": "greaterThanOrEqual" }` Ensure that a field such as `age_at_death` is greater than the provided `age_at_diagnosis` | -| `count` | Array fields of all types | `integer` or [`RangeRule`](#rangerule-data-structure) object | Enfroces the number of entries in an array. Can specify an exact array size, or provide range rules that set maximum and minimum counts. | `7` or `{"min": 5, "max": 10}` | -| `empty` | all | | Requires that no value is provided. This is useful when used on a [conditional restriction](#conditional-restrictions) in order to prevent a value from being given when the condition is `true`. For an array field with this restriction, an empty array is a valid value for this restriction. | n/a | -| `range` | `integer`, `number` | | Uses a [RangeRule](#rangerule-data-structure) object to define minimum and/or maximum values for this field | `{"min": 5}`, `{"exclusiveMax": 50}`, `{"exclusiveMin": 5, "max": 50}` | -| `regex` | `string` | | A regular expression that all values must match. | `^[a-z0-9]+$` | -| `required` | all | | A value must be provided, missing/undefined values will fail validation. Empty strings will not be accepted, though `0` (for `number` and `int` fields) and `false` (for `boolean` fields) are accepted. An array field with this restriction must have at least one entry. | `true`, `false` | -| `unique` | all | | When a field has the `unique` restriction, each record must have a distinct value for this field. Uniqueness tests are case sensitive, so `Abc` and `abc` are both distinct values. This restriction is only applied when a collection of records are tested together, ensuring that no two records in that collection share a value. | `true`, `false` | +| Restriction | Used with Field Types | Type | Description | Examples | +| ----------- | ----------------------------- | --------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `codeList` | `integer`, `number`, `string` | Array of type of the field | An array of values of the type matching this field. Data provided for this field must have one of the values in this list. | `["Weak", "Average", "Strong"]` | +| `compare` | all | [ComparedFieldsRule](#comparedfieldsrule-data-structure) object | Enforces that this field has a value based on the provided value in another field. Examples would be to ensure that the two values are not equal, or for numeric values ensure one is greater than the other. | `{ "fields": ["age_at_diagnosis"], "relation": "greaterThanOrEqual" }` Ensure that a field such as `age_at_death` is greater than the provided `age_at_diagnosis` | +| `count` | Array fields of all types | `integer` or [`RangeRule`](#rangerule-data-structure) object | Enfroces the number of entries in an array. Can specify an exact array size, or provide range rules that set maximum and minimum counts. | `7` or `{"min": 5, "max": 10}` | +| `empty` | all | | Requires that no value is provided. This is useful when used on a [conditional restriction](#conditional-restrictions) in order to prevent a value from being given when the condition is `true`. For an array field with this restriction, an empty array is a valid value for this restriction. | n/a | +| `range` | `integer`, `number` | | Uses a [RangeRule](#rangerule-data-structure) object to define minimum and/or maximum values for this field | `{"min": 5}`, `{"exclusiveMax": 50}`, `{"exclusiveMin": 5, "max": 50}` | +| `regex` | `string` | | A regular expression that all values must match. | `^[a-z0-9]+$` | +| `required` | all | | A value must be provided, missing/undefined values will fail validation. Empty strings will not be accepted, though `0` (for `number` and `int` fields) and `false` (for `boolean` fields) are accepted. An array field with this restriction must have at least one entry. | `true`, `false` | +| `unique` | all | | When a field has the `unique` restriction, each record must have a distinct value for this field. Uniqueness tests are case insensitive, so `Abc` and `abc` are treated as the same value. This restriction is only applied when a collection of records are tested together, ensuring that no two records in that collection share a value. | `true`, `false` | #### Conditional Restrictions @@ -170,15 +170,15 @@ A requirement condition is defined by providing a field name or list of field na > ``` ##### Conditional Match Rules -| Property | Used with Field Types | Type | Description | Example | -| ---------- | --------------------- | -------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `codeList` | all | Array of type of specified fields | A list of values that the field could match. This rule passes when the specified field's value can be found in this list. | `["value_one", "value_two"]` | -| `compare` | all | [ComparedFieldsRule](#comparedfieldsrule-data-structure) | Compare the value of the specified fields to values of another field (or set of fields). This can be configured to check if the fields match, dont match, and for numeric fields can check if the field is Greater or Lesser than. | `{ "fields": ["compared_to_field"], "relation": "equal" }` | -| `count` | Array type fields | Integer, or [RangeRule](#rangerule-data-structure) | Matches the number of values in an array field. This condition can be provided as a number, in which case this condition matches if the array is that exact length. This condition can be provided as a Range object as well, in which case it will match if the number of elements in the array pass the minimum and maximum conditions provided in the condition. | `2` - Field must have exactly 2 elements.
`{ max: 10 }` - Field must have no more than 10 items. | -| `exists` | all | Boolean | This condition requires a field to either have a value or have no value. When the `exists` condition is set to `true`, the field must have a value. When `exists` is sdet to `false`, the field must have no value. For array fields, `exists=false` only matches when the array is completely empty, and `exists=true` passes if the array has 1 or more values - `arrayCase` has no interaction with the `exists` condition. | `true` | -| `range` | `number`, `integer` | [RangeRule](#rangerule-data-structure) | Maximum and minimum value conditions that a numeric field must pass. | `{ min: 5, exclusiveMax: 10 }` Represents an integer from 5-9. | -| `regex` | `string` | String (Regular Expression) | A regular expression pattern that the value must match. | `^NCIT:C\d+$` Value must match an NCI Thesaurus ID. | -| `value` | all | Type of specified fields | Field value exactly matches the value of the specified field. Strings are matched case sensitive. | `some_value` | +| Property | Used with Field Types | Type | Description | Example | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `codeList` | all | Array of type of specified fields | A list of values that the field could match. This rule passes when the specified field's value can be found in this list. | `["value_one", "value_two"]` | +| `count` | Array type fields | Integer, or [RangeRule](#rangerule-data-structure) | Matches the number of values in an array field. This condition can be provided as a number, in which case this condition matches if the array is that exact length. This condition can be provided as a Range object as well, in which case it will match if the number of elements in the array pass the minimum and maximum conditions provided in the condition. | `2` - Field must have exactly 2 elements.
`{ max: 10 }` - Field must have no more than 10 items. | +| `exists` | all | Boolean | This condition requires a field to either have a value or have no value. When the `exists` condition is set to `true`, the field must have a value. When `exists` is sdet to `false`, the field must have no value. For array fields, `exists=false` only matches when the array is completely empty, and `exists=true` passes if the array has 1 or more values - `arrayCase` has no interaction with the `exists` condition. | `true` | +| `range` | `number`, `integer` | [RangeRule](#rangerule-data-structure) | Maximum and minimum value conditions that a numeric field must pass. | `{ min: 5, exclusiveMax: 10 }` Represents an integer from 5-9. | +| `regex` | `string` | String (Regular Expression) | A regular expression pattern that the value must match. | `^NCIT:C\d+$` Value must match an NCI Thesaurus ID. | +| `value` | all | Type of specified fields | Field value matches the value of the specified field. Strings are matched case insensitive. When arrays are matched, the order of their | +| elements is ignored - a field matches this condition if the elements in field are the same elements as in the value match rule. For example, the rule `['abc', 'def']` matches the value `['def', 'abc']` but does not match `['abc', 'def', 'ghi']`. | `some_value`, `[1, 2, 3]` | ### Meta Data Structure diff --git a/docs/lectern-2.0-changes.md b/docs/lectern-2.0-changes.md index 467a5a01..9f389539 100644 --- a/docs/lectern-2.0-changes.md +++ b/docs/lectern-2.0-changes.md @@ -10,6 +10,7 @@ The release of Lectern 2.0 brings some important upgrades to the Lectern service - The `unique` restriction has been moved to be a property of the field, not a restriction. - Conditional restrictions have been added. - Fields now accept an array of restrictions, allowing multiple regex and codeList restrictions applied to a specific field. +- Regex restrictions can be a single string or array of strings, allowing multiple regular expressions to be applied to a field's value(s). ### Lectern JS Client Updates @@ -21,6 +22,7 @@ The release of Lectern 2.0 brings some important upgrades to the Lectern service - Updated interface for Lectern Server REST client - Exposes dictionary meta-schema validation, data parsing, and data validation functions + ### New Published Lectern TS Packages - [Lectern Dictionary](../packages/dictionary/) diff --git a/packages/dictionary/src/metaSchema/dictionarySchemas.ts b/packages/dictionary/src/metaSchema/dictionarySchemas.ts index 285d4312..a04020c5 100644 --- a/packages/dictionary/src/metaSchema/dictionarySchemas.ts +++ b/packages/dictionary/src/metaSchema/dictionarySchemas.ts @@ -21,13 +21,13 @@ import { z as zod } from 'zod'; import allUnique from '../utils/allUnique'; import { ReferenceTag, References } from './referenceSchemas'; import { + ConditionalRestriction, RestrictionCodeListInteger, RestrictionCodeListNumber, RestrictionCodeListString, RestrictionIntegerRange, RestrictionNumberRange, RestrictionRegex, - RestrictionScript, } from './restrictionsSchemas'; /** @@ -66,14 +66,23 @@ export type SchemaFieldValueType = zod.infer; /* ****************************** * * Field Type Restriction Objects * * ****************************** */ -export const StringFieldRestrictions = zod +export const BooleanFieldRestrictions = zod.object({ required: zod.boolean() }).partial(); +export type BooleanFieldRestrictions = zod.infer; + +const BooleanFieldRestrictionsObject = BooleanFieldRestrictions.or(ConditionalRestriction(BooleanFieldRestrictions)); +export type BooleanFieldRestrictionsObject = zod.infer; + +export const IntegerFieldRestrictions = zod .object({ - codeList: RestrictionCodeListString.or(ReferenceTag), + codeList: RestrictionCodeListInteger.or(ReferenceTag), required: zod.boolean(), - regex: RestrictionRegex.or(ReferenceTag), + range: RestrictionIntegerRange, }) .partial(); -export type StringFieldRestrictions = zod.infer; +export type IntegerFieldRestrictions = zod.infer; + +const IntegerFieldRestrictionsObject = IntegerFieldRestrictions.or(ConditionalRestriction(IntegerFieldRestrictions)); +export type IntegerFieldRestrictionsObject = zod.infer; export const NumberFieldRestrictions = zod .object({ @@ -84,17 +93,29 @@ export const NumberFieldRestrictions = zod .partial(); export type NumberFieldRestrictions = zod.infer; -export const IntegerFieldRestrictions = zod +const NumberFieldRestrictionsObject = NumberFieldRestrictions.or(ConditionalRestriction(NumberFieldRestrictions)); +export type NumberFieldRestrictionsObject = zod.infer; + +export const StringFieldRestrictions = zod .object({ - codeList: RestrictionCodeListInteger.or(ReferenceTag), + codeList: RestrictionCodeListString.or(ReferenceTag), required: zod.boolean(), - range: RestrictionIntegerRange, + regex: RestrictionRegex.or(ReferenceTag), + // TODO: regex can be optionally be an array. this would simplify resolving references and allow multiple regex conditions in a single object }) .partial(); -export type IntegerFieldRestrictions = zod.infer; +export type StringFieldRestrictions = zod.infer; -export const BooleanFieldRestrictions = zod.object({ required: zod.boolean() }).partial(); -export type BooleanFieldRestrictions = zod.infer; +const StringFieldRestrictionsObject = StringFieldRestrictions.or(ConditionalRestriction(StringFieldRestrictions)); +export type StringFieldRestrictionsObject = zod.infer; + +export const AnyFieldRestrictions = zod.union([ + BooleanFieldRestrictions, + IntegerFieldRestrictions, + NumberFieldRestrictions, + StringFieldRestrictions, +]); +export type AnyFieldRestrictions = zod.infer; /* ***************** * * Field Definitions * @@ -110,18 +131,18 @@ export const SchemaFieldBase = zod .strict(); export type SchemaFieldBase = zod.infer; -export const SchemaStringField = SchemaFieldBase.merge( +export const SchemaBooleanField = SchemaFieldBase.merge( zod.object({ - valueType: zod.literal(SchemaFieldValueType.Values.string), - restrictions: StringFieldRestrictions.or(StringFieldRestrictions.array()).optional(), + valueType: zod.literal(SchemaFieldValueType.Values.boolean), + restrictions: BooleanFieldRestrictionsObject.or(BooleanFieldRestrictionsObject.array()).optional(), }), ).strict(); -export type SchemaStringField = zod.infer; +export type SchemaBooleanField = zod.infer; export const SchemaNumberField = SchemaFieldBase.merge( zod.object({ valueType: zod.literal(SchemaFieldValueType.Values.number), - restrictions: NumberFieldRestrictions.or(NumberFieldRestrictions.array()).optional(), + restrictions: NumberFieldRestrictionsObject.or(NumberFieldRestrictionsObject.array()).optional(), }), ).strict(); export type SchemaNumberField = zod.infer; @@ -129,18 +150,18 @@ export type SchemaNumberField = zod.infer; export const SchemaIntegerField = SchemaFieldBase.merge( zod.object({ valueType: zod.literal(SchemaFieldValueType.Values.integer), - restrictions: IntegerFieldRestrictions.or(IntegerFieldRestrictions.array()).optional(), + restrictions: IntegerFieldRestrictionsObject.or(IntegerFieldRestrictionsObject.array()).optional(), }), ).strict(); export type SchemaIntegerField = zod.infer; -export const SchemaBooleanField = SchemaFieldBase.merge( +export const SchemaStringField = SchemaFieldBase.merge( zod.object({ - valueType: zod.literal(SchemaFieldValueType.Values.boolean), - restrictions: BooleanFieldRestrictions.or(BooleanFieldRestrictions.array()).optional(), + valueType: zod.literal(SchemaFieldValueType.Values.string), + restrictions: StringFieldRestrictionsObject.or(StringFieldRestrictionsObject.array()).optional(), }), ).strict(); -export type SchemaBooleanField = zod.infer; +export type SchemaStringField = zod.infer; export const SchemaField = zod.discriminatedUnion('valueType', [ SchemaStringField, diff --git a/packages/dictionary/src/metaSchema/referenceSchemas.ts b/packages/dictionary/src/metaSchema/referenceSchemas.ts index 9a8daf78..2d3e23bd 100644 --- a/packages/dictionary/src/metaSchema/referenceSchemas.ts +++ b/packages/dictionary/src/metaSchema/referenceSchemas.ts @@ -23,7 +23,7 @@ export const ReferenceTag = zod .string() .regex( RegExp('^#(/[-_A-Za-z0-9]+)+$'), - 'Not formatted as a valid reference tag. References must be formatted like `#/path/to/reference', + 'Not formatted as a valid reference tag. References must be formatted like `#/path/to/reference`', ); export type ReferenceTag = zod.infer; diff --git a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts index f9835e4e..95669582 100644 --- a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts +++ b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts @@ -17,7 +17,7 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { z as zod } from 'zod'; +import { z as zod, type ZodSchema } from 'zod'; import { ReferenceTag } from './referenceSchemas'; import type { Values } from '../types'; @@ -45,7 +45,12 @@ export type RestrictionCodeListNumber = zod.infer; -export type RestrictionCodeList = RestrictionCodeListString | RestrictionCodeListNumber | RestrictionCodeListInteger; +export const RestrictionCodeList = zod.union([ + RestrictionCodeListString, + RestrictionCodeListNumber, + RestrictionCodeListInteger, +]); +export type RestrictionCodeList = zod.infer; export const RestrictionNumberRange = zod .object({ @@ -98,7 +103,7 @@ export const RestrictionIntegerRange = zod export const RestrictionRange = RestrictionNumberRange; export type RestrictionRange = zod.infer; -export const RestrictionRegex = zod.string().superRefine((data, context) => { +const RegexString = zod.string().superRefine((data, context) => { try { // Attempt to build regexp from the value RegExp(data); @@ -111,4 +116,103 @@ export const RestrictionRegex = zod.string().superRefine((data, context) => { }); } }); +export const RestrictionRegex = RegexString.or(RegexString.array()); export type RestrictionRegex = zod.infer; + +/* + * Conditions for ConditionalRestrictionTest + */ +export const StringFieldValue = zod.string(); +export const StringArrayFieldValue = StringFieldValue.array(); +export const NumberFieldValue = zod.number(); +export const NumberArrayFieldValue = NumberFieldValue.array(); +export const IntegerFieldValue = zod.number().int(); +export const IntegerArrayFieldValue = IntegerFieldValue.array(); +export const BooleanFieldValue = zod.boolean(); +export const BooleanArrayFieldValue = BooleanFieldValue.array(); + +export const FieldValue = zod.union([ + BooleanFieldValue, + BooleanArrayFieldValue, + IntegerFieldValue, + IntegerArrayFieldValue, + NumberFieldValue, + NumberArrayFieldValue, + StringFieldValue, + StringArrayFieldValue, +]); +export type FieldValue = zod.infer; + +export const ArrayTestCase = zod.enum(['all', 'any', 'none']); +export type ArrayTestCase = zod.infer; + +export const ARRAY_TEST_CASE_DEFAULT = ArrayTestCase.Values.all; + +export const MatchRuleCodeList = RestrictionCodeList; +export type MatchRuleCodeList = zod.infer; + +export const MatchRuleCount = zod.number().or(RestrictionIntegerRange); +export type MatchRuleCount = zod.infer; + +export const MatchRuleExists = zod.boolean(); +export type MatchRuleExists = zod.infer; + +export const MatchRuleRange = RestrictionRange; +export type MatchRuleRange = zod.infer; + +export const MatchRuleRegex = RestrictionRegex; +export type MatchRuleRegex = zod.infer; + +export const MatchRuleValue = FieldValue; +export type MatchRuleValue = zod.infer; + +export const ConditionMatchRule = zod + .object({ + codeList: MatchRuleCodeList, + count: MatchRuleCount, + exists: MatchRuleExists, + range: MatchRuleRange, + regex: MatchRuleRegex, + value: MatchRuleValue, + }) + .partial(); +type ConditionMatchRule = zod.infer; + +export const RestrictionCondition = zod.object({ + fields: zod.string().array(), + match: ConditionMatchRule.array(), + case: ArrayTestCase.optional(), + arrayFieldCase: ArrayTestCase.optional(), +}); +export type RestrictionCondition = zod.infer; + +export const ConditionalRestrictionTest = zod.object({ + conditions: zod.array(RestrictionCondition), + case: ArrayTestCase, +}); +export type ConditionalRestrictionTest = zod.infer; + +export type ConditionalRestriction = { + if: ConditionalRestrictionTest; + then?: + | TRestrictionObject + | ConditionalRestriction + | (TRestrictionObject | ConditionalRestriction)[]; + else?: + | TRestrictionObject + | ConditionalRestriction + | (TRestrictionObject | ConditionalRestriction)[]; +}; +export const ConditionalRestriction = ( + restrictionsSchema: ZodSchema, +): ZodSchema> => { + const restrictionOrConditional = zod.union([ + restrictionsSchema, + zod.lazy(() => ConditionalRestriction(restrictionsSchema)), + ]); + return zod.object({ + if: ConditionalRestrictionTest, + then: restrictionOrConditional.or(restrictionOrConditional).optional(), + else: restrictionOrConditional.or(restrictionOrConditional).optional(), + }); +}; diff --git a/packages/dictionary/src/references.ts b/packages/dictionary/src/references.ts index 3d2e716a..2982d1a4 100644 --- a/packages/dictionary/src/references.ts +++ b/packages/dictionary/src/references.ts @@ -17,102 +17,45 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import * as immer from 'immer'; import { cloneDeep, get, isObject, omit } from 'lodash'; -import { Dictionary, ReferenceArray, ReferenceTag, ReferenceValue, References, Schema, TypeUtils } from '.'; +import { + Dictionary, + DictionaryMeta, + ReferenceArray, + ReferenceTag, + ReferenceValue, + References, + Schema, + TypeUtils, + type SchemaField, + type StringFieldRestrictionsObject, +} from '.'; import { InvalidReferenceError } from './errors'; +import { isNumberArray, isStringArray } from './utils/typeUtils'; // This is the union of all schema sections that could have reference values type OutputReferenceValues = ReferenceArray | ReferenceValue; type DiscoveredMap = Map; +const createDiscoveredMap = () => new Map(); + type VisitedSet = Set; +const createVisitedSet = () => new Set(); + +const isReferenceTag = (input: unknown): input is ReferenceTag => ReferenceTag.safeParse(input).success; /** - * Logic for replacing references in an individual schema. + * Convert a ReferenceTag value into a dot separated path that can be used by lodash _.get to find the value + * in the references object. * - * This is used by the replaceReferences method that replaces references in ALL schemas. By allowing the caller - * to provide the discovered/visited objects that are used in the recursive logic we can let the replaceReferences - * method reuse the same dictionaries across all schemas. - * @param schema - * @param references - * @param discovered - * @param visited - * @returns + * @example + * const referenceTag = `#/some/path`; + * + * const path = referenceTagToObjectPath(referenceTag); + * // some.path + * + * const referenceValue = _.get(references, path); */ -const internalReplaceSchemaReferences = ( - schema: Schema, - references: References, - discovered: DiscoveredMap, - visited: VisitedSet, -): Schema => { - const clone = cloneDeep(schema); - - clone.fields.forEach((field) => { - // Process Field Meta: - if (field.meta !== undefined) { - for (const key in field.meta) { - const value = field.meta[key]; - if (isReferenceTag(value)) { - const replacement = resolveReference(value, references, discovered, visited); - if (Array.isArray(replacement)) { - throw new InvalidReferenceError( - `Field '${field.name}' has meta field '${key}' with a reference '${value}' that resolves to an array. Meta fields must be string, number, or boolean.`, - ); - } - } - } - } - // Process Field Restrictions: - if (field.restrictions !== undefined) { - // reusable functions to simplify converting - const resolveSingleRestrictionReferences = (value: string | string[]) => - resolveAllReferences(value, references, discovered, visited); - const resolveNoArrays = (value: string | string[], restrictionName: string) => { - const output = resolveSingleRestrictionReferences(value); - if (Array.isArray(output)) { - throw new InvalidReferenceError( - `Field '${field.name}' has restriction '${restrictionName}' with a reference '${value}' that resolves to an array. This restriction must be a string.`, - ); - } - return output; - }; - - switch (field.valueType) { - // - Each field type has different allowed restriction types, we need to handle the reference replacement rules carefully - // to ensure the output schema adhers to the type rules. - // - All the checking for undefined prevents us from adding properties with value undefined into the field's ouput JSON - // - Since the restrictions could be an array, we need to apply these changes to each restrictions object - case 'string': { - const restrictions = TypeUtils.asArray(field.restrictions).map((restrictions) => { - if (restrictions.codeList !== undefined) { - restrictions.codeList = TypeUtils.asArray(resolveSingleRestrictionReferences(restrictions.codeList)); - } - if (restrictions.regex !== undefined) { - restrictions.regex = resolveNoArrays(restrictions.regex, 'regex'); - } - return restrictions; - }); - field.restrictions = Array.isArray(field.restrictions) ? restrictions : restrictions[0]; - break; - } - case 'number': { - break; - } - case 'integer': { - break; - } - case 'boolean': { - break; - } - } - } - }); - - return clone; -}; - -const isReferenceTag = (input: unknown): input is ReferenceTag => ReferenceTag.safeParse(input).success; const referenceTagToObjectPath = (value: ReferenceTag): string => { try { return value.split('/').slice(1).join('.'); @@ -121,6 +64,33 @@ const referenceTagToObjectPath = (value: ReferenceTag): string => { } }; +/** + * For an array of strings, replace all values that are ReferenceTags with the corresponding reference value. + */ +const resolveArrayReferences = ( + value: string[], + references: References, + discovered: DiscoveredMap, + visited: VisitedSet, +): string[] => + value.flatMap((item) => (isReferenceTag(item) ? resolveReference(item, references, discovered, visited) : item)); + +const transformOneOrMany = (data: TInput | TInput[], transform: (item: TInput) => TOutput) => { + if (Array.isArray(data)) { + return data.map(transform); + } else { + return transform(data); + } +}; + +/** + * + * @param value + * @param references + * @param discovered + * @param visited + * @returns + */ const resolveAllReferences = ( value: string | string[], references: References, @@ -128,9 +98,7 @@ const resolveAllReferences = ( visited: VisitedSet, ): string | string[] => { if (Array.isArray(value)) { - return value.flatMap((item) => - isReferenceTag(item) ? resolveReference(item, references, discovered, visited) : item, - ); + return resolveArrayReferences(value, references, discovered, visited); } if (isReferenceTag(value)) { return resolveReference(value, references, discovered, visited); @@ -171,9 +139,7 @@ const resolveReference = ( throw new InvalidReferenceError(`No reference found for provided tag '${tag}'.`); } if (Array.isArray(replacement)) { - const output = replacement.flatMap((item) => - isReferenceTag(item) ? resolveReference(item, references, discovered, visited) : item, - ); + const output = resolveArrayReferences(replacement, references, discovered, visited); discovered.set(tag, output); return output; } else if (isReferenceTag(replacement)) { @@ -187,6 +153,177 @@ const resolveReference = ( } }; +/** + * Warning: This mutates the meta argument object. This is meant for use within this module only and should not be exported. + */ +const recursiveReplaceMetaReferences = ( + meta: DictionaryMeta, + references: References, + discovered: DiscoveredMap, + visited: VisitedSet, +): DictionaryMeta => { + for (const [key, value] of Object.entries(meta)) { + if (isStringArray(value)) { + // value is an array of strings, we want to check if any of the values are reference tags and replace them if they are. + const replacement = resolveArrayReferences(value, references, discovered, visited); + meta[key] = replacement; + } else if (isReferenceTag(value)) { + // value is a reference tag, we replace it with the corresponding reference. + const replacement = resolveReference(value, references, discovered, visited); + meta[key] = replacement; + } else if (typeof value === 'object' && !Array.isArray(value)) { + // value is a nested meta object, send it into this function! recursion! + const replacement = recursiveReplaceMetaReferences(value, references, discovered, visited); + meta[key] = replacement; + } + } + + return meta; +}; + +/** + * Warning: This mutates the restrictionsObject argument object. This is meant for use within this module only and should not be exported. + */ +const replaceReferencesInStringRestrictionsObject = ( + restrictionsObject: StringFieldRestrictionsObject, + references: References, + discovered: DiscoveredMap, + visited: VisitedSet, +) => { + if ('if' in restrictionsObject) { + // Do replacements inside the if conditions + restrictionsObject.if.conditions = restrictionsObject.if.conditions.map((condition) => { + condition.match = condition.match.map((match) => { + if (match.codeList && !isNumberArray(match.codeList)) { + match.codeList = TypeUtils.asArray(resolveAllReferences(match.codeList, references, discovered, visited)); + } + if (typeof match.value === 'string' || isStringArray(match.value)) { + match.value = resolveAllReferences(match.value, references, discovered, visited); + } + if (match.regex) { + match.regex = TypeUtils.asArray(resolveAllReferences(match.regex, references, discovered, visited)); + } + return match; + }); + return condition; + }); + + const transform = (data: StringFieldRestrictionsObject) => + replaceReferencesInStringRestrictionsObject(data, references, discovered, visited); + if (restrictionsObject.then) { + restrictionsObject.then = transformOneOrMany(restrictionsObject.then, transform); + } + if (restrictionsObject.else) { + restrictionsObject.else = transformOneOrMany(restrictionsObject.else, transform); + } + } else { + if (restrictionsObject.codeList !== undefined) { + restrictionsObject.codeList = TypeUtils.asArray( + resolveAllReferences(restrictionsObject.codeList, references, discovered, visited), + ); + } + if (restrictionsObject.regex !== undefined) { + const updatedRegex = resolveAllReferences(restrictionsObject.regex, references, discovered, visited); + if (Array.isArray(updatedRegex)) { + throw new InvalidReferenceError( + `Regex restriction with reference '${restrictionsObject.regex}' resolves to an array. This restriction must be a string.`, + ); + } + restrictionsObject.regex = updatedRegex; + } + } + return restrictionsObject; +}; + +/** + * Warning: This mutates the field argument object. This is meant for use within this module only and should not be exported. + */ +const internalReplaceFieldReferences = ( + field: SchemaField, + references: References, + discovered: DiscoveredMap, + visited: VisitedSet, +): SchemaField => { + // Process Field Meta: + if (field.meta !== undefined) { + field.meta = recursiveReplaceMetaReferences(field.meta, references, discovered, visited); + } + + // Process Field Restrictions: + if (field.restrictions !== undefined) { + // Each field type has different allowed restriction types, + // we need to handle the reference replacement rules carefully + // to ensure the output schema adheres to the type rules. + switch (field.valueType) { + case 'string': { + field.restrictions = transformOneOrMany(field.restrictions, (restriction) => + replaceReferencesInStringRestrictionsObject(restriction, references, discovered, visited), + ); + break; + } + case 'number': { + break; + } + case 'integer': { + break; + } + case 'boolean': { + break; + } + } + } + return field; +}; + +/** + * Logic for replacing references in a schema. + * + * This is used by the replaceReferences method that replaces references in ALL schemas. By allowing the caller + * to provide the discovered/visited objects that are used in the recursive logic we can let the replaceReferences + * method reuse the same dictionaries across all schemas. + * + * Warning: This mutates the schema argument object. This is meant for use within this module only and should not be exported. + * @param schema + * @param references + * @param discovered + * @param visited + * @returns + */ +const internalReplaceSchemaReferences = ( + schema: Schema, + references: References, + discovered: DiscoveredMap, + visited: VisitedSet, +): Schema => { + if (schema.meta) { + schema.meta = recursiveReplaceMetaReferences(schema.meta, references, discovered, visited); + } + + schema.fields.forEach((field) => { + internalReplaceFieldReferences(field, references, discovered, visited); + }); + + return schema; +}; + +/** + * Replace all ReferenceTags in in the restrictions and meta sections of the field definition object with + * values retrieved from the `references` object. + */ +export const replaceMetaReferences = (meta: DictionaryMeta, references: References): DictionaryMeta => { + const clone = cloneDeep(meta); + return recursiveReplaceMetaReferences(clone, references, createDiscoveredMap(), createVisitedSet()); +}; + +/** + * Replace all ReferenceTags in in the restrictions and meta sections of the field definition object with + * values retrieved from the `references` object. + */ +export const replaceFieldReferences = (field: SchemaField, references: References): SchemaField => { + const clone = cloneDeep(field); + return internalReplaceFieldReferences(clone, references, createDiscoveredMap(), createVisitedSet()); +}; + /** * Replace all Reference Tags in the restrictions and meta sections of the schema with values retrieved from * the `references` argument. @@ -194,13 +331,10 @@ const resolveReference = ( * @param references * @return schema clone with references replaced */ -export const replaceSchemaReferences = (schema: Schema, references: References) => - internalReplaceSchemaReferences( - schema, - references, - new Map(), - new Set(), - ); +export const replaceSchemaReferences = (schema: Schema, references: References) => { + const clone = cloneDeep(schema); + return internalReplaceSchemaReferences(clone, references, createDiscoveredMap(), createVisitedSet()); +}; /** * Replace all ReferenceTags found in dictionary schemas with the values retrieved from the dictionary's references. @@ -208,15 +342,14 @@ export const replaceSchemaReferences = (schema: Schema, references: References) * @returns Clone of dictionary with reference replacements */ export const replaceReferences = (dictionary: Dictionary): Dictionary => { + const clone = cloneDeep(omit(dictionary, 'references')); const references = dictionary.references || {}; - const discovered: DiscoveredMap = new Map(); - const visited: VisitedSet = new Set(); + const discovered = createDiscoveredMap(); + const visited = createVisitedSet(); - const updatedDictionary = immer.produce(dictionary, (draft) => { - draft.schemas = draft.schemas.map((schema) => - internalReplaceSchemaReferences(schema, references, discovered, visited), - ); - }); + clone.schemas = dictionary.schemas.map((schema) => + internalReplaceSchemaReferences(schema, references, discovered, visited), + ); - return omit(updatedDictionary, 'references'); + return clone; }; diff --git a/packages/dictionary/src/utils/resolveRestrictions.ts b/packages/dictionary/src/utils/resolveRestrictions.ts deleted file mode 100644 index 74460fd8..00000000 --- a/packages/dictionary/src/utils/resolveRestrictions.ts +++ /dev/null @@ -1,51 +0,0 @@ -// import { -// BooleanFieldRestrictions, -// IntegerFieldRestrictions, -// NumberFieldRestrictions, -// SchemaBooleanField, -// SchemaField, -// SchemaIntegerField, -// SchemaNumberField, -// SchemaStringField, -// StringFieldRestrictions, -// } from 'types'; - -// type SingleElement = T extends readonly (infer Element)[] ? Element : T; - -// /** -// * Given a schema field, get the type of the restriction object -// */ -// export type RestrictionObject = T extends { restrictions?: infer RestrictionType } -// ? SingleElement -// : never; - -// type BR = RestrictionObject; -// const x: BR = {}; - -// export const resolveRestrictions = ( -// field: T, -// ): RestrictionObject => { -// const y: RestrictionObject = { required: true }; - -// if (!field.restrictions) { -// return {}; -// } -// if (field.valueType === 'integer') { -// const restrictions = field.restrictions; -// return {}; -// } -// return {}; -// // if(!field.restrictions) { -// // return {}; -// // } -// // if(!Array.isArray(field.)) -// }; - -// type A = { type: 'a'; thing: number }; -// type B = { type: 'b'; thing: string }; -// type DUnion = A | B; - -// type ThingType = T extends { thing: infer U } ? U : never; - -// type AThing = ThingType; -// type BThing = ThingType; diff --git a/packages/dictionary/src/utils/schemaUtils.ts b/packages/dictionary/src/utils/schemaUtils.ts index 0d12aae9..69231cfc 100644 --- a/packages/dictionary/src/utils/schemaUtils.ts +++ b/packages/dictionary/src/utils/schemaUtils.ts @@ -3,21 +3,31 @@ import type { Schema, SchemaField } from '../metaSchema'; /** * Get an array of fields from this schema that have the required restriction set to true + * + * Note: this does not consider conditional restrictions that could make + * the field required or optional depending on the values of each record. * @param schema * @returns */ export const getRequiredFields = (schema: Schema): SchemaField[] => schema.fields.filter((field) => - TypeUtils.asArray(field.restrictions).some((restrictionObject) => restrictionObject?.required), + TypeUtils.asArray(field.restrictions).some( + (restrictionObject) => restrictionObject && 'required' in restrictionObject && restrictionObject?.required, + ), ); /** * Get an array of fields from this schema that are optional, - * meaning they do not have the required restriction set to true + * meaning they do not have the required restriction set to true. + * + * Note: this does not consider conditional restrictions that could make + * the field required or optional depending on the values of each record. * @param schema * @returns */ export const getOptionalFields = (schema: Schema): SchemaField[] => schema.fields.filter((field) => - TypeUtils.asArray(field.restrictions).every((restrictionObject) => !restrictionObject?.required), + TypeUtils.asArray(field.restrictions).every( + (restrictionObject) => !(restrictionObject && 'required' in restrictionObject && restrictionObject?.required), + ), ); diff --git a/packages/dictionary/src/utils/typeUtils.ts b/packages/dictionary/src/utils/typeUtils.ts index 7f404d06..e8e9c186 100644 --- a/packages/dictionary/src/utils/typeUtils.ts +++ b/packages/dictionary/src/utils/typeUtils.ts @@ -26,6 +26,44 @@ */ export const asArray = (val: T | T[]): T[] => (Array.isArray(val) ? val : [val]); +/** + * Given a predicate function that checks for type `T`, this will create a new predicate funcion that + * will check if a value is of type `T[]`. + * + * @example + * // Create type and predicate for `Person`: + * type Person = { name: string; age: number }; + * const isPerson = (value: unknown): value is Person => + * !!value && + * typeof value === 'object' && + * 'name' in value && + * typeof value.name === 'string' && + * 'age' in value && + * typeof value.age === 'number'; + * + * // Use `isArrayOf` and the new predicate to create `isPersonArray`: + * const isPersonArray = isArrayOf(isPerson); + * + * // Usage of `isPersonArray`: + * isPersonArray([{name:'Lisa', age: 8}, {name: 'Bart', age: 10}]); // true + * isPersonArray(['not a person']); // false + * isPersonArray('not an array'); // false + * isPersonArray([{name:'Lisa', age: 8}, {not: 'a person'}]); // false + * @param predicate + * @returns + */ +export const isArrayOf = + (predicate: (value: unknown) => value is T) => + (value: unknown) => + Array.isArray(value) && value.every(predicate); + +/** + * Determines if a variable is of type `boolean[]`. + * @param value + * @returns + */ +export const isBooleanArray = isArrayOf((value: unknown) => typeof value === 'boolean'); + /** * Checks that the input does not equal undefined (and lets the type checker know). * @@ -38,3 +76,42 @@ export const asArray = (val: T | T[]): T[] => (Array.isArray(val) ? val : [va * const stringArray = combinedArray.filter(isDefined); // type is: Array */ export const isDefined = (input: T | undefined) => input !== undefined; + +/** + * Determines if a variable is a number, with added restriction that it is Finite. + * This eliminates the values `NaN` and `Infinity`. + * + * Note: this is just a wrapper on `Number.isFinite` which is used by Lectern for identifying numbers in data value type checks. + * @param value + * @returns + */ +export const isNumber = (value: unknown): value is number => Number.isFinite(value); +/** + * Determines if variable is of type number[], with added restriction that every element is Finite. + * @param value + * @returns + */ +export const isNumberArray = isArrayOf(isNumber); + +/** + * Determines if a variable is of type string[] + * @param value + * @returns + */ +export const isStringArray = isArrayOf((value: unknown) => typeof value === 'string'); + +/** + * Determines if a variable is a number, with added restriction that it is an Integer. + * + * Note: This is a wrapper over `Number.isInteger` which is used by Lectern for identifying integers in data value type checks. + * @param value + * @returns + */ +export const isInteger = (value: unknown): value is number => Number.isInteger(value); + +/** + * Determines if a variables is of type number[], with add restriction that every element is an Integer. + * @param value + * @returns + */ +export const isIntegerArray = isArrayOf(isInteger); diff --git a/packages/dictionary/test/dictionaryTypes.spec.ts b/packages/dictionary/test/dictionaryTypes.spec.ts index 9d5fff43..6a62253e 100644 --- a/packages/dictionary/test/dictionaryTypes.spec.ts +++ b/packages/dictionary/test/dictionaryTypes.spec.ts @@ -640,4 +640,6 @@ describe('Dictionary Types', () => { expect(DictionaryMeta.safeParse(meta).success).false; }); }); + // TODO: References spec + // TODO: Conditional Restrictions }); diff --git a/packages/dictionary/test/references.spec.ts b/packages/dictionary/test/references.spec.ts index 57cc25fd..b2247198 100644 --- a/packages/dictionary/test/references.spec.ts +++ b/packages/dictionary/test/references.spec.ts @@ -20,32 +20,72 @@ import { expect } from 'chai'; import { replaceReferences } from '../src/references'; -import noReferencesSectionInput from './fixtures/references/no_references_section/input'; -import noReferencesSectionOutput from './fixtures/references/no_references_section/output'; -import emptyReferencesInput from './fixtures/references/empty_references_section/input'; -import emptyReferencesOutput from './fixtures/references/empty_references_section/output'; -import simpleReferencesInput from './fixtures/references/simple_references/input'; -import simpleReferencesOutput from './fixtures/references/simple_references/output'; +import assert from 'assert'; import codeListReferencesInput from './fixtures/references/codeList_references/input'; import codeListReferencesOutput from './fixtures/references/codeList_references/output'; +import cyclicReferencesInput from './fixtures/references/cyclic_references/input'; +import emptyReferencesInput from './fixtures/references/empty_references_section/input'; +import emptyReferencesOutput from './fixtures/references/empty_references_section/output'; +import noReferencesSectionInput from './fixtures/references/no_references_section/input'; +import noReferencesSectionOutput from './fixtures/references/no_references_section/output'; +import nonExistingReferencesInput from './fixtures/references/non_existing_references/input'; import referencesWithinReferencesInput from './fixtures/references/references_within_references/input'; import referencesWithinReferencesOutput from './fixtures/references/references_within_references/output'; import regexReferencesInput from './fixtures/references/regex_reference/input'; -import regexReferencesOutput from './fixtures/references/regex_reference/output'; import regexArrayReferencesInput from './fixtures/references/regex_reference/input_with_array'; -import nonExistingReferencesInput from './fixtures/references/non_existing_references/input'; -import cyclicReferencesInput from './fixtures/references/cyclic_references/input'; +import regexReferencesOutput from './fixtures/references/regex_reference/output'; import selfReferencesInput from './fixtures/references/self_references/input'; +import simpleReferencesInput from './fixtures/references/simple_references/input'; +import simpleReferencesOutput from './fixtures/references/simple_references/output'; describe('Replace References', () => { - it('Should return the same original schema if dictionary does not contain a references section', () => { + it('Returns unmodified schema when dictionary does not contain a references section', () => { const replacedDictionary = replaceReferences(noReferencesSectionInput); expect(replacedDictionary).to.deep.eq(noReferencesSectionOutput); }); - it('Should return the same original schema if dictionary contains an empty references section', () => { + it('Returns unmodified schema when dictionary contains an empty references section', () => { const replacedDictionary = replaceReferences(emptyReferencesInput); expect(replacedDictionary).to.deep.eq(emptyReferencesOutput); }); + it('Returns unmodified schema when no ReferenceTag values are used', () => { + assert(false, 'unimplemented test'); + }); + it('Throws an error when a ReferenceTag to an unknown path is provided', () => { + expect(() => replaceReferences(nonExistingReferencesInput)).to.throw( + "No reference found for provided tag '#/NON_EXISTING_REFERENCE'", + ); + }); + it('Throws an error if cyclic references are found', () => { + expect(() => replaceReferences(cyclicReferencesInput)).to.throw("Cyclical references found for '#/OTHER'"); + }); + it('Throws an error if self references are found', () => { + expect(() => replaceReferences(selfReferencesInput)).to.throw("Cyclical references found for '#/SELF_REFERENCE'"); + }); + describe('Meta', () => { + it('Replaces reference tag value in meta root', () => { + assert(false, 'unimplemented test'); + }); + it('Replaces reference tag value in meta nested properties', () => { + assert(false, 'unimplemented test'); + }); + it('Replaces reference tag value in meta string array', () => { + assert(false, 'unimplemented test'); + }); + }); + describe('String Restrictions', () => { + it('CodeList with ReferenceTag to single value is replaced with an array with the single value', () => { + assert(false, 'unimplemented test'); + }); + it('CodeList with array containing ReferenceTag is replaced by array with reference values added to array', () => { + assert(false, 'unimplemented test'); + }); + it('Regex with ReferenceTag is replaced by single value', () => { + assert(false, 'unimplemented test'); + }); + it('Regex with ReferenceTag to array value throws an error', () => { + assert(false, 'unimplemented test'); + }); + }); // TODO: Check reference replacement in meta it('Should return the schema with simple references replaced', () => { const replacedDictionary = replaceReferences(simpleReferencesInput); @@ -65,18 +105,7 @@ describe('Replace References', () => { }); it('Regex Reference cannot be an array', () => { expect(() => replaceReferences(regexArrayReferencesInput)).to.throw( - `Field 'some_id' has restriction 'regex' with a reference '#/regex/ID_FORMAT' that resolves to an array. This restriction must be a string.`, - ); - }); - it('Should throw exception if reference does not exist', () => { - expect(() => replaceReferences(nonExistingReferencesInput)).to.throw( - "No reference found for provided tag '#/NON_EXISTING_REFERENCE'", + `Regex restriction with reference '#/regex/ID_FORMAT' resolves to an array. This restriction must be a string.`, ); }); - it('Should throw exception if cyclic references are found', () => { - expect(() => replaceReferences(cyclicReferencesInput)).to.throw("Cyclical references found for '#/OTHER'"); - }); - it('Should throw exception if self references are found', () => { - expect(() => replaceReferences(selfReferencesInput)).to.throw("Cyclical references found for '#/SELF_REFERENCE'"); - }); }); diff --git a/packages/validation/src/parseValues/matchCodeListFormatting.ts b/packages/validation/src/parseValues/matchCodeListFormatting.ts index 322f281d..95536860 100644 --- a/packages/validation/src/parseValues/matchCodeListFormatting.ts +++ b/packages/validation/src/parseValues/matchCodeListFormatting.ts @@ -17,8 +17,25 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { TypeUtils, type SchemaField } from '@overture-stack/lectern-dictionary'; +import { TypeUtils, type SchemaField, type StringFieldRestrictionsObject } from '@overture-stack/lectern-dictionary'; +/** + * Loop through restrictins and nested conditional restrictions finding every codeList restriction and collect all their + * values into a single array. This is used to format data values during parsing. + */ +const collectAllNestedCodeLists = ( + restrictions: StringFieldRestrictionsObject | StringFieldRestrictionsObject[], +): string[] => { + return TypeUtils.asArray(restrictions).flatMap((restrictionsObject) => { + if ('if' in restrictionsObject) { + const thenCodeLists = restrictionsObject.then ? collectAllNestedCodeLists(restrictionsObject.then) : []; + const elseCodeLists = restrictionsObject.else ? collectAllNestedCodeLists(restrictionsObject) : []; + return [...thenCodeLists, ...elseCodeLists]; + } else { + return restrictionsObject.codeList ? restrictionsObject.codeList : []; + } + }); +}; /** * Given a string value, look for any matching values in code list restrictions and return that * value. This is used by the convertValue functions to ensure the value returned matches the letter @@ -38,11 +55,7 @@ import { TypeUtils, type SchemaField } from '@overture-stack/lectern-dictionary' export function matchCodeListFormatting(value: string, fieldDefinition: SchemaField): string { const { valueType } = fieldDefinition; if (valueType === 'string') { - // find all possible codeLists in the restrictions - const codeList = TypeUtils.asArray(fieldDefinition.restrictions) - .map((restrictionObject) => restrictionObject?.codeList) - .filter(TypeUtils.isDefined) - .flat(); + const codeList = fieldDefinition.restrictions && collectAllNestedCodeLists(fieldDefinition.restrictions); if (Array.isArray(codeList)) { // We have found a code list to compare to! diff --git a/packages/validation/src/parseValues/parseValues.ts b/packages/validation/src/parseValues/parseValues.ts index 0a3d2410..66af9a46 100644 --- a/packages/validation/src/parseValues/parseValues.ts +++ b/packages/validation/src/parseValues/parseValues.ts @@ -22,6 +22,7 @@ import { failure, failWith, success, + TypeUtils, type ArrayDataValue, type DataRecord, type DataRecordValue, @@ -32,7 +33,6 @@ import { type SchemaFieldValueType, type UnprocessedDataRecord, } from '@overture-stack/lectern-dictionary'; -import { isInteger, isNumber } from '../utils/typeUtils'; import type { ParseDictionaryData, ParseDictionaryFailure, @@ -44,6 +44,8 @@ import type { } from './ParseValuesResult'; import { matchCodeListFormatting } from './matchCodeListFormatting'; +const { isInteger, isNumber } = TypeUtils; + /* === Type Specific conversion functions === */ // Note: These are intended to be passed only normalized values that have already passed through the diff --git a/packages/validation/src/utils/isValidValueType.ts b/packages/validation/src/utils/isValidValueType.ts index f7c8bc6c..47ad49c6 100644 --- a/packages/validation/src/utils/isValidValueType.ts +++ b/packages/validation/src/utils/isValidValueType.ts @@ -17,8 +17,8 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { DataRecordValue, SchemaField } from '@overture-stack/lectern-dictionary'; -import { isBooleanArray, isInteger, isIntegerArray, isNumber, isNumberArray, isStringArray } from './typeUtils'; +import { type DataRecordValue, type SchemaField, TypeUtils } from '@overture-stack/lectern-dictionary'; +const { isBooleanArray, isInteger, isIntegerArray, isNumber, isNumberArray, isStringArray } = TypeUtils; /** * Checks that a value matches the expected type for a given field, based on the value type specified in its field diff --git a/packages/validation/src/utils/resultForArayTestCase.ts b/packages/validation/src/utils/resultForArayTestCase.ts new file mode 100644 index 00000000..fd0d15ec --- /dev/null +++ b/packages/validation/src/utils/resultForArayTestCase.ts @@ -0,0 +1,29 @@ +import type { ArrayTestCase } from '@overture-stack/lectern-dictionary'; + +/** + * `ArrayTestCase` values dictate how many results in an array need to be successful in order for + * an entire test to be considered a success. This function takes an array of boolean values and + * an `ArrayTestCase` value and determine if the entire list is successful. + * + * The possible test case values and their behaviours are: + * - all: every boolean in the results must be true + * - any: at least one result must be true + * - none: no result can be true (all must be false) + * + * @param results Array of booleans representing a list of test results where true is a success + * @param testCase + * @returns + */ +export const resultForArrayTestCase = (results: boolean[], testCase: ArrayTestCase): boolean => { + switch (testCase) { + case 'all': { + return results.every((result) => result); + } + case 'any': { + return results.some((result) => result); + } + case 'none': { + return results.every((result) => !result); + } + } +}; diff --git a/packages/validation/src/utils/typeUtils.ts b/packages/validation/src/utils/typeUtils.ts deleted file mode 100644 index 0720e8d1..00000000 --- a/packages/validation/src/utils/typeUtils.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved - * - * This program and the accompanying materials are made available under the terms of - * the GNU Affero General Public License v3.0. You should have received a copy of the - * GNU Affero General Public License along with this program. - * If not, see . - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY - * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES - * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT - * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED - * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; - * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER - * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN - * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -/** - * Given a predicate function that checks for type `T`, this will create a new predicate funcion that - * will check if a value is of type `T[]`. - * - * @example - * // Create type and predicate for `Person`: - * type Person = { name: string; age: number }; - * const isPerson = (value: unknown): value is Person => - * !!value && - * typeof value === 'object' && - * 'name' in value && - * typeof value.name === 'string' && - * 'age' in value && - * typeof value.age === 'number'; - * - * // Use `isArrayOf` and the new predicate to create `isPersonArray`: - * const isPersonArray = isArrayOf(isPerson); - * - * // Usage of `isPersonArray`: - * isPersonArray([{name:'Lisa', age: 8}, {name: 'Bart', age: 10}]); // true - * isPersonArray(['not a person']); // false - * isPersonArray('not an array'); // false - * isPersonArray([{name:'Lisa', age: 8}, {not: 'a person'}]); // false - * @param predicate - * @returns - */ -export const isArrayOf = - (predicate: (value: unknown) => value is T) => - (value: unknown) => - Array.isArray(value) && value.every(predicate); - -/** - * Determines if a variable is of type `boolean[]`. - * @param value - * @returns - */ -export const isBooleanArray = isArrayOf((value: unknown) => typeof value === 'boolean'); - -/** - * Determines if a variable is a number, with added restriction that it is Finite. - * This eliminates the values `NaN` and `Infinity`. - * - * Note: this is just a wrapper on `Number.isFinite` which is used by Lectern for identifying numbers in data value type checks. - * @param value - * @returns - */ -export const isNumber = (value: unknown): value is number => Number.isFinite(value); -/** - * Determines if variable is of type number[], with added restriction that every element is Finite. - * @param value - * @returns - */ -export const isNumberArray = isArrayOf(isNumber); - -/** - * Determines if a variable is of type string[] - * @param value - * @returns - */ -export const isStringArray = isArrayOf((value: unknown) => typeof value === 'string'); - -/** - * Determines if a variable is a number, with added restriction that it is an Integer. - * - * Note: This is a wrapper over `Number.isInteger` which is used by Lectern for identifying integers in data value type checks. - * @param value - * @returns - */ -export const isInteger = (value: unknown): value is number => Number.isInteger(value); - -/** - * Determines if a variables is of type number[], with add restriction that every element is an Integer. - * @param value - * @returns - */ -export const isIntegerArray = isArrayOf(isInteger); diff --git a/packages/validation/src/validateField/conditions/index.ts b/packages/validation/src/validateField/conditions/index.ts new file mode 100644 index 00000000..164fff8f --- /dev/null +++ b/packages/validation/src/validateField/conditions/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +export * from './testMatchCodeList'; diff --git a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts new file mode 100644 index 00000000..be81e356 --- /dev/null +++ b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { + ARRAY_TEST_CASE_DEFAULT, + type ArrayDataValue, + type ConditionalRestrictionTest, + type DataRecord, + type DataRecordValue, + type RestrictionCondition, + type SingleDataValue, +} from '@overture-stack/lectern-dictionary'; +import { resultForArrayTestCase } from '../../utils/resultForArayTestCase'; +import { testMatchCount } from './testMatchCount'; +import { testMatchCodeList } from './testMatchCodeList'; +import { testMatchExists } from './testMatchExists'; +import { testMatchRange } from './testMatchRange'; +import { testMatchRegex } from './testMatchRegex'; +import { testMatchValue } from './testMatchValue'; + +const allValuesPassMatchTest = ( + values: DataRecordValue[], + rule: TMatchRule, + matchTest: (rule: TMatchRule, value: DataRecordValue) => boolean, +): boolean => values.every((value) => matchTest(rule, value)); + +const testConditionForSingularValue = ( + condition: RestrictionCondition, + _value: SingleDataValue, + fieldValues: DataRecordValue[], +): boolean => { + const results = condition.match.map((match) => { + if (match.codeList) { + if (!allValuesPassMatchTest(fieldValues, match.codeList, testMatchCodeList)) { + return false; + } + } + // count rule can have value of 0 so we need to directly check for undefined + if (match.count !== undefined) { + if (!allValuesPassMatchTest(fieldValues, match.count, testMatchCount)) { + return false; + } + } + if (match.exists) { + if (!allValuesPassMatchTest(fieldValues, match.exists, testMatchExists)) { + return false; + } + } + if (match.range) { + if (!allValuesPassMatchTest(fieldValues, match.range, testMatchRange)) { + return false; + } + } + if (match.regex) { + if (!allValuesPassMatchTest(fieldValues, match.regex, testMatchRegex)) { + return false; + } + } + if (match.value) { + if (!allValuesPassMatchTest(fieldValues, match.value, testMatchValue)) { + return false; + } + } + return true; + }); + return resultForArrayTestCase(results, condition.case || ARRAY_TEST_CASE_DEFAULT); +}; + +const testConditionForArray = ( + _condition: RestrictionCondition, + _value: ArrayDataValue, + _fieldValues: DataRecordValue[], +): boolean => { + throw new Error('Unimplemented.'); +}; + +const testCondition = (condition: RestrictionCondition, value: DataRecordValue, record: DataRecord): boolean => { + const recordValues = condition.fields.map((fieldName) => record[fieldName]); + return Array.isArray(value) + ? testConditionForArray(condition, value, recordValues) + : testConditionForSingularValue(condition, value, recordValues); +}; + +/** + * Check all conditions inside the `if` object of a Conditiohnal Restriction to determine if the condition is met for + * a given field and its data record. This will apply all match rules inside each condition versus a field value and data record. + */ +export const testConditionalRestriction = ( + conditionalTest: ConditionalRestrictionTest, + value: DataRecordValue, + record: DataRecord, +): boolean => { + const results = conditionalTest.conditions.map((condition) => testCondition(condition, value, record)); + return resultForArrayTestCase(results, conditionalTest.case); +}; diff --git a/packages/validation/src/validateField/conditions/testMatchCodeList.ts b/packages/validation/src/validateField/conditions/testMatchCodeList.ts new file mode 100644 index 00000000..b75e3fa2 --- /dev/null +++ b/packages/validation/src/validateField/conditions/testMatchCodeList.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import type { DataRecordValue, MatchRuleCodeList } from '@overture-stack/lectern-dictionary'; +import { isNumberArray, isStringArray } from '@overture-stack/lectern-dictionary/dist/utils/typeUtils'; + +/** + * Check if the value (or at least one value from an array) is found in the code list. + */ +export const testMatchCodeList = (codeList: MatchRuleCodeList, value: DataRecordValue): boolean => { + if (isStringArray(codeList)) { + if (isStringArray(value)) { + // If we can find at least one match from our codeList inside the array of values then we return true + return value.some((singleValue) => codeList.includes(singleValue)); + } + if (typeof value === 'string') { + return codeList.includes(value); + } + } + if (isNumberArray(codeList)) { + if (isNumberArray(value)) { + return value.some((singleValue) => codeList.includes(singleValue)); + } + if (typeof value === 'number') { + return codeList.includes(value); + } + } + + // If the code reaches here, we have a mismatch between the type of the codeList and the value, for example + // the code list is an array of strings but the value is a number. Since these mismatched types will never match + // we return false. + return false; +}; diff --git a/packages/validation/src/validateField/conditions/testMatchCount.ts b/packages/validation/src/validateField/conditions/testMatchCount.ts new file mode 100644 index 00000000..89459f55 --- /dev/null +++ b/packages/validation/src/validateField/conditions/testMatchCount.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import type { DataRecordValue, MatchRuleCount } from '@overture-stack/lectern-dictionary'; +import { testRange } from '../restrictions'; + +/** + * Test if the number of elements in an array value is an exact number, or within a range. + * + * Note: This test is only meant to match on array fields. It is counting the number of elements in an array. + * This will always return false for non-array values, it does not count the character length of strings + * or numbers. + * @param count + * @param value + * @returns + */ +export const testMatchCount = (count: MatchRuleCount, value: DataRecordValue): boolean => { + if (!Array.isArray(value)) { + // can only match with arrays + return false; + } + + // count match rule is either a range object or a number + if (typeof count === 'object') { + // here it is the range object so we can use the testRange fuctionality to determine if we have + // the correct number of elements + return testRange(count, value.length).valid; + } + + // whats left + return value.length >= count; +}; diff --git a/packages/validation/src/validateField/conditions/testMatchExists.ts b/packages/validation/src/validateField/conditions/testMatchExists.ts new file mode 100644 index 00000000..8b55d277 --- /dev/null +++ b/packages/validation/src/validateField/conditions/testMatchExists.ts @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { + TypeUtils, + type DataRecordValue, + type MatchRuleExists, + type SingleDataValue, +} from '@overture-stack/lectern-dictionary'; + +const valueExists = (value: SingleDataValue) => { + if (value === undefined) { + return false; + } + switch (typeof value) { + case 'string': { + // empty string, and all whitespace, are treated as empty values + return value.trim() !== ''; + } + case 'number': { + // Treate NaN and Infinity values as missing values + return Number.isFinite(value); + } + case 'boolean': { + return true; + } + } +}; + +/** + * Test if the value exists, ie. that it is not undefined or an empty array. When the rule is true, this will + * return true when the value exists, and when the rule is false this will return true only when the value does + * not exist. + * + * Notes: + * - Boolean value `false` is an existing value + * - Empty strings represent a missing value, so empty string value is not teated as an existing value + */ +export const testMatchExists = (exists: MatchRuleExists, value: DataRecordValue): boolean => { + const isEmptyArray = Array.isArray(value) && value.length === 0; + const isValueExists = !isEmptyArray && TypeUtils.asArray(value).every(valueExists); + + return exists === isValueExists; +}; diff --git a/packages/validation/src/validateField/conditions/testMatchRange.ts b/packages/validation/src/validateField/conditions/testMatchRange.ts new file mode 100644 index 00000000..ec5e86e5 --- /dev/null +++ b/packages/validation/src/validateField/conditions/testMatchRange.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { type DataRecordValue, type MatchRuleRange } from '@overture-stack/lectern-dictionary'; +import { isNumberArray } from '@overture-stack/lectern-dictionary/dist/utils/typeUtils'; +import { testRange } from '../restrictions'; + +export const testMatchRange = (range: MatchRuleRange, value: DataRecordValue): boolean => { + if (typeof value === 'number') { + return testRange(range, value).valid; + } + if (isNumberArray(value)) { + return value.some((item) => testRange(range, item).valid); + } + + // value is not a type that can match the range rule, we return false; + return false; +}; diff --git a/packages/validation/src/validateField/conditions/testMatchRegex.ts b/packages/validation/src/validateField/conditions/testMatchRegex.ts new file mode 100644 index 00000000..2ed76fe0 --- /dev/null +++ b/packages/validation/src/validateField/conditions/testMatchRegex.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { type DataRecordValue, type MatchRuleRegex } from '@overture-stack/lectern-dictionary'; +import { isStringArray } from '@overture-stack/lectern-dictionary/dist/utils/typeUtils'; +import { testRegex } from '../restrictions'; + +export const testMatchRegex = (regex: MatchRuleRegex, value: DataRecordValue): boolean => { + if (typeof value === 'string') { + return testRegex(regex, value).valid; + } + + if (isStringArray(value)) { + return value.some((item) => testRegex(regex, item).valid); + } + + // value is not a type that can match the regex rule, we return false; + return false; +}; diff --git a/packages/validation/src/validateField/conditions/testMatchValue.ts b/packages/validation/src/validateField/conditions/testMatchValue.ts new file mode 100644 index 00000000..5b9b4fcb --- /dev/null +++ b/packages/validation/src/validateField/conditions/testMatchValue.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { type DataRecordValue, type MatchRuleValue, type SingleDataValue } from '@overture-stack/lectern-dictionary'; +import { isStringArray } from '@overture-stack/lectern-dictionary/dist/utils/typeUtils'; +import { testRegex } from '../restrictions'; + +const normalizeValue = (value: SingleDataValue): SingleDataValue => { + if (typeof value === 'string') { + return value.trim().toLowerCase(); + } + return value; +}; + +/** + * Tests if the value has the same value as the value match rule. No type coercion is performed, strings + * values only match strings, and arrays only match arrays. + * + * Strings are matched case insensitive and after trimming and forcing each string to lowercase. + * @param valueRule + * @param value + * @returns + */ +export const testMatchValue = (valueRule: MatchRuleValue, value: DataRecordValue): boolean => { + if (Array.isArray(valueRule)) { + if (!Array.isArray(value)) { + return false; + } + + if (value.length !== valueRule.length) { + return false; + } + + const sortedValue = [...value].map(normalizeValue).sort(); + const sortedRule = [...valueRule].map(normalizeValue).sort(); + + const allMatch = sortedRule.every((item, index) => item === sortedValue[index]); + + return allMatch; + } + + if (Array.isArray(value)) { + return false; + } + + return normalizeValue(value) === normalizeValue(valueRule); +}; diff --git a/packages/validation/src/validateField/restrictions/index.ts b/packages/validation/src/validateField/restrictions/index.ts index 682deae8..abd8d3b7 100644 --- a/packages/validation/src/validateField/restrictions/index.ts +++ b/packages/validation/src/validateField/restrictions/index.ts @@ -17,6 +17,7 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +export * from './resolveFieldRestrictions'; export * from './testCodeList'; export * from './testRange'; export * from './testRegex'; diff --git a/packages/validation/src/validateField/resolveFieldRestrictions.ts b/packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts similarity index 50% rename from packages/validation/src/validateField/resolveFieldRestrictions.ts rename to packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts index d36ad8fa..d1420596 100644 --- a/packages/validation/src/validateField/resolveFieldRestrictions.ts +++ b/packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts @@ -19,53 +19,77 @@ import { TypeUtils, + type AnyFieldRestrictions, type DataRecord, type DataRecordValue, - type Defined, type SchemaField, type SchemaRestrictions, } from '@overture-stack/lectern-dictionary'; -import type { FieldRestrictionRule } from './FieldRestrictionRule'; +import type { FieldRestrictionRule } from '../FieldRestrictionRule'; +import { testConditionalRestriction } from '../conditions/testConditionalRestriction'; + +const extractRulesFromRestriction = (restrictions: AnyFieldRestrictions): FieldRestrictionRule[] => { + const rules: FieldRestrictionRule[] = []; -const extractRulesFromRestrictionObject = (restrictions: Defined): FieldRestrictionRule[] => { - const output: FieldRestrictionRule[] = []; if ('required' in restrictions) { if (restrictions.required) { - output.push({ type: 'required', rule: restrictions.required }); + rules.push({ type: 'required', rule: restrictions.required }); } } if ('codeList' in restrictions) { if (Array.isArray(restrictions.codeList)) { - output.push({ type: 'codeList', rule: restrictions.codeList }); + rules.push({ type: 'codeList', rule: restrictions.codeList }); } } if ('range' in restrictions) { if (restrictions.range) { - output.push({ type: 'range', rule: restrictions.range }); + rules.push({ type: 'range', rule: restrictions.range }); } } if ('regex' in restrictions) { if (restrictions.regex) { - output.push({ type: 'regex', rule: restrictions.regex }); + rules.push({ type: 'regex', rule: restrictions.regex }); } } + + return rules; +}; + +const recursiveResolveRestrictions = ( + restrictions: SchemaRestrictions, + value: DataRecordValue, + record: DataRecord, +): FieldRestrictionRule[] => { + if (!restrictions) { + return []; + } + + const output = TypeUtils.asArray(restrictions).flatMap((restrictionObject) => { + if ('if' in restrictionObject) { + // This object is a conditional restriction, we will test the record vs the conditions then extract rules + // from either the `then` or `else` block. + const result = testConditionalRestriction(restrictionObject.if, value, record); + if (result) { + return recursiveResolveRestrictions(restrictionObject.then, value, record); + } else { + return recursiveResolveRestrictions(restrictionObject.else, value, record); + } + } else { + // The restriction object here is not conditional, so we can grab the rules and add them to the output + return extractRulesFromRestriction(restrictionObject); + } + }); + return output; }; /** * Convert the restrictions found in a SchemaField definition into a list of rules that apply for this specific value - * and DataRecord. + * and DataRecord. This will check all conditional restrictions versus the field value and its data record, exracting + * the restriction rules that apply based on the conditional logic. */ export const resolveFieldRestrictions = ( - _value: DataRecordValue, - _record: DataRecord, + value: DataRecordValue, + record: DataRecord, field: SchemaField, -): FieldRestrictionRule[] => { - // TODO: This function requires value and record parameters so that conditional restrictions can be resolved. - // The original implementation with a static set of available restrictions does not need these parameters. - if (!field.restrictions) { - return []; - } - - return TypeUtils.asArray(field.restrictions).flatMap(extractRulesFromRestrictionObject); -}; +): FieldRestrictionRule[] => recursiveResolveRestrictions(field.restrictions, value, record); diff --git a/packages/validation/src/validateField/restrictions/testRegex.ts b/packages/validation/src/validateField/restrictions/testRegex.ts index 4681b22d..ed59d4a1 100644 --- a/packages/validation/src/validateField/restrictions/testRegex.ts +++ b/packages/validation/src/validateField/restrictions/testRegex.ts @@ -17,7 +17,7 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { type RestrictionRegex } from '@overture-stack/lectern-dictionary'; +import { TypeUtils, type RestrictionRegex } from '@overture-stack/lectern-dictionary'; import { invalid, valid } from '../../types/testResult'; import type { FieldRestrictionSingleValueTestFunction, FieldRestrictionTestFunction } from '../FieldRestrictionTest'; import { createFieldRestrictionTestForArrays } from './createFieldRestrictionTestForArrays'; @@ -34,12 +34,16 @@ const testRegexSingleValue: FieldRestrictionSingleValueTestFunction { + const regexPattern = new RegExp(regexRule); - if (regexPattern.test(value)) { - return valid(); - } - return invalid({ message: `The value must match the regular expression.` }); + if (!regexPattern.test(value)) { + return false; + } + return true; + }); + return regexResult ? valid() : invalid({ message: `The value must match the regular expression.` }); + // TODO: update message to communicate which regex failed (if array) }; const testRegexArray = createFieldRestrictionTestForArrays( @@ -47,5 +51,8 @@ const testRegexArray = createFieldRestrictionTestForArrays( (_rule) => `All values in the array must match the regular expression.`, ); +// TODO: The error messages returned here don't inform which regular expressions failed, if there is a list. +// ...The message doesnt even acknowledge that there could be a list + export const testRegex: FieldRestrictionTestFunction = (rule, value) => Array.isArray(value) ? testRegexArray(rule, value) : testRegexSingleValue(rule, value); diff --git a/packages/validation/src/validateField/validateField.ts b/packages/validation/src/validateField/validateField.ts index 11823f40..9ef461ea 100644 --- a/packages/validation/src/validateField/validateField.ts +++ b/packages/validation/src/validateField/validateField.ts @@ -21,7 +21,7 @@ import type { DataRecord, DataRecordValue, SchemaField } from '@overture-stack/l import { invalid, valid, type TestResult } from '../types'; import { isValidValueType } from '../utils/isValidValueType'; import type { FieldRestrictionRule } from './FieldRestrictionRule'; -import { resolveFieldRestrictions } from './resolveFieldRestrictions'; +import { resolveFieldRestrictions } from './restrictions/resolveFieldRestrictions'; import { testCodeList } from './restrictions/testCodeList'; import { testRange } from './restrictions/testRange'; import { testRegex } from './restrictions/testRegex'; diff --git a/packages/validation/test/validateField/conditions/testMatchExists.spec.ts b/packages/validation/test/validateField/conditions/testMatchExists.spec.ts new file mode 100644 index 00000000..148c90e6 --- /dev/null +++ b/packages/validation/test/validateField/conditions/testMatchExists.spec.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { testMatchExists } from '../../../src/validateField/conditions/testMatchExists'; + +describe('ConditionalRestriction - testMatchExists', () => { + it('Primitive values all found to exist', () => { + expect(testMatchExists(true, 'hello')).true; + expect(testMatchExists(true, 123)).true; + expect(testMatchExists(true, true)).true; + }); + it('Array values with some elements all found to exist', () => { + expect(testMatchExists(true, ['hello'])).true; + expect(testMatchExists(true, ['hello', 'world', 'how are you?'])).true; + expect(testMatchExists(true, [123, 456, 789])).true; + expect(testMatchExists(true, [true, false, true, false])).true; + }); + it('`false` values treated as existing', () => { + expect(testMatchExists(true, false)).true; + }); + it('`undefined` values treated as not existing', () => { + expect(testMatchExists(true, undefined)).false; + }); + it('Empty string values treated as not existing', () => { + expect(testMatchExists(true, '')).false; + }); + it('All whitespacce string values treated as not existing', () => { + expect(testMatchExists(true, ' ')).false; + }); + it('Non-finite numbers (NaN, Infinity) values are treated as not existing.', () => { + expect(testMatchExists(true, NaN)).false; + expect(testMatchExists(true, Infinity)).false; + expect(testMatchExists(true, -Infinity)).false; + }); + it('Empty array value treated as not existing', () => { + expect(testMatchExists(true, [])).false; + }); + it('Array with only non existing elements treated as not existing', () => { + expect(testMatchExists(true, [''])).false; + expect(testMatchExists(true, ['', ' '])).false; + expect(testMatchExists(true, [NaN, Infinity])).false; + }); + + it('Inverse rule - Exist rule `false` resolves `true` when value does not exist', () => { + expect(testMatchExists(false, undefined)).true; + }); + it('Inverse rule - Exist rule `false` resolves `false` when value exists', () => { + expect(testMatchExists(false, 'hello')).false; + }); +}); diff --git a/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts b/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts index 0eba65e4..d7bbcd2c 100644 --- a/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts +++ b/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts @@ -18,10 +18,10 @@ */ import { expect } from 'chai'; -import { resolveFieldRestrictions } from '../../src/validateField/resolveFieldRestrictions'; +import { resolveFieldRestrictions } from '../../src/validateField/restrictions/resolveFieldRestrictions'; +import { fieldStringArrayMultipleRegex } from '../fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex'; import { fieldStringManyRestrictions } from '../fixtures/fields/multipleRestrictions/fieldStringManyRestrictions'; import { fieldStringNoRestriction } from '../fixtures/fields/noRestrictions/fieldStringNoRestriction'; -import { fieldStringArrayMultipleRegex } from '../fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex'; describe('Field - resolveFieldRestrictions', () => { it('Returns empty array when there are no restrictions', () => { From 5cbdf70f99fae414ac67c73cdcb0be159f783409 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 4 Aug 2024 18:54:50 -0400 Subject: [PATCH 07/21] Tests for references in meta and regex arrays --- packages/dictionary/src/references.ts | 5 -- .../references/codeList_references/input.ts | 5 +- .../references/codeList_references/output.ts | 2 +- .../nested_meta_references/input.ts | 29 ++++++++ .../nested_meta_references/output.ts | 25 +++++++ .../references/no_referece_tags/input.ts | 55 +++++++++++++++ .../references/no_referece_tags/output.ts | 44 ++++++++++++ .../input.ts} | 2 +- .../regex_reference_with_array/output.ts | 21 ++++++ .../input.ts | 53 ++++++++++++++ .../output.ts | 43 ++++++++++++ packages/dictionary/test/references.spec.ts | 70 +++++++++---------- 12 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 packages/dictionary/test/fixtures/references/nested_meta_references/input.ts create mode 100644 packages/dictionary/test/fixtures/references/nested_meta_references/output.ts create mode 100644 packages/dictionary/test/fixtures/references/no_referece_tags/input.ts create mode 100644 packages/dictionary/test/fixtures/references/no_referece_tags/output.ts rename packages/dictionary/test/fixtures/references/{regex_reference/input_with_array.ts => regex_reference_with_array/input.ts} (90%) create mode 100644 packages/dictionary/test/fixtures/references/regex_reference_with_array/output.ts create mode 100644 packages/dictionary/test/fixtures/references/restrictions_array_with_references/input.ts create mode 100644 packages/dictionary/test/fixtures/references/restrictions_array_with_references/output.ts diff --git a/packages/dictionary/src/references.ts b/packages/dictionary/src/references.ts index 2982d1a4..26995444 100644 --- a/packages/dictionary/src/references.ts +++ b/packages/dictionary/src/references.ts @@ -224,11 +224,6 @@ const replaceReferencesInStringRestrictionsObject = ( } if (restrictionsObject.regex !== undefined) { const updatedRegex = resolveAllReferences(restrictionsObject.regex, references, discovered, visited); - if (Array.isArray(updatedRegex)) { - throw new InvalidReferenceError( - `Regex restriction with reference '${restrictionsObject.regex}' resolves to an array. This restriction must be a string.`, - ); - } restrictionsObject.regex = updatedRegex; } } diff --git a/packages/dictionary/test/fixtures/references/codeList_references/input.ts b/packages/dictionary/test/fixtures/references/codeList_references/input.ts index 3378a57b..4b4d61d1 100644 --- a/packages/dictionary/test/fixtures/references/codeList_references/input.ts +++ b/packages/dictionary/test/fixtures/references/codeList_references/input.ts @@ -35,7 +35,9 @@ const content: Dictionary = { meta: { default: 'Unknown', }, - restrictions: {}, + restrictions: { + codeList: '#/SINGLE_VALUE', + }, }, ], }, @@ -43,6 +45,7 @@ const content: Dictionary = { references: { ID_REG_EXP: '^[\\w\\s\\W]{5,}$', SEX: ['Male', 'Female'], + SINGLE_VALUE: 'this', }, }; export default content; diff --git a/packages/dictionary/test/fixtures/references/codeList_references/output.ts b/packages/dictionary/test/fixtures/references/codeList_references/output.ts index c18a7102..0105cdfc 100644 --- a/packages/dictionary/test/fixtures/references/codeList_references/output.ts +++ b/packages/dictionary/test/fixtures/references/codeList_references/output.ts @@ -35,7 +35,7 @@ const content: Dictionary = { meta: { default: 'Unknown', }, - restrictions: {}, + restrictions: { codeList: ['this'] }, }, ], }, diff --git a/packages/dictionary/test/fixtures/references/nested_meta_references/input.ts b/packages/dictionary/test/fixtures/references/nested_meta_references/input.ts new file mode 100644 index 00000000..d7cadd9c --- /dev/null +++ b/packages/dictionary/test/fixtures/references/nested_meta_references/input.ts @@ -0,0 +1,29 @@ +import { Dictionary } from '../../../../src'; + +const content: Dictionary = { + name: 'Test Dictionary', + version: '1.2', + schemas: [ + { + name: 'donor', + description: 'Donor Entity', + fields: [ + { + name: 'donor_submitter_id', + valueType: 'string', + description: 'Unique identifier for donor; assigned by data provider', + meta: { + displayName: 'Submitter Donor ID', + exampleReference: '#/meta/example', + nested1: { nested2: { nested3: ['#/meta/nestedMeta/example', 'array'] } }, + }, + }, + ], + }, + ], + references: { + text: 'text', + meta: { example: 'This is an example', nestedMeta: { example: ['some', '#/text', 'in an'] } }, + }, +}; +export default content; diff --git a/packages/dictionary/test/fixtures/references/nested_meta_references/output.ts b/packages/dictionary/test/fixtures/references/nested_meta_references/output.ts new file mode 100644 index 00000000..0f3edc78 --- /dev/null +++ b/packages/dictionary/test/fixtures/references/nested_meta_references/output.ts @@ -0,0 +1,25 @@ +import { Dictionary } from '../../../../src'; + +const content: Dictionary = { + name: 'Test Dictionary', + version: '1.2', + schemas: [ + { + name: 'donor', + description: 'Donor Entity', + fields: [ + { + name: 'donor_submitter_id', + valueType: 'string', + description: 'Unique identifier for donor; assigned by data provider', + meta: { + displayName: 'Submitter Donor ID', + exampleReference: 'This is an example', + nested1: { nested2: { nested3: ['some', 'text', 'in an', 'array'] } }, + }, + }, + ], + }, + ], +}; +export default content; diff --git a/packages/dictionary/test/fixtures/references/no_referece_tags/input.ts b/packages/dictionary/test/fixtures/references/no_referece_tags/input.ts new file mode 100644 index 00000000..c0a3697b --- /dev/null +++ b/packages/dictionary/test/fixtures/references/no_referece_tags/input.ts @@ -0,0 +1,55 @@ +import { Dictionary } from '../../../../src'; + +const content: Dictionary = { + name: 'Test Dictionary', + version: '1.2', + schemas: [ + { + name: 'donor', + description: 'Donor Entity', + fields: [ + { + name: 'donor_submitter_id', + valueType: 'string', + description: 'Unique identifier for donor; assigned by data provider', + meta: { + displayName: 'Submitter Donor ID', + key: true, + }, + restrictions: { + regex: '^[\\w]*$', + }, + }, + { + name: 'gender', + valueType: 'string', + description: 'Donor Biological Sex', + restrictions: { + codeList: ['Male', 'Female', 'Other'], + }, + }, + { + name: 'ethnicity', + valueType: 'string', + description: 'Self described', + meta: { + default: 'Unknown', + }, + restrictions: {}, + }, + ], + }, + ], + references: { + regex: { + REPEATED_TEXT: '(\\w+).*\\1', + ALPHA_ONLY: '^[A-Za-z]*$', + COMBINED: ['#/regex/ID_REG_EXP', '#/regex/ALPHA_ONLY'], + }, + enums: { + SEX: ['Male', 'Female', 'Other'], + }, + }, +}; + +export default content; diff --git a/packages/dictionary/test/fixtures/references/no_referece_tags/output.ts b/packages/dictionary/test/fixtures/references/no_referece_tags/output.ts new file mode 100644 index 00000000..f59a1365 --- /dev/null +++ b/packages/dictionary/test/fixtures/references/no_referece_tags/output.ts @@ -0,0 +1,44 @@ +import { Dictionary } from '../../../../src'; + +const content: Dictionary = { + name: 'Test Dictionary', + version: '1.2', + schemas: [ + { + name: 'donor', + description: 'Donor Entity', + fields: [ + { + name: 'donor_submitter_id', + valueType: 'string', + description: 'Unique identifier for donor; assigned by data provider', + meta: { + displayName: 'Submitter Donor ID', + key: true, + }, + restrictions: { + regex: '^[\\w]*$', + }, + }, + { + name: 'gender', + valueType: 'string', + description: 'Donor Biological Sex', + restrictions: { + codeList: ['Male', 'Female', 'Other'], + }, + }, + { + name: 'ethnicity', + valueType: 'string', + description: 'Self described', + meta: { + default: 'Unknown', + }, + restrictions: {}, + }, + ], + }, + ], +}; +export default content; diff --git a/packages/dictionary/test/fixtures/references/regex_reference/input_with_array.ts b/packages/dictionary/test/fixtures/references/regex_reference_with_array/input.ts similarity index 90% rename from packages/dictionary/test/fixtures/references/regex_reference/input_with_array.ts rename to packages/dictionary/test/fixtures/references/regex_reference_with_array/input.ts index bb394bea..84624452 100644 --- a/packages/dictionary/test/fixtures/references/regex_reference/input_with_array.ts +++ b/packages/dictionary/test/fixtures/references/regex_reference_with_array/input.ts @@ -19,7 +19,7 @@ const content: Dictionary = { ], references: { regex: { - ID_FORMAT: ['bad', 'reference'], + ID_FORMAT: ['good', 'reference'], }, }, }; diff --git a/packages/dictionary/test/fixtures/references/regex_reference_with_array/output.ts b/packages/dictionary/test/fixtures/references/regex_reference_with_array/output.ts new file mode 100644 index 00000000..03f856d1 --- /dev/null +++ b/packages/dictionary/test/fixtures/references/regex_reference_with_array/output.ts @@ -0,0 +1,21 @@ +import { Dictionary } from '../../../../src'; + +const content: Dictionary = { + name: 'Test Dictionary', + version: '1.0', + schemas: [ + { + name: 'participant', + fields: [ + { + name: 'some_id', + valueType: 'string', + restrictions: { + regex: ['good', 'reference'], + }, + }, + ], + }, + ], +}; +export default content; diff --git a/packages/dictionary/test/fixtures/references/restrictions_array_with_references/input.ts b/packages/dictionary/test/fixtures/references/restrictions_array_with_references/input.ts new file mode 100644 index 00000000..640b0f56 --- /dev/null +++ b/packages/dictionary/test/fixtures/references/restrictions_array_with_references/input.ts @@ -0,0 +1,53 @@ +import { Dictionary } from '../../../../src'; + +const content: Dictionary = { + name: 'Test Dictionary', + version: '1.2', + schemas: [ + { + name: 'donor', + description: 'Donor Entity', + fields: [ + { + name: 'imaginary_field', + valueType: 'string', + description: 'Nonsense example to test an array of restriction objects.', + meta: { + displayName: 'Submitter Donor ID', + key: true, + }, + restrictions: [ + { + regex: ['#/regex/REPEATED_TEXT', '#/regex/ALPHA_ONLY'], + }, + { + codeList: '#/enums/SEX', + required: true, + }, + ], + }, + { + name: 'nonsense_field', + valueType: 'string', + description: 'another meaningless example testing complex references within an array.', + restrictions: [ + { + regex: '#/regex/COMBINED', + }, + ], + }, + ], + }, + ], + references: { + regex: { + REPEATED_TEXT: '(\\w+).*\\1', + ALPHA_ONLY: '^[A-Za-z]*$', + COMBINED: ['#/regex/REPEATED_TEXT', '#/regex/ALPHA_ONLY'], + }, + enums: { + SEX: ['Male', 'Female', 'Other'], + }, + }, +}; +export default content; diff --git a/packages/dictionary/test/fixtures/references/restrictions_array_with_references/output.ts b/packages/dictionary/test/fixtures/references/restrictions_array_with_references/output.ts new file mode 100644 index 00000000..ee112600 --- /dev/null +++ b/packages/dictionary/test/fixtures/references/restrictions_array_with_references/output.ts @@ -0,0 +1,43 @@ +import { Dictionary } from '../../../../src'; + +const content: Dictionary = { + name: 'Test Dictionary', + version: '1.2', + schemas: [ + { + name: 'donor', + description: 'Donor Entity', + fields: [ + { + name: 'imaginary_field', + valueType: 'string', + description: 'Nonsense example to test an array of restriction objects.', + meta: { + displayName: 'Submitter Donor ID', + key: true, + }, + restrictions: [ + { + regex: ['(\\w+).*\\1', '^[A-Za-z]*$'], + }, + { + codeList: ['Male', 'Female', 'Other'], + required: true, + }, + ], + }, + { + name: 'nonsense_field', + valueType: 'string', + description: 'another meaningless example testing complex references within an array.', + restrictions: [ + { + regex: ['(\\w+).*\\1', '^[A-Za-z]*$'], + }, + ], + }, + ], + }, + ], +}; +export default content; diff --git a/packages/dictionary/test/references.spec.ts b/packages/dictionary/test/references.spec.ts index b2247198..3f20af4a 100644 --- a/packages/dictionary/test/references.spec.ts +++ b/packages/dictionary/test/references.spec.ts @@ -26,14 +26,21 @@ import codeListReferencesOutput from './fixtures/references/codeList_references/ import cyclicReferencesInput from './fixtures/references/cyclic_references/input'; import emptyReferencesInput from './fixtures/references/empty_references_section/input'; import emptyReferencesOutput from './fixtures/references/empty_references_section/output'; +import nestedMetaReferencesInput from './fixtures/references/nested_meta_references/input'; +import nestedMetaReferencesOutput from './fixtures/references/nested_meta_references/output'; import noReferencesSectionInput from './fixtures/references/no_references_section/input'; import noReferencesSectionOutput from './fixtures/references/no_references_section/output'; +import noReferencesTagsInput from './fixtures/references/no_referece_tags/input'; +import noReferencesTagsOutput from './fixtures/references/no_referece_tags/output'; import nonExistingReferencesInput from './fixtures/references/non_existing_references/input'; import referencesWithinReferencesInput from './fixtures/references/references_within_references/input'; import referencesWithinReferencesOutput from './fixtures/references/references_within_references/output'; import regexReferencesInput from './fixtures/references/regex_reference/input'; -import regexArrayReferencesInput from './fixtures/references/regex_reference/input_with_array'; import regexReferencesOutput from './fixtures/references/regex_reference/output'; +import regexArrayReferencesInput from './fixtures/references/regex_reference_with_array/input'; +import regexArrayReferencesOutput from './fixtures/references/regex_reference_with_array/output'; +import restrictionsArrayWithReferencesInput from './fixtures/references/restrictions_array_with_references/input'; +import restrictionsArrayWithReferencesOutput from './fixtures/references/restrictions_array_with_references/output'; import selfReferencesInput from './fixtures/references/self_references/input'; import simpleReferencesInput from './fixtures/references/simple_references/input'; import simpleReferencesOutput from './fixtures/references/simple_references/output'; @@ -48,7 +55,8 @@ describe('Replace References', () => { expect(replacedDictionary).to.deep.eq(emptyReferencesOutput); }); it('Returns unmodified schema when no ReferenceTag values are used', () => { - assert(false, 'unimplemented test'); + const replacedDictionary = replaceReferences(noReferencesTagsInput); + expect(replacedDictionary).to.deep.eq(noReferencesTagsOutput); }); it('Throws an error when a ReferenceTag to an unknown path is provided', () => { expect(() => replaceReferences(nonExistingReferencesInput)).to.throw( @@ -61,51 +69,39 @@ describe('Replace References', () => { it('Throws an error if self references are found', () => { expect(() => replaceReferences(selfReferencesInput)).to.throw("Cyclical references found for '#/SELF_REFERENCE'"); }); - describe('Meta', () => { - it('Replaces reference tag value in meta root', () => { - assert(false, 'unimplemented test'); - }); - it('Replaces reference tag value in meta nested properties', () => { - assert(false, 'unimplemented test'); - }); - it('Replaces reference tag value in meta string array', () => { - assert(false, 'unimplemented test'); - }); - }); - describe('String Restrictions', () => { - it('CodeList with ReferenceTag to single value is replaced with an array with the single value', () => { - assert(false, 'unimplemented test'); - }); - it('CodeList with array containing ReferenceTag is replaced by array with reference values added to array', () => { - assert(false, 'unimplemented test'); - }); - it('Regex with ReferenceTag is replaced by single value', () => { - assert(false, 'unimplemented test'); - }); - it('Regex with ReferenceTag to array value throws an error', () => { - assert(false, 'unimplemented test'); - }); + it('Replaces references when restrictions are in arrays', () => { + restrictionsArrayWithReferencesInput; + const replacedDictionary = replaceReferences(restrictionsArrayWithReferencesInput); + expect(replacedDictionary).to.deep.eq(restrictionsArrayWithReferencesOutput); }); // TODO: Check reference replacement in meta it('Should return the schema with simple references replaced', () => { const replacedDictionary = replaceReferences(simpleReferencesInput); expect(replacedDictionary).to.deep.eq(simpleReferencesOutput); }); - it('Should return the schema where references inside codeLists are replaced', () => { - const replacedDictionary = replaceReferences(codeListReferencesInput); - expect(replacedDictionary).to.deep.eq(codeListReferencesOutput); - }); it('Should return the schema where references inside references are replaced', () => { const output = replaceReferences(referencesWithinReferencesInput); expect(output).to.deep.eq(referencesWithinReferencesOutput); }); - it('Regex Reference replaced successfully', () => { - const output = replaceReferences(regexReferencesInput); - expect(output).to.deep.eq(regexReferencesOutput); + it('Replaces reference tag value in meta nested properties', () => { + const replacedDictionary = replaceReferences(nestedMetaReferencesInput); + expect(replacedDictionary).to.deep.eq(nestedMetaReferencesOutput); }); - it('Regex Reference cannot be an array', () => { - expect(() => replaceReferences(regexArrayReferencesInput)).to.throw( - `Regex restriction with reference '#/regex/ID_FORMAT' resolves to an array. This restriction must be a string.`, - ); + describe('String Restrictions', () => { + it('CodeList with references are replaced', () => { + // has a couple test cases in the test fixture dictionary: + // - array containing ReferenceTag is replaced by array with reference values added to array + // - CodeList with ReferenceTag to single value is replaced with an array with the single value + const replacedDictionary = replaceReferences(codeListReferencesInput); + expect(replacedDictionary).to.deep.eq(codeListReferencesOutput); + }); + it('Regex with ReferenceTag is replaced by single value', () => { + const output = replaceReferences(regexReferencesInput); + expect(output).to.deep.eq(regexReferencesOutput); + }); + it('Regex with ReferenceTag to array value throws an error', () => { + const output = replaceReferences(regexArrayReferencesInput); + expect(output).to.deep.eq(regexArrayReferencesOutput); + }); }); }); From 528660019a03d4998b7f89fd0092dcb66a66d271 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 4 Aug 2024 19:57:33 -0400 Subject: [PATCH 08/21] ConditionalRestrictionTest has optional case with proper default in validation --- packages/dictionary/src/metaSchema/restrictionsSchemas.ts | 2 +- .../src/validateField/conditions/testConditionalRestriction.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts index 95669582..4292255a 100644 --- a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts +++ b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts @@ -188,7 +188,7 @@ export type RestrictionCondition = zod.infer; export const ConditionalRestrictionTest = zod.object({ conditions: zod.array(RestrictionCondition), - case: ArrayTestCase, + case: ArrayTestCase.optional(), }); export type ConditionalRestrictionTest = zod.infer; diff --git a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts index be81e356..64ff6f2a 100644 --- a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts +++ b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts @@ -107,5 +107,5 @@ export const testConditionalRestriction = ( record: DataRecord, ): boolean => { const results = conditionalTest.conditions.map((condition) => testCondition(condition, value, record)); - return resultForArrayTestCase(results, conditionalTest.case); + return resultForArrayTestCase(results, conditionalTest.case || ARRAY_TEST_CASE_DEFAULT); }; From cfc8cd7b1af3d66a3df07d35871abc79d68e8a20 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Wed, 7 Aug 2024 11:37:00 -0400 Subject: [PATCH 09/21] Single match object per condition, instead of array --- .../src/metaSchema/restrictionsSchemas.ts | 16 +++++- packages/dictionary/src/references.ts | 26 +++++---- ...yTestCase.ts => resultForArrayTestCase.ts} | 0 .../conditions/testConditionalRestriction.ts | 55 +++++++++---------- .../validateRecord/validateRecord.spec.ts | 1 - 5 files changed, 53 insertions(+), 45 deletions(-) rename packages/validation/src/utils/{resultForArayTestCase.ts => resultForArrayTestCase.ts} (100%) diff --git a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts index 4292255a..6397d0c6 100644 --- a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts +++ b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts @@ -166,7 +166,17 @@ export type MatchRuleRegex = zod.infer; export const MatchRuleValue = FieldValue; export type MatchRuleValue = zod.infer; -export const ConditionMatchRule = zod +export const MatchRule = zod.union([ + MatchRuleCodeList, + MatchRuleCount, + MatchRuleExists, + MatchRuleRange, + MatchRuleRegex, + MatchRuleValue, +]); +export type MatchRule = zod.infer; + +export const ConditionMatch = zod .object({ codeList: MatchRuleCodeList, count: MatchRuleCount, @@ -176,11 +186,11 @@ export const ConditionMatchRule = zod value: MatchRuleValue, }) .partial(); -type ConditionMatchRule = zod.infer; +type ConditionMatch = zod.infer; export const RestrictionCondition = zod.object({ fields: zod.string().array(), - match: ConditionMatchRule.array(), + match: ConditionMatch, case: ArrayTestCase.optional(), arrayFieldCase: ArrayTestCase.optional(), }); diff --git a/packages/dictionary/src/references.ts b/packages/dictionary/src/references.ts index 26995444..04198831 100644 --- a/packages/dictionary/src/references.ts +++ b/packages/dictionary/src/references.ts @@ -193,18 +193,20 @@ const replaceReferencesInStringRestrictionsObject = ( if ('if' in restrictionsObject) { // Do replacements inside the if conditions restrictionsObject.if.conditions = restrictionsObject.if.conditions.map((condition) => { - condition.match = condition.match.map((match) => { - if (match.codeList && !isNumberArray(match.codeList)) { - match.codeList = TypeUtils.asArray(resolveAllReferences(match.codeList, references, discovered, visited)); - } - if (typeof match.value === 'string' || isStringArray(match.value)) { - match.value = resolveAllReferences(match.value, references, discovered, visited); - } - if (match.regex) { - match.regex = TypeUtils.asArray(resolveAllReferences(match.regex, references, discovered, visited)); - } - return match; - }); + if (condition.match.codeList && !isNumberArray(condition.match.codeList)) { + condition.match.codeList = TypeUtils.asArray( + resolveAllReferences(condition.match.codeList, references, discovered, visited), + ); + } + if (typeof condition.match.value === 'string' || isStringArray(condition.match.value)) { + condition.match.value = resolveAllReferences(condition.match.value, references, discovered, visited); + } + if (condition.match.regex) { + condition.match.regex = TypeUtils.asArray( + resolveAllReferences(condition.match.regex, references, discovered, visited), + ); + } + return condition; }); diff --git a/packages/validation/src/utils/resultForArayTestCase.ts b/packages/validation/src/utils/resultForArrayTestCase.ts similarity index 100% rename from packages/validation/src/utils/resultForArayTestCase.ts rename to packages/validation/src/utils/resultForArrayTestCase.ts diff --git a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts index 64ff6f2a..2e989c28 100644 --- a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts +++ b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts @@ -45,41 +45,38 @@ const testConditionForSingularValue = ( _value: SingleDataValue, fieldValues: DataRecordValue[], ): boolean => { - const results = condition.match.map((match) => { - if (match.codeList) { - if (!allValuesPassMatchTest(fieldValues, match.codeList, testMatchCodeList)) { - return false; - } + if (condition.match.codeList) { + if (!allValuesPassMatchTest(fieldValues, condition.match.codeList, testMatchCodeList)) { + return false; } - // count rule can have value of 0 so we need to directly check for undefined - if (match.count !== undefined) { - if (!allValuesPassMatchTest(fieldValues, match.count, testMatchCount)) { - return false; - } + } + // count rule can have value of 0 so we need to directly check for undefined + if (condition.match.count !== undefined) { + if (!allValuesPassMatchTest(fieldValues, condition.match.count, testMatchCount)) { + return false; } - if (match.exists) { - if (!allValuesPassMatchTest(fieldValues, match.exists, testMatchExists)) { - return false; - } + } + if (condition.match.exists) { + if (!allValuesPassMatchTest(fieldValues, condition.match.exists, testMatchExists)) { + return false; } - if (match.range) { - if (!allValuesPassMatchTest(fieldValues, match.range, testMatchRange)) { - return false; - } + } + if (condition.match.range) { + if (!allValuesPassMatchTest(fieldValues, condition.match.range, testMatchRange)) { + return false; } - if (match.regex) { - if (!allValuesPassMatchTest(fieldValues, match.regex, testMatchRegex)) { - return false; - } + } + if (condition.match.regex) { + if (!allValuesPassMatchTest(fieldValues, condition.match.regex, testMatchRegex)) { + return false; } - if (match.value) { - if (!allValuesPassMatchTest(fieldValues, match.value, testMatchValue)) { - return false; - } + } + if (condition.match.value) { + if (!allValuesPassMatchTest(fieldValues, condition.match.value, testMatchValue)) { + return false; } - return true; - }); - return resultForArrayTestCase(results, condition.case || ARRAY_TEST_CASE_DEFAULT); + } + return true; }; const testConditionForArray = ( diff --git a/packages/validation/test/validateRecord/validateRecord.spec.ts b/packages/validation/test/validateRecord/validateRecord.spec.ts index d338f5d7..1f8870c2 100644 --- a/packages/validation/test/validateRecord/validateRecord.spec.ts +++ b/packages/validation/test/validateRecord/validateRecord.spec.ts @@ -143,7 +143,6 @@ describe('Record - validateRecord', () => { }, schemaAllDataTypesMixedRestrictions, ); - console.log(JSON.stringify(result, null, 2)); expect(result.valid).false; assert(result.valid === false); From 91d11f968e86e76660349e6cd388774904bbb8d0 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sat, 10 Aug 2024 18:00:46 -0400 Subject: [PATCH 10/21] Fix recursive conditional restriction parsing and add tests --- .../src/metaSchema/dictionarySchemas.ts | 22 +- .../src/metaSchema/restrictionsSchemas.ts | 14 +- .../dictionarySchemas.spec.ts} | 90 +-------- .../metaSchema/restrictionsSchemas.spec.ts | 188 ++++++++++++++++++ packages/dictionary/test/recursion.spec.ts | 25 +++ .../booleanRegex.spec.ts} | 2 +- .../test/{ => utils}/versionUtils.spec.ts | 2 +- 7 files changed, 239 insertions(+), 104 deletions(-) rename packages/dictionary/test/{dictionaryTypes.spec.ts => metaSchema/dictionarySchemas.spec.ts} (81%) create mode 100644 packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts create mode 100644 packages/dictionary/test/recursion.spec.ts rename packages/dictionary/test/{dataTypes.spec.ts => types/booleanRegex.spec.ts} (98%) rename packages/dictionary/test/{ => utils}/versionUtils.spec.ts (98%) diff --git a/packages/dictionary/src/metaSchema/dictionarySchemas.ts b/packages/dictionary/src/metaSchema/dictionarySchemas.ts index a04020c5..c3dd7767 100644 --- a/packages/dictionary/src/metaSchema/dictionarySchemas.ts +++ b/packages/dictionary/src/metaSchema/dictionarySchemas.ts @@ -66,47 +66,57 @@ export type SchemaFieldValueType = zod.infer; /* ****************************** * * Field Type Restriction Objects * * ****************************** */ -export const BooleanFieldRestrictions = zod.object({ required: zod.boolean() }).partial(); +export const BooleanFieldRestrictions = zod.object({ empty: zod.boolean(), required: zod.boolean() }).partial(); export type BooleanFieldRestrictions = zod.infer; -const BooleanFieldRestrictionsObject = BooleanFieldRestrictions.or(ConditionalRestriction(BooleanFieldRestrictions)); +const BooleanFieldRestrictionsObject = BooleanFieldRestrictions.strict().or( + ConditionalRestriction(BooleanFieldRestrictions.strict()), +); export type BooleanFieldRestrictionsObject = zod.infer; export const IntegerFieldRestrictions = zod .object({ codeList: RestrictionCodeListInteger.or(ReferenceTag), + empty: zod.boolean(), required: zod.boolean(), range: RestrictionIntegerRange, }) .partial(); export type IntegerFieldRestrictions = zod.infer; -const IntegerFieldRestrictionsObject = IntegerFieldRestrictions.or(ConditionalRestriction(IntegerFieldRestrictions)); +const IntegerFieldRestrictionsObject = IntegerFieldRestrictions.strict().or( + ConditionalRestriction(IntegerFieldRestrictions.strict()), +); export type IntegerFieldRestrictionsObject = zod.infer; export const NumberFieldRestrictions = zod .object({ codeList: RestrictionCodeListNumber.or(ReferenceTag), + empty: zod.boolean(), required: zod.boolean(), range: RestrictionNumberRange, }) .partial(); export type NumberFieldRestrictions = zod.infer; -const NumberFieldRestrictionsObject = NumberFieldRestrictions.or(ConditionalRestriction(NumberFieldRestrictions)); +const NumberFieldRestrictionsObject = NumberFieldRestrictions.strict().or( + ConditionalRestriction(NumberFieldRestrictions.strict()), +); export type NumberFieldRestrictionsObject = zod.infer; export const StringFieldRestrictions = zod .object({ codeList: RestrictionCodeListString.or(ReferenceTag), + empty: zod.boolean(), required: zod.boolean(), regex: RestrictionRegex.or(ReferenceTag), - // TODO: regex can be optionally be an array. this would simplify resolving references and allow multiple regex conditions in a single object }) .partial(); export type StringFieldRestrictions = zod.infer; -const StringFieldRestrictionsObject = StringFieldRestrictions.or(ConditionalRestriction(StringFieldRestrictions)); +const StringFieldRestrictionsObject = StringFieldRestrictions.strict().or( + ConditionalRestriction(StringFieldRestrictions.strict()), +); export type StringFieldRestrictionsObject = zod.infer; export const AnyFieldRestrictions = zod.union([ diff --git a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts index 6397d0c6..d6210a36 100644 --- a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts +++ b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts @@ -25,6 +25,7 @@ export const Integer = zod.number().int(); export const FieldRestrictionTypes = { codeList: 'codeList', + empty: 'empty', range: 'range', required: 'required', regex: 'regex', @@ -33,9 +34,6 @@ export const FieldRestrictionTypes = { } as const; export type FieldRestrictionType = Values; -export const RestrictionScript = zod.array(zod.string().or(ReferenceTag)).min(1); //TODO: script formatting validation -export type RestrictionScript = zod.infer; - export const RestrictionCodeListString = zod.union([zod.string(), ReferenceTag]).array().min(1); export type RestrictionCodeListString = zod.infer; @@ -213,16 +211,16 @@ export type ConditionalRestriction = { | ConditionalRestriction | (TRestrictionObject | ConditionalRestriction)[]; }; -export const ConditionalRestriction = ( - restrictionsSchema: ZodSchema, -): ZodSchema> => { +export const ConditionalRestriction = ( + restrictionsSchema: TRestrictionObjectSchema, +): ZodSchema>> => { const restrictionOrConditional = zod.union([ restrictionsSchema, zod.lazy(() => ConditionalRestriction(restrictionsSchema)), ]); return zod.object({ if: ConditionalRestrictionTest, - then: restrictionOrConditional.or(restrictionOrConditional).optional(), - else: restrictionOrConditional.or(restrictionOrConditional).optional(), + then: restrictionOrConditional.or(restrictionOrConditional.array()).optional(), + else: restrictionOrConditional.or(restrictionOrConditional.array()).optional(), }); }; diff --git a/packages/dictionary/test/dictionaryTypes.spec.ts b/packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts similarity index 81% rename from packages/dictionary/test/dictionaryTypes.spec.ts rename to packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts index 6a62253e..5ab1cab2 100644 --- a/packages/dictionary/test/dictionaryTypes.spec.ts +++ b/packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts @@ -18,23 +18,9 @@ */ import { expect } from 'chai'; -import { - BooleanFieldRestrictions, - Dictionary, - DictionaryMeta, - Integer, - IntegerFieldRestrictions, - NameValue, - NumberFieldRestrictions, - RestrictionIntegerRange, - RestrictionNumberRange, - Schema, - SchemaField, - StringFieldRestrictions, - VersionString, -} from '../src'; +import { Dictionary, DictionaryMeta, NameValue, Schema, SchemaField, VersionString } from '../../src'; -describe('Dictionary Types', () => { +describe('Dictionary Schemas', () => { describe('NameValue', () => { it('Rejects empty string', () => { expect(NameValue.safeParse('').success).false; @@ -54,78 +40,6 @@ describe('Dictionary Types', () => { }); }); - describe('Integer', () => { - it("Can't be float", () => { - expect(Integer.safeParse(1.3).success).false; - expect(Integer.safeParse(2.0000001).success).false; - // Note: float precision issues, if the float resolves to a whole number the value will be accepted. - }); - it("Can't be string, boolean, object, array", () => { - expect(Integer.safeParse('1').success).false; - expect(Integer.safeParse(true).success).false; - expect(Integer.safeParse([1]).success).false; - expect(Integer.safeParse({}).success).false; - expect(Integer.safeParse({ thing: 1 }).success).false; - }); - it('Can be integer', () => { - expect(Integer.safeParse(1).success).true; - expect(Integer.safeParse(0).success).true; - expect(Integer.safeParse(-1).success).true; - expect(Integer.safeParse(1123).success).true; - }); - }); - describe('RangeRestriction', () => { - it("Integer Range Can't have exclusiveMin and Min", () => { - expect(RestrictionIntegerRange.safeParse({ exclusiveMin: 0, min: 0 }).success).false; - expect(RestrictionIntegerRange.safeParse({ min: 0 }).success).true; - expect(RestrictionIntegerRange.safeParse({ exclusiveMin: 0 }).success).true; - }); - it("Integer Range Can't have exclusiveMax and Max", () => { - expect(RestrictionIntegerRange.safeParse({ exclusiveMax: 0, max: 0 }).success).false; - expect(RestrictionIntegerRange.safeParse({ max: 0 }).success).true; - expect(RestrictionIntegerRange.safeParse({ exclusiveMax: 0 }).success).true; - }); - it("Number Range Can't have exclusiveMin and Min", () => { - expect(RestrictionNumberRange.safeParse({ exclusiveMin: 0, min: 0 }).success).false; - expect(RestrictionNumberRange.safeParse({ min: 0 }).success).true; - expect(RestrictionNumberRange.safeParse({ exclusiveMin: 0 }).success).true; - }); - it("Number Range Can't have exclusiveMax and Max", () => { - expect(RestrictionNumberRange.safeParse({ exclusiveMax: 0, max: 0 }).success).false; - expect(RestrictionNumberRange.safeParse({ max: 0 }).success).true; - expect(RestrictionNumberRange.safeParse({ exclusiveMax: 0 }).success).true; - }); - }); - describe('RegexRestriction', () => { - it('Accepts valid regex', () => { - expect(StringFieldRestrictions.safeParse({ regex: '[a-zA-Z]' }).success).true; - expect( - StringFieldRestrictions.safeParse({ - regex: '^({((([WUBRG]|([0-9]|[1-9][0-9]*))(/[WUBRG])?)|(X)|([WUBRG](/[WUBRG])?/[P]))})+$', - }).success, - ).true; - }); - it('Rejects invalid regex', () => { - expect(StringFieldRestrictions.safeParse({ regex: '[' }).success).false; - }); - }); - describe('UniqueRestriction', () => { - it('All fields accept unique restriction', () => { - expect(StringFieldRestrictions.safeParse({ unique: true }).success).true; - expect(NumberFieldRestrictions.safeParse({ unique: true }).success).true; - expect(IntegerFieldRestrictions.safeParse({ unique: true }).success).true; - expect(BooleanFieldRestrictions.safeParse({ unique: true }).success).true; - }); - }); - describe('ScriptRestriction', () => { - it('All fields accept script restriction', () => { - expect(StringFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; - expect(NumberFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; - expect(IntegerFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; - expect(BooleanFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; - }); - }); - describe('Fields', () => { it('Can have no restrictions', () => { const fieldString: SchemaField = { diff --git a/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts b/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts new file mode 100644 index 00000000..2551fb0a --- /dev/null +++ b/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { + BooleanFieldRestrictions, + Integer, + IntegerFieldRestrictions, + NumberFieldRestrictions, + RestrictionIntegerRange, + RestrictionNumberRange, + SchemaField, + StringFieldRestrictions, + type ConditionalRestriction, + type SchemaStringField, + type StringFieldRestrictionsObject, +} from '../../src'; +import assert from 'assert'; + +describe('Restriction Schemas', () => { + describe('Integer', () => { + it("Can't be float", () => { + expect(Integer.safeParse(1.3).success).false; + expect(Integer.safeParse(2.0000001).success).false; + // Note: float precision issues, if the float resolves to a whole number the value will be accepted. + }); + it("Can't be string, boolean, object, array", () => { + expect(Integer.safeParse('1').success).false; + expect(Integer.safeParse(true).success).false; + expect(Integer.safeParse([1]).success).false; + expect(Integer.safeParse({}).success).false; + expect(Integer.safeParse({ thing: 1 }).success).false; + }); + it('Can be integer', () => { + expect(Integer.safeParse(1).success).true; + expect(Integer.safeParse(0).success).true; + expect(Integer.safeParse(-1).success).true; + expect(Integer.safeParse(1123).success).true; + }); + }); + describe('RangeRestriction', () => { + it("Integer Range Can't have exclusiveMin and Min", () => { + expect(RestrictionIntegerRange.safeParse({ exclusiveMin: 0, min: 0 }).success).false; + expect(RestrictionIntegerRange.safeParse({ min: 0 }).success).true; + expect(RestrictionIntegerRange.safeParse({ exclusiveMin: 0 }).success).true; + }); + it("Integer Range Can't have exclusiveMax and Max", () => { + expect(RestrictionIntegerRange.safeParse({ exclusiveMax: 0, max: 0 }).success).false; + expect(RestrictionIntegerRange.safeParse({ max: 0 }).success).true; + expect(RestrictionIntegerRange.safeParse({ exclusiveMax: 0 }).success).true; + }); + it("Number Range Can't have exclusiveMin and Min", () => { + expect(RestrictionNumberRange.safeParse({ exclusiveMin: 0, min: 0 }).success).false; + expect(RestrictionNumberRange.safeParse({ min: 0 }).success).true; + expect(RestrictionNumberRange.safeParse({ exclusiveMin: 0 }).success).true; + }); + it("Number Range Can't have exclusiveMax and Max", () => { + expect(RestrictionNumberRange.safeParse({ exclusiveMax: 0, max: 0 }).success).false; + expect(RestrictionNumberRange.safeParse({ max: 0 }).success).true; + expect(RestrictionNumberRange.safeParse({ exclusiveMax: 0 }).success).true; + }); + }); + describe('RegexRestriction', () => { + it('Accepts valid regex', () => { + expect(StringFieldRestrictions.safeParse({ regex: '[a-zA-Z]' }).success).true; + expect( + StringFieldRestrictions.safeParse({ + regex: '^({((([WUBRG]|([0-9]|[1-9][0-9]*))(/[WUBRG])?)|(X)|([WUBRG](/[WUBRG])?/[P]))})+$', + }).success, + ).true; + }); + it('Rejects invalid regex', () => { + expect(StringFieldRestrictions.safeParse({ regex: '[' }).success).false; + }); + }); + describe('UniqueRestriction', () => { + it('All fields accept unique restriction', () => { + expect(StringFieldRestrictions.safeParse({ unique: true }).success).true; + expect(NumberFieldRestrictions.safeParse({ unique: true }).success).true; + expect(IntegerFieldRestrictions.safeParse({ unique: true }).success).true; + expect(BooleanFieldRestrictions.safeParse({ unique: true }).success).true; + }); + }); + describe('ScriptRestriction', () => { + it('All fields accept script restriction', () => { + expect(StringFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; + expect(NumberFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; + expect(IntegerFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; + expect(BooleanFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; + }); + }); + + describe('ConditionalRestrictions', () => { + // These parsing functions seem unnecessary but they are checking for a failure case that was found: + // The restrictions property is a union between a restrictions object and conditional restriction schema, + // and the restriction object has all optional fields, so it will match with a conditional restriction + // object successfully and strip out the if/then/else properties. To avoid this scenario, the RestrictionObject + // schemas make the restriction validation `strict()`. These parsing tests are ensuring that this behaviour + // is not changed. + + it('Parses single conditional restriction', () => { + const restrictions: ConditionalRestriction = { + if: { + conditions: [{ fields: ['another-field'], match: { value: 'asdf' } }], + }, + then: { required: true }, + else: { empty: true }, + }; + const field: SchemaStringField = { + name: 'example-string', + valueType: 'string', + restrictions, + }; + const parseResult = SchemaField.safeParse(field); + expect(parseResult.success).true; + assert(parseResult.success === true); + + expect(parseResult.data).deep.equal(field); + }); + }); + it('Parses conditional restrictions in an array', () => { + const restrictions: Array = [ + { codeList: ['value1', 'value2'] }, + { + if: { + conditions: [{ fields: ['another-field'], match: { value: 'asdf' } }], + }, + then: { required: true }, + else: { empty: true }, + }, + ]; + const field: SchemaStringField = { + name: 'example-string', + valueType: 'string', + restrictions, + }; + const parseResult = SchemaField.safeParse(field); + expect(parseResult.success).true; + assert(parseResult.success === true); + + expect(parseResult.data).deep.equal(field); + }); + + it('Parses nested conditional restrictions', () => { + const restrictions: Array = [ + { codeList: ['value1', 'value2'] }, + { + if: { + conditions: [{ fields: ['first-dependent-field'], match: { value: 'asdf' } }], + }, + then: { + if: { + conditions: [{ fields: ['second-dependent-field'], match: { range: { max: 10, min: 0 } } }], + }, + then: { required: true }, + else: { empty: true }, + }, + else: { empty: true }, + }, + ]; + const field: SchemaStringField = { + name: 'example-string', + valueType: 'string', + restrictions, + }; + const parseResult = SchemaField.safeParse(field); + expect(parseResult.success).true; + assert(parseResult.success === true); + + expect(parseResult.data).deep.equal(field); + }); +}); diff --git a/packages/dictionary/test/recursion.spec.ts b/packages/dictionary/test/recursion.spec.ts new file mode 100644 index 00000000..bcac3d28 --- /dev/null +++ b/packages/dictionary/test/recursion.spec.ts @@ -0,0 +1,25 @@ +import { z as zod } from 'zod'; +import { expect } from 'chai'; +import { RecursiveData, NumberValue, StringValue } from '../src/zodtest'; +describe('Recursion', () => { + it('Non recursive works', () => { + const input: RecursiveData = { value: 'asdf' }; + const parsed = RecursiveData(StringValue).parse(input); + expect(input).deep.equal(parsed); + }); + it('Single nested works', () => { + const input: RecursiveData = { nested: { value: 'asdf' } }; + const parsed = RecursiveData(StringValue).parse(input); + expect(input).deep.equal(parsed); + }); + it('Double nested works', () => { + const input: RecursiveData = { nested: { nested: { value: 'asdf' } } }; + const parsed = RecursiveData(StringValue).parse(input); + expect(input).deep.equal(parsed); + }); + it('Wrong value type has error nested works', () => { + const input: RecursiveData = { nested: { nested: { value: 'asdf' } } }; + const parsed = RecursiveData(NumberValue).safeParse(input); + expect(parsed.success).false; + }); +}); diff --git a/packages/dictionary/test/dataTypes.spec.ts b/packages/dictionary/test/types/booleanRegex.spec.ts similarity index 98% rename from packages/dictionary/test/dataTypes.spec.ts rename to packages/dictionary/test/types/booleanRegex.spec.ts index 9584c38e..5fb2c68f 100644 --- a/packages/dictionary/test/dataTypes.spec.ts +++ b/packages/dictionary/test/types/booleanRegex.spec.ts @@ -18,7 +18,7 @@ */ import { expect } from 'chai'; -import { REGEXP_BOOLEAN_VALUE } from '../src'; +import { REGEXP_BOOLEAN_VALUE } from '../../src'; describe('Data Types', () => { describe('Boolean RegExp', () => { diff --git a/packages/dictionary/test/versionUtils.spec.ts b/packages/dictionary/test/utils/versionUtils.spec.ts similarity index 98% rename from packages/dictionary/test/versionUtils.spec.ts rename to packages/dictionary/test/utils/versionUtils.spec.ts index d63a37d1..65a70818 100644 --- a/packages/dictionary/test/versionUtils.spec.ts +++ b/packages/dictionary/test/utils/versionUtils.spec.ts @@ -18,7 +18,7 @@ */ import { expect } from 'chai'; -import { VersionUtils } from '../'; +import { VersionUtils } from '../../dist'; const { isValidVersion, incrementMinor, incrementMajor, isGreater } = VersionUtils; From 09757c12c4f3b612291c0fcf543bb611aa5e7175 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sat, 10 Aug 2024 18:02:49 -0400 Subject: [PATCH 11/21] Named container and volume in compose file --- apps/server/docker-compose.yaml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/docker-compose.yaml b/apps/server/docker-compose.yaml index e2306739..73f62b7c 100644 --- a/apps/server/docker-compose.yaml +++ b/apps/server/docker-compose.yaml @@ -1,12 +1,13 @@ -version: "2" +version: '2' services: lecternDb: - image: "bitnami/mongodb:4.0" + container_name: lectern-mongo + image: bitnami/mongodb:4.0 ports: - - "27017:27017" + - 27017:27017 volumes: - - "mongodb_data:/bitnami" + - mongodb_data:/bitnami environment: MONGODB_USERNAME: admin MONGODB_PASSWORD: password @@ -14,4 +15,5 @@ services: MONGODB_ROOT_PASSWORD: password123 volumes: mongodb_data: + name: lectern-mongo-data driver: local From 949e68a5d3384fb95d477bd80086a445cc7c0b17 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sat, 10 Aug 2024 18:04:30 -0400 Subject: [PATCH 12/21] Fix import path --- .../src/validateField/conditions/testConditionalRestriction.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts index 2e989c28..ecb481b4 100644 --- a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts +++ b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts @@ -26,7 +26,7 @@ import { type RestrictionCondition, type SingleDataValue, } from '@overture-stack/lectern-dictionary'; -import { resultForArrayTestCase } from '../../utils/resultForArayTestCase'; +import { resultForArrayTestCase } from '../../utils/resultForArrayTestCase'; import { testMatchCount } from './testMatchCount'; import { testMatchCodeList } from './testMatchCodeList'; import { testMatchExists } from './testMatchExists'; From 469416e2893dc057cf2c60c40b1447c74cc0eb55 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sat, 10 Aug 2024 18:05:30 -0400 Subject: [PATCH 13/21] Empty field restriction validation tests --- .../src/validateField/FieldRestrictionRule.ts | 15 ++-- .../src/validateField/restrictions/index.ts | 1 + .../restrictions/resolveFieldRestrictions.ts | 15 ++-- .../validateField/restrictions/testEmpty.ts | 82 +++++++++++++++++++ .../src/validateField/validateField.ts | 16 ++-- 5 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 packages/validation/src/validateField/restrictions/testEmpty.ts diff --git a/packages/validation/src/validateField/FieldRestrictionRule.ts b/packages/validation/src/validateField/FieldRestrictionRule.ts index e27a8261..8446325d 100644 --- a/packages/validation/src/validateField/FieldRestrictionRule.ts +++ b/packages/validation/src/validateField/FieldRestrictionRule.ts @@ -28,6 +28,10 @@ export type FieldRestrictionRuleCodeList = { type: typeof FieldRestrictionTypes.codeList; rule: RestrictionCodeList; }; +export type FieldRestrictionRuleEmpty = { + type: typeof FieldRestrictionTypes.empty; + rule: boolean; +}; export type FieldRestrictionRuleRange = { type: typeof FieldRestrictionTypes.range; @@ -44,18 +48,9 @@ export type FieldRestrictionRuleRegex = { rule: RestrictionRegex; }; -// export type FieldRestrictionRuleScript = { -// type: typeof FieldRestrictionTypes.script; -// rule: RestrictionScript; -// }; - -// export type FieldRestrictionRuleUnique = { -// type: typeof FieldRestrictionTypes.unique; -// rule: boolean; -// }; - export type FieldRestrictionRule = | FieldRestrictionRuleCodeList + | FieldRestrictionRuleEmpty | FieldRestrictionRuleRange | FieldRestrictionRuleRequired | FieldRestrictionRuleRegex; diff --git a/packages/validation/src/validateField/restrictions/index.ts b/packages/validation/src/validateField/restrictions/index.ts index abd8d3b7..2f7b542b 100644 --- a/packages/validation/src/validateField/restrictions/index.ts +++ b/packages/validation/src/validateField/restrictions/index.ts @@ -19,6 +19,7 @@ export * from './resolveFieldRestrictions'; export * from './testCodeList'; +export * from './testEmpty'; export * from './testRange'; export * from './testRegex'; export * from './testRequired'; diff --git a/packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts b/packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts index d1420596..b655b77a 100644 --- a/packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts +++ b/packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts @@ -31,16 +31,16 @@ import { testConditionalRestriction } from '../conditions/testConditionalRestric const extractRulesFromRestriction = (restrictions: AnyFieldRestrictions): FieldRestrictionRule[] => { const rules: FieldRestrictionRule[] = []; - if ('required' in restrictions) { - if (restrictions.required) { - rules.push({ type: 'required', rule: restrictions.required }); - } - } if ('codeList' in restrictions) { if (Array.isArray(restrictions.codeList)) { rules.push({ type: 'codeList', rule: restrictions.codeList }); } } + if ('empty' in restrictions) { + if (restrictions.empty) { + rules.push({ type: 'empty', rule: restrictions.empty }); + } + } if ('range' in restrictions) { if (restrictions.range) { rules.push({ type: 'range', rule: restrictions.range }); @@ -51,6 +51,11 @@ const extractRulesFromRestriction = (restrictions: AnyFieldRestrictions): FieldR rules.push({ type: 'regex', rule: restrictions.regex }); } } + if ('required' in restrictions) { + if (restrictions.required) { + rules.push({ type: 'required', rule: restrictions.required }); + } + } return rules; }; diff --git a/packages/validation/src/validateField/restrictions/testEmpty.ts b/packages/validation/src/validateField/restrictions/testEmpty.ts new file mode 100644 index 00000000..7a28a53f --- /dev/null +++ b/packages/validation/src/validateField/restrictions/testEmpty.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { type ArrayDataValue } from '@overture-stack/lectern-dictionary'; +import { invalid, valid, type TestResult } from '../../types/testResult'; +import type { + FieldRestrictionSingleValueTestFunction, + FieldRestrictionTestFunction, + RestrictionTestInvalidInfo, +} from '../FieldRestrictionTest'; +import { createFieldRestrictionTestForArrays } from './createFieldRestrictionTestForArrays'; + +const testEmptySingleValue: FieldRestrictionSingleValueTestFunction = (rule, value) => { + if (rule === false) { + return valid(); + } + if (value === undefined || value === '') { + return valid(); + } + + return invalid({ message: `This field must be empty but was provided a value.` }); +}; + +/** + * This function is the common pattern for applying a fieldRestriction value test to an array. + * For the empty restriction, we wanted to perform a couple additional checks to modify how + * this works, so this is used inside testEmptyArray after those additional checks are complete. + */ +const internalTestEmptyArray = createFieldRestrictionTestForArrays( + testEmptySingleValue, + `This field must be empty but was provided a value.`, +); + +/** + * Test for required value on an array field. Before using the common pattern of applying the value test to + * each item in the array, we first check: + * - if the rule is `false` then the value is always valid + * - if the length of the array is 0 then the value is invalid + * @param rule + * @param values + * @returns + */ +const testEmptyArray = (rule: boolean, values: ArrayDataValue): TestResult => { + // Note: This doesn't apply the + if (rule === false) { + return valid(); + } + if (values.length === 0) { + return valid(); + } + return internalTestEmptyArray(rule, values); +}; + +/** + * Validate if a value is valid based on the required restriction value. + * + * When a field has the restriction `required: true`, it cannot be `undefined` or be an empty string. If + * the field is an array it cannot be an empty array (length 0). + * + * When a field has the restriction `required: false` then this test will always return as `valid: true` + * @param rule + * @param value + * @returns + */ +export const testEmpty: FieldRestrictionTestFunction = (rule, value) => + Array.isArray(value) ? testEmptyArray(rule, value) : testEmptySingleValue(rule, value); diff --git a/packages/validation/src/validateField/validateField.ts b/packages/validation/src/validateField/validateField.ts index 9ef461ea..a2b318aa 100644 --- a/packages/validation/src/validateField/validateField.ts +++ b/packages/validation/src/validateField/validateField.ts @@ -27,12 +27,20 @@ import { testRange } from './restrictions/testRange'; import { testRegex } from './restrictions/testRegex'; import { testRequired } from './restrictions/testRequired'; import type { FieldValidationError, FieldValidationErrorRestrictionInfo } from './FieldValidationError'; +import type { RestrictionTestInvalidInfo } from './FieldRestrictionTest'; +import { testEmpty } from './restrictions/testEmpty'; -const testRestriction = (value: DataRecordValue, restriction: FieldRestrictionRule) => { +const testRestriction = ( + value: DataRecordValue, + restriction: FieldRestrictionRule, +): TestResult => { switch (restriction.type) { case 'codeList': { return testCodeList(restriction.rule, value); } + case 'empty': { + return testEmpty(restriction.rule, value); + } case 'range': { return testRange(restriction.rule, value); } @@ -42,12 +50,6 @@ const testRestriction = (value: DataRecordValue, restriction: FieldRestrictionRu case 'required': { return testRequired(restriction.rule, value); } - // case 'unique': { - // return testRequired(restriction.rule, value); - // } - // case 'script': { - // return valid(); - // } } }; From 5e0f357858d9fde9fd601bf5bfa00c2b6e639aa7 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sat, 10 Aug 2024 22:48:12 -0400 Subject: [PATCH 14/21] Remove development test file --- packages/dictionary/test/recursion.spec.ts | 25 ---------------------- 1 file changed, 25 deletions(-) delete mode 100644 packages/dictionary/test/recursion.spec.ts diff --git a/packages/dictionary/test/recursion.spec.ts b/packages/dictionary/test/recursion.spec.ts deleted file mode 100644 index bcac3d28..00000000 --- a/packages/dictionary/test/recursion.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z as zod } from 'zod'; -import { expect } from 'chai'; -import { RecursiveData, NumberValue, StringValue } from '../src/zodtest'; -describe('Recursion', () => { - it('Non recursive works', () => { - const input: RecursiveData = { value: 'asdf' }; - const parsed = RecursiveData(StringValue).parse(input); - expect(input).deep.equal(parsed); - }); - it('Single nested works', () => { - const input: RecursiveData = { nested: { value: 'asdf' } }; - const parsed = RecursiveData(StringValue).parse(input); - expect(input).deep.equal(parsed); - }); - it('Double nested works', () => { - const input: RecursiveData = { nested: { nested: { value: 'asdf' } } }; - const parsed = RecursiveData(StringValue).parse(input); - expect(input).deep.equal(parsed); - }); - it('Wrong value type has error nested works', () => { - const input: RecursiveData = { nested: { nested: { value: 'asdf' } } }; - const parsed = RecursiveData(NumberValue).safeParse(input); - expect(parsed.success).false; - }); -}); From 775c37c382b9f1b4c4131b7ee9d8f8629db04b21 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 11 Aug 2024 17:43:44 -0400 Subject: [PATCH 15/21] Remove script restriction from test dictionary --- apps/server/test/integration/fixtures/updateNewFile.json | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/test/integration/fixtures/updateNewFile.json b/apps/server/test/integration/fixtures/updateNewFile.json index ba9df6c8..5aa11323 100644 --- a/apps/server/test/integration/fixtures/updateNewFile.json +++ b/apps/server/test/integration/fixtures/updateNewFile.json @@ -30,7 +30,6 @@ "units": "days" }, "restrictions": { - "script": ["validateWithMagic(check dependece on another field)"], "range": { "min": 0, "max": 99999 From 5c1b59fd30ca0fb84cbb2598ce09c6b2932a2118 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 11 Aug 2024 17:46:13 -0400 Subject: [PATCH 16/21] Restriction schemas directly written for each field type - Generic conditional restriction function was removed because it could not be interpretted by the json schema generator - Although there is repeated code, directly writing the typed conditional restriction type schemas is easier to parse and hopefully maintain. They have their types enforced by a generic type even if the schema itself is not generated through a function. --- .../src/metaSchema/dictionarySchemas.ts | 81 +++++++++--- .../src/metaSchema/restrictionsSchemas.ts | 14 -- .../metaSchema/restrictionsSchemas.spec.ts | 124 +++++++++--------- 3 files changed, 127 insertions(+), 92 deletions(-) diff --git a/packages/dictionary/src/metaSchema/dictionarySchemas.ts b/packages/dictionary/src/metaSchema/dictionarySchemas.ts index c3dd7767..63bb0738 100644 --- a/packages/dictionary/src/metaSchema/dictionarySchemas.ts +++ b/packages/dictionary/src/metaSchema/dictionarySchemas.ts @@ -17,11 +17,12 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { z as zod } from 'zod'; +import { z as zod, type ZodType } from 'zod'; import allUnique from '../utils/allUnique'; import { ReferenceTag, References } from './referenceSchemas'; import { ConditionalRestriction, + ConditionalRestrictionTest, RestrictionCodeListInteger, RestrictionCodeListNumber, RestrictionCodeListString, @@ -66,12 +67,25 @@ export type SchemaFieldValueType = zod.infer; /* ****************************** * * Field Type Restriction Objects * * ****************************** */ -export const BooleanFieldRestrictions = zod.object({ empty: zod.boolean(), required: zod.boolean() }).partial(); +export const BooleanFieldRestrictions = zod + .object({ empty: zod.boolean(), required: zod.boolean() }) + .partial() + .strict(); export type BooleanFieldRestrictions = zod.infer; -const BooleanFieldRestrictionsObject = BooleanFieldRestrictions.strict().or( - ConditionalRestriction(BooleanFieldRestrictions.strict()), -); +const BooleanFieldConditionalRestriction: ZodType> = zod + .object({ + if: ConditionalRestrictionTest, + then: BooleanFieldRestrictions.or(zod.lazy(() => BooleanFieldConditionalRestriction)) + .or(zod.array(zod.union([BooleanFieldRestrictions, zod.lazy(() => BooleanFieldConditionalRestriction)]))) + .optional(), + else: BooleanFieldRestrictions.or(zod.lazy(() => BooleanFieldConditionalRestriction)) + .or(zod.array(zod.union([BooleanFieldRestrictions, zod.lazy(() => BooleanFieldConditionalRestriction)]))) + .optional(), + }) + .strict(); + +const BooleanFieldRestrictionsObject = BooleanFieldRestrictions.or(BooleanFieldConditionalRestriction); export type BooleanFieldRestrictionsObject = zod.infer; export const IntegerFieldRestrictions = zod @@ -81,12 +95,23 @@ export const IntegerFieldRestrictions = zod required: zod.boolean(), range: RestrictionIntegerRange, }) - .partial(); + .partial() + .strict(); export type IntegerFieldRestrictions = zod.infer; -const IntegerFieldRestrictionsObject = IntegerFieldRestrictions.strict().or( - ConditionalRestriction(IntegerFieldRestrictions.strict()), -); +const IntegerFieldConditionalRestriction: ZodType> = zod + .object({ + if: ConditionalRestrictionTest, + then: IntegerFieldRestrictions.or(zod.lazy(() => IntegerFieldConditionalRestriction)) + .or(zod.array(zod.union([IntegerFieldRestrictions, zod.lazy(() => IntegerFieldConditionalRestriction)]))) + .optional(), + else: IntegerFieldRestrictions.or(zod.lazy(() => IntegerFieldConditionalRestriction)) + .or(zod.array(zod.union([IntegerFieldRestrictions, zod.lazy(() => IntegerFieldConditionalRestriction)]))) + .optional(), + }) + .strict(); + +const IntegerFieldRestrictionsObject = IntegerFieldRestrictions.or(IntegerFieldConditionalRestriction); export type IntegerFieldRestrictionsObject = zod.infer; export const NumberFieldRestrictions = zod @@ -96,12 +121,23 @@ export const NumberFieldRestrictions = zod required: zod.boolean(), range: RestrictionNumberRange, }) - .partial(); + .partial() + .strict(); export type NumberFieldRestrictions = zod.infer; -const NumberFieldRestrictionsObject = NumberFieldRestrictions.strict().or( - ConditionalRestriction(NumberFieldRestrictions.strict()), -); +const NumberFieldConditionalRestriction: ZodType> = zod + .object({ + if: ConditionalRestrictionTest, + then: NumberFieldRestrictions.or(zod.lazy(() => NumberFieldConditionalRestriction)) + .or(zod.array(zod.union([NumberFieldRestrictions, zod.lazy(() => NumberFieldConditionalRestriction)]))) + .optional(), + else: NumberFieldRestrictions.or(zod.lazy(() => NumberFieldConditionalRestriction)) + .or(zod.array(zod.union([NumberFieldRestrictions, zod.lazy(() => NumberFieldConditionalRestriction)]))) + .optional(), + }) + .strict(); + +const NumberFieldRestrictionsObject = NumberFieldRestrictions.or(NumberFieldConditionalRestriction); export type NumberFieldRestrictionsObject = zod.infer; export const StringFieldRestrictions = zod @@ -111,12 +147,23 @@ export const StringFieldRestrictions = zod required: zod.boolean(), regex: RestrictionRegex.or(ReferenceTag), }) - .partial(); + .partial() + .strict(); export type StringFieldRestrictions = zod.infer; -const StringFieldRestrictionsObject = StringFieldRestrictions.strict().or( - ConditionalRestriction(StringFieldRestrictions.strict()), -); +const StringFieldConditionalRestriction: ZodType> = zod + .object({ + if: ConditionalRestrictionTest, + then: StringFieldRestrictions.or(zod.lazy(() => StringFieldConditionalRestriction)) + .or(zod.array(zod.union([StringFieldRestrictions, zod.lazy(() => StringFieldConditionalRestriction)]))) + .optional(), + else: StringFieldRestrictions.or(zod.lazy(() => StringFieldConditionalRestriction)) + .or(zod.array(zod.union([StringFieldRestrictions, zod.lazy(() => StringFieldConditionalRestriction)]))) + .optional(), + }) + .strict(); + +const StringFieldRestrictionsObject = StringFieldRestrictions.or(StringFieldConditionalRestriction); export type StringFieldRestrictionsObject = zod.infer; export const AnyFieldRestrictions = zod.union([ diff --git a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts index d6210a36..1497cc40 100644 --- a/packages/dictionary/src/metaSchema/restrictionsSchemas.ts +++ b/packages/dictionary/src/metaSchema/restrictionsSchemas.ts @@ -190,7 +190,6 @@ export const RestrictionCondition = zod.object({ fields: zod.string().array(), match: ConditionMatch, case: ArrayTestCase.optional(), - arrayFieldCase: ArrayTestCase.optional(), }); export type RestrictionCondition = zod.infer; @@ -211,16 +210,3 @@ export type ConditionalRestriction = { | ConditionalRestriction | (TRestrictionObject | ConditionalRestriction)[]; }; -export const ConditionalRestriction = ( - restrictionsSchema: TRestrictionObjectSchema, -): ZodSchema>> => { - const restrictionOrConditional = zod.union([ - restrictionsSchema, - zod.lazy(() => ConditionalRestriction(restrictionsSchema)), - ]); - return zod.object({ - if: ConditionalRestrictionTest, - then: restrictionOrConditional.or(restrictionOrConditional.array()).optional(), - else: restrictionOrConditional.or(restrictionOrConditional.array()).optional(), - }); -}; diff --git a/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts b/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts index 2551fb0a..42bdcc25 100644 --- a/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts +++ b/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts @@ -17,21 +17,21 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import assert from 'assert'; import { expect } from 'chai'; import { - BooleanFieldRestrictions, Integer, - IntegerFieldRestrictions, - NumberFieldRestrictions, RestrictionIntegerRange, RestrictionNumberRange, + SchemaBooleanField, SchemaField, + SchemaIntegerField, + SchemaNumberField, + SchemaStringField, StringFieldRestrictions, type ConditionalRestriction, - type SchemaStringField, type StringFieldRestrictionsObject, } from '../../src'; -import assert from 'assert'; describe('Restriction Schemas', () => { describe('Integer', () => { @@ -90,19 +90,11 @@ describe('Restriction Schemas', () => { }); }); describe('UniqueRestriction', () => { - it('All fields accept unique restriction', () => { - expect(StringFieldRestrictions.safeParse({ unique: true }).success).true; - expect(NumberFieldRestrictions.safeParse({ unique: true }).success).true; - expect(IntegerFieldRestrictions.safeParse({ unique: true }).success).true; - expect(BooleanFieldRestrictions.safeParse({ unique: true }).success).true; - }); - }); - describe('ScriptRestriction', () => { - it('All fields accept script restriction', () => { - expect(StringFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; - expect(NumberFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; - expect(IntegerFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; - expect(BooleanFieldRestrictions.safeParse({ script: ['()=>true'] }).success).true; + it('All fields accept unique property', () => { + expect(SchemaBooleanField.safeParse({ name: 'name', valueType: 'boolean', unique: true }).success).true; + expect(SchemaIntegerField.safeParse({ name: 'name', valueType: 'integer', unique: true }).success).true; + expect(SchemaNumberField.safeParse({ name: 'name', valueType: 'number', unique: true }).success).true; + expect(SchemaStringField.safeParse({ name: 'name', valueType: 'string', unique: true }).success).true; }); }); @@ -133,56 +125,66 @@ describe('Restriction Schemas', () => { expect(parseResult.data).deep.equal(field); }); - }); - it('Parses conditional restrictions in an array', () => { - const restrictions: Array = [ - { codeList: ['value1', 'value2'] }, - { - if: { - conditions: [{ fields: ['another-field'], match: { value: 'asdf' } }], + + it('Parses conditional restrictions in an array', () => { + const restrictions: Array = [ + { codeList: ['value1', 'value2'] }, + { + if: { + conditions: [{ fields: ['another-field'], match: { value: 'asdf' } }], + }, + then: { required: true }, + else: { empty: true }, }, - then: { required: true }, - else: { empty: true }, - }, - ]; - const field: SchemaStringField = { - name: 'example-string', - valueType: 'string', - restrictions, - }; - const parseResult = SchemaField.safeParse(field); - expect(parseResult.success).true; - assert(parseResult.success === true); + ]; + const field: SchemaStringField = { + name: 'example-string', + valueType: 'string', + restrictions, + }; + const parseResult = SchemaField.safeParse(field); + expect(parseResult.success).true; + assert(parseResult.success === true); - expect(parseResult.data).deep.equal(field); - }); + expect(parseResult.data).deep.equal(field); + }); - it('Parses nested conditional restrictions', () => { - const restrictions: Array = [ - { codeList: ['value1', 'value2'] }, - { - if: { - conditions: [{ fields: ['first-dependent-field'], match: { value: 'asdf' } }], - }, - then: { + it('Parses nested conditional restrictions', () => { + const restrictions: Array = [ + { codeList: ['value1', 'value2'] }, + { if: { - conditions: [{ fields: ['second-dependent-field'], match: { range: { max: 10, min: 0 } } }], + conditions: [{ fields: ['first-dependent-field'], match: { value: 'asdf' } }], }, - then: { required: true }, + then: [ + { + if: { + conditions: [{ fields: ['second-dependent-field'], match: { range: { max: 10, min: 0 } } }], + }, + then: { required: true }, + else: { empty: true }, + }, + { + if: { + conditions: [{ fields: ['third-dependent-field'], match: { range: { max: 10, min: 0 } } }], + }, + then: { regex: 'asdf' }, + else: { codeList: ['a', 's', 'd', 'f'] }, + }, + ], else: { empty: true }, }, - else: { empty: true }, - }, - ]; - const field: SchemaStringField = { - name: 'example-string', - valueType: 'string', - restrictions, - }; - const parseResult = SchemaField.safeParse(field); - expect(parseResult.success).true; - assert(parseResult.success === true); + ]; + const field: SchemaStringField = { + name: 'example-string', + valueType: 'string', + restrictions, + }; + const parseResult = SchemaField.safeParse(field); + expect(parseResult.success).true; + assert(parseResult.success === true); - expect(parseResult.data).deep.equal(field); + expect(parseResult.data).deep.equal(field); + }); }); }); From 8382b7c97c4ad4e09cad1b7699e2831af0d80b56 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 11 Aug 2024 17:49:57 -0400 Subject: [PATCH 17/21] Adds tests for conditional restriction match rules --- .../src/validateField/conditions/index.ts | 5 + .../conditions/testConditionalRestriction.ts | 56 ++- .../conditions/testMatchCount.ts | 2 +- .../fieldStringConditionalExists.ts | 29 ++ ...ieldStringConditionalMultipleConditions.ts | 48 +++ ...eldStringConditionalMultipleFieldsRegex.ts | 34 ++ .../fieldStringNestedConditional.ts | 64 +++ .../fieldStringRequiredConditionalRange.ts | 35 ++ .../conditions/testMatchCodeList.spec.ts | 45 +++ .../conditions/testMatchCount.spec.ts | 65 +++ .../conditions/testMatchExists.spec.ts | 38 +- .../conditions/testMatchRange.spec.ts | 53 +++ .../conditions/testMatchRegex.spec.ts | 54 +++ .../conditions/testMatchValue.spec.ts | 86 ++++ .../resolveFieldRestrictions.spec.ts | 373 ++++++++++++++++++ 15 files changed, 953 insertions(+), 34 deletions(-) create mode 100644 packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalExists.ts create mode 100644 packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleConditions.ts create mode 100644 packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleFieldsRegex.ts create mode 100644 packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringNestedConditional.ts create mode 100644 packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringRequiredConditionalRange.ts create mode 100644 packages/validation/test/validateField/conditions/testMatchCodeList.spec.ts create mode 100644 packages/validation/test/validateField/conditions/testMatchCount.spec.ts create mode 100644 packages/validation/test/validateField/conditions/testMatchRange.spec.ts create mode 100644 packages/validation/test/validateField/conditions/testMatchRegex.spec.ts create mode 100644 packages/validation/test/validateField/conditions/testMatchValue.spec.ts diff --git a/packages/validation/src/validateField/conditions/index.ts b/packages/validation/src/validateField/conditions/index.ts index 164fff8f..c49a91d4 100644 --- a/packages/validation/src/validateField/conditions/index.ts +++ b/packages/validation/src/validateField/conditions/index.ts @@ -18,3 +18,8 @@ */ export * from './testMatchCodeList'; +export * from './testMatchCount'; +export * from './testMatchExists'; +export * from './testMatchRange'; +export * from './testMatchRegex'; +export * from './testMatchValue'; diff --git a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts index ecb481b4..097215de 100644 --- a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts +++ b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts @@ -19,6 +19,7 @@ import { ARRAY_TEST_CASE_DEFAULT, + ArrayTestCase, type ArrayDataValue, type ConditionalRestrictionTest, type DataRecord, @@ -34,45 +35,66 @@ import { testMatchRange } from './testMatchRange'; import { testMatchRegex } from './testMatchRegex'; import { testMatchValue } from './testMatchValue'; -const allValuesPassMatchTest = ( +/** + * Test values extracted from other fields vs a match test. This function should be passed the + * + * @param values + * @param rule + * @param matchTest + * @param arrayCase Intentionally using `| undefined` vs `/?: arrayCase` to ensure that this argument is provided when this function is called + * @returns + */ +const fieldsPassMatchTest = ( values: DataRecordValue[], - rule: TMatchRule, - matchTest: (rule: TMatchRule, value: DataRecordValue) => boolean, -): boolean => values.every((value) => matchTest(rule, value)); + matchTest: (value: DataRecordValue) => boolean, + arrayCase: ArrayTestCase | undefined, +): boolean => { + const fieldTestResults = values.map((value) => matchTest(value)); + return resultForArrayTestCase(fieldTestResults, arrayCase || ARRAY_TEST_CASE_DEFAULT); +}; const testConditionForSingularValue = ( condition: RestrictionCondition, _value: SingleDataValue, fieldValues: DataRecordValue[], ): boolean => { - if (condition.match.codeList) { - if (!allValuesPassMatchTest(fieldValues, condition.match.codeList, testMatchCodeList)) { + const matchCodeList = condition.match.codeList; + if (matchCodeList !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchCodeList(matchCodeList, value), condition.case)) { return false; } } - // count rule can have value of 0 so we need to directly check for undefined - if (condition.match.count !== undefined) { - if (!allValuesPassMatchTest(fieldValues, condition.match.count, testMatchCount)) { + const matchCount = condition.match.count; + if (matchCount !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchCount(matchCount, value), condition.case)) { return false; } } - if (condition.match.exists) { - if (!allValuesPassMatchTest(fieldValues, condition.match.exists, testMatchExists)) { + + const matchExists = condition.match.exists; + if (matchExists !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchExists(matchExists, value), condition.case)) { return false; } } - if (condition.match.range) { - if (!allValuesPassMatchTest(fieldValues, condition.match.range, testMatchRange)) { + + const matchRange = condition.match.range; + if (matchRange !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchRange(matchRange, value), condition.case)) { return false; } } - if (condition.match.regex) { - if (!allValuesPassMatchTest(fieldValues, condition.match.regex, testMatchRegex)) { + + const matchRegex = condition.match.regex; + if (matchRegex !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchRegex(matchRegex, value), condition.case)) { return false; } } - if (condition.match.value) { - if (!allValuesPassMatchTest(fieldValues, condition.match.value, testMatchValue)) { + + const matchValue = condition.match.value; + if (matchValue !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchValue(matchValue, value), condition.case)) { return false; } } diff --git a/packages/validation/src/validateField/conditions/testMatchCount.ts b/packages/validation/src/validateField/conditions/testMatchCount.ts index 89459f55..e6fe8e92 100644 --- a/packages/validation/src/validateField/conditions/testMatchCount.ts +++ b/packages/validation/src/validateField/conditions/testMatchCount.ts @@ -44,5 +44,5 @@ export const testMatchCount = (count: MatchRuleCount, value: DataRecordValue): b } // whats left - return value.length >= count; + return value.length === count; }; diff --git a/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalExists.ts b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalExists.ts new file mode 100644 index 00000000..42cc5e9c --- /dev/null +++ b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalExists.ts @@ -0,0 +1,29 @@ +import { SchemaField, type SchemaStringField } from '@overture-stack/lectern-dictionary'; +import { fieldStringNoRestriction } from '../noRestrictions/fieldStringNoRestriction'; +import { validateFixture } from '../../../testUtils/validateFixture'; + +export const fieldStringConditionalExists = { + name: 'conditional-field', + valueType: 'string', + description: 'Required if `fieldStringNoRestriction` field exists, otherwise must be empty', + restrictions: { + if: { + conditions: [ + { + fields: [fieldStringNoRestriction.name], + match: { + exists: true, + }, + }, + ], + }, + then: { + required: true, + }, + else: { + empty: true, + }, + }, +} as const satisfies SchemaStringField; + +validateFixture(fieldStringConditionalExists, SchemaField, 'fieldStringConditionalExists is not a valid SchemaField'); diff --git a/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleConditions.ts b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleConditions.ts new file mode 100644 index 00000000..0bc2d17e --- /dev/null +++ b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleConditions.ts @@ -0,0 +1,48 @@ +import { SchemaField, type SchemaStringField } from '@overture-stack/lectern-dictionary'; +import { fieldStringNoRestriction } from '../noRestrictions/fieldStringNoRestriction'; +import { validateFixture } from '../../../testUtils/validateFixture'; +import { fieldNumberNoRestriction } from '../noRestrictions/fieldNumberNoRestriction'; +import { fieldBooleanNoRestriction } from '../noRestrictions/fieldBooleanNoRestriction'; + +export const fieldStringConditionalMultipleConditions: SchemaField = { + name: 'conditional-field', + valueType: 'string', + description: + 'Conditionally required or empty, based on the existence of `any-string`, `any-number`, and `any-boolean` fields.', + restrictions: { + if: { + conditions: [ + { + fields: [fieldStringNoRestriction.name], + match: { + exists: true, + }, + }, + { + fields: [fieldNumberNoRestriction.name], + match: { + exists: true, + }, + }, + { + fields: [fieldBooleanNoRestriction.name], + match: { + exists: true, + }, + }, + ], + }, + then: { + required: true, + }, + else: { + empty: true, + }, + }, +}; + +validateFixture( + fieldStringConditionalMultipleConditions, + SchemaField, + 'fieldStringConditionalMultipleConditions is not a valid SchemaField', +); diff --git a/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleFieldsRegex.ts b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleFieldsRegex.ts new file mode 100644 index 00000000..11376be2 --- /dev/null +++ b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleFieldsRegex.ts @@ -0,0 +1,34 @@ +import { SchemaField, type SchemaStringField } from '@overture-stack/lectern-dictionary'; +import { validateFixture } from '../../../testUtils/validateFixture'; +import { regexAlphaOnly } from '../../restrictions/regexFixtures'; + +export const fieldStringConditionalMultipleFieldsRegex: SchemaField = { + name: 'conditional-field', + valueType: 'string', + description: + 'Checks fields named `first`, `second`, and `third` are all alpha only, required if so, empty otherwise.', + restrictions: { + if: { + conditions: [ + { + fields: ['first', 'second', 'third'], + match: { + regex: regexAlphaOnly, + }, + }, + ], + }, + then: { + required: true, + }, + else: { + empty: true, + }, + }, +} satisfies SchemaStringField; + +validateFixture( + fieldStringConditionalMultipleFieldsRegex, + SchemaField, + 'fieldStringConditionalMultipleFieldsRegex is not a valid SchemaField', +); diff --git a/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringNestedConditional.ts b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringNestedConditional.ts new file mode 100644 index 00000000..fb004733 --- /dev/null +++ b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringNestedConditional.ts @@ -0,0 +1,64 @@ +import { SchemaField, type SchemaStringField } from '@overture-stack/lectern-dictionary'; +import { fieldStringNoRestriction } from '../noRestrictions/fieldStringNoRestriction'; +import { validateFixture } from '../../../testUtils/validateFixture'; +import { regexAlphaOnly, regexRepeatedText } from '../../restrictions/regexFixtures'; +import { fieldNumberNoRestriction } from '../noRestrictions/fieldNumberNoRestriction'; +import { fieldBooleanNoRestriction } from '../noRestrictions/fieldBooleanNoRestriction'; + +export const fieldStringNestedConditional = { + name: 'conditional-field', + valueType: 'string', + description: + 'Nested conditional restrictions. If `any-string` has repeated text, then two different conditions are tested, otherwise this should be empty. Nested condition 1: `any-number` has a value of 0 or greater, then we have the regex restriction alpha-only. Nested condition 2: `any-boolean` has the value `true`, then we have the `required` restriction.', + restrictions: [ + { + if: { + conditions: [ + { + fields: [fieldStringNoRestriction.name], + match: { + regex: regexRepeatedText, + }, + }, + ], + }, + then: [ + { + if: { + conditions: [ + { + fields: [fieldNumberNoRestriction.name], + match: { + range: { min: 0 }, + }, + }, + ], + }, + then: { + regex: regexAlphaOnly, + }, + }, + { + if: { + conditions: [ + { + fields: [fieldBooleanNoRestriction.name], + match: { + value: true, + }, + }, + ], + }, + then: { + required: true, + }, + }, + ], + else: { + empty: true, + }, + }, + ], +} as const satisfies SchemaStringField; + +validateFixture(fieldStringNestedConditional, SchemaField, 'fieldStringNestedConditional is not a valid SchemaField'); diff --git a/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringRequiredConditionalRange.ts b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringRequiredConditionalRange.ts new file mode 100644 index 00000000..78fda03d --- /dev/null +++ b/packages/validation/test/fixtures/fields/conditionalRestrictions/fieldStringRequiredConditionalRange.ts @@ -0,0 +1,35 @@ +import { SchemaField, type SchemaStringField } from '@overture-stack/lectern-dictionary'; +import { validateFixture } from '../../../testUtils/validateFixture'; +import { fieldNumberNoRestriction } from '../noRestrictions/fieldNumberNoRestriction'; + +export const fieldStringRequiredConditionalRange = { + name: 'required-and-conditional-field', + valueType: 'string', + description: 'Required string, value must match a code list if `any-number` field has value 10 or greater.', + restrictions: [ + { + required: true, + }, + { + if: { + conditions: [ + { + fields: [fieldNumberNoRestriction.name], + match: { + range: { min: 10 }, + }, + }, + ], + }, + then: { + codeList: ['big', 'large', 'huge'], + }, + }, + ], +} as const satisfies SchemaStringField; + +validateFixture( + fieldStringRequiredConditionalRange, + SchemaField, + 'fieldStringRequiredConditionalRange is not a valid SchemaField', +); diff --git a/packages/validation/test/validateField/conditions/testMatchCodeList.spec.ts b/packages/validation/test/validateField/conditions/testMatchCodeList.spec.ts new file mode 100644 index 00000000..07adf5ee --- /dev/null +++ b/packages/validation/test/validateField/conditions/testMatchCodeList.spec.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { testMatchCodeList } from '../../../src/validateField/conditions'; + +describe('ConditionalRestriction - testMatchCodeList', () => { + it('Tests true when the primitive value matches an item in the list', () => { + expect(testMatchCodeList(['hello', 'world'], 'hello')).true; + expect(testMatchCodeList(['hello', 'world'], 'world')).true; + expect(testMatchCodeList([123, 456, 789, 1011], 123)).true; + expect(testMatchCodeList([123, 456, 789, 1011], 456)).true; + expect(testMatchCodeList([123, 456, 789, 1011], 789)).true; + expect(testMatchCodeList([123, 456, 789, 1011], 1011)).true; + }); + it('Tests false when the primitive value is not in the list', () => { + expect(testMatchCodeList(['hello', 'world'], 'goodbye')).false; + expect(testMatchCodeList([123, 456, 789, 1011], -123)).false; + }); + it('Tests true when an array has at least one item form the code list', () => { + expect(testMatchCodeList(['hello', 'world'], ['hello', 'everyone'])).true; + expect(testMatchCodeList(['hello', 'world'], ['hello', 'world'])).true; + expect(testMatchCodeList([123, 456, 789, 1011], [1, 12, 123])).true; + }); + it('Tests false when an array has no items form the code list', () => { + expect(testMatchCodeList(['hello', 'world'], ['good', 'bye'])).false; + expect(testMatchCodeList([123, 456, 789, 1011], [-1, -12, -123])).false; + }); +}); diff --git a/packages/validation/test/validateField/conditions/testMatchCount.spec.ts b/packages/validation/test/validateField/conditions/testMatchCount.spec.ts new file mode 100644 index 00000000..2d39f383 --- /dev/null +++ b/packages/validation/test/validateField/conditions/testMatchCount.spec.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { testMatchCount } from '../../../src/validateField/conditions'; + +describe('ConditionalRestriction - testMatchCount', () => { + describe('Exact value rule', () => { + it('Tests true when the value is the specified length', () => { + expect(testMatchCount(3, [1, 2, 3])).true; + expect(testMatchCount(1, ['test'])).true; + expect(testMatchCount(0, [])).true; + }); + it('Tests false when the value is not the specified length', () => { + expect(testMatchCount(4, [1, 2, 3])).false; + expect(testMatchCount(2, [1, 2, 3])).false; + expect(testMatchCount(2, ['test'])).false; + expect(testMatchCount(0, ['test'])).false; + }); + it('Tests false for non array values', () => { + expect(testMatchCount(4, 'test')).false; + expect(testMatchCount(0, 'test')).false; + expect(testMatchCount(0, '')).false; + expect(testMatchCount(0, undefined)).false; + expect(testMatchCount(0, 0)).false; + expect(testMatchCount(1, 1)).false; + }); + }); + describe('Range rule', () => { + it('Tests true when the value has a length within the range', () => { + expect(testMatchCount({ min: 2, max: 5 }, [1, 2, 3])).true; + expect(testMatchCount({ min: 1 }, [1])).true; + expect(testMatchCount({ exclusiveMax: 10 }, [1, 2, 3, 4, 5, 6, 7, 8, 9])).true; + }); + it('Tests false when the value has a length outside the range', () => { + expect(testMatchCount({ min: 4, max: 5 }, [1, 2, 3])).false; + expect(testMatchCount({ exclusiveMax: 9 }, [1, 2, 3, 4, 5, 6, 7, 8, 9])).false; + expect(testMatchCount({ exclusiveMin: 1 }, [1])).false; + }); + it('Tests false for non array values', () => { + expect(testMatchCount({ min: 1 }, 'test')).false; + expect(testMatchCount({ min: 1 }, 'test')).false; + expect(testMatchCount({ min: 1 }, '')).false; + expect(testMatchCount({ min: 1 }, undefined)).false; + expect(testMatchCount({ min: 1 }, 0)).false; + expect(testMatchCount({ min: 1 }, 1)).false; + }); + }); +}); diff --git a/packages/validation/test/validateField/conditions/testMatchExists.spec.ts b/packages/validation/test/validateField/conditions/testMatchExists.spec.ts index 148c90e6..94d3d9bf 100644 --- a/packages/validation/test/validateField/conditions/testMatchExists.spec.ts +++ b/packages/validation/test/validateField/conditions/testMatchExists.spec.ts @@ -18,50 +18,56 @@ */ import { expect } from 'chai'; -import { testMatchExists } from '../../../src/validateField/conditions/testMatchExists'; +import { testMatchExists } from '../../../src/validateField/conditions'; describe('ConditionalRestriction - testMatchExists', () => { - it('Primitive values all found to exist', () => { + it('Tests true with all primitive values', () => { expect(testMatchExists(true, 'hello')).true; expect(testMatchExists(true, 123)).true; + expect(testMatchExists(true, 0)).true; expect(testMatchExists(true, true)).true; }); - it('Array values with some elements all found to exist', () => { + it('Tests true with array values with some elements', () => { expect(testMatchExists(true, ['hello'])).true; expect(testMatchExists(true, ['hello', 'world', 'how are you?'])).true; expect(testMatchExists(true, [123, 456, 789])).true; expect(testMatchExists(true, [true, false, true, false])).true; }); - it('`false` values treated as existing', () => { + it('Tests true for value `false`', () => { expect(testMatchExists(true, false)).true; }); - it('`undefined` values treated as not existing', () => { + it('Tests false for `undefined`', () => { expect(testMatchExists(true, undefined)).false; }); - it('Empty string values treated as not existing', () => { + it('Tests false for empty string values', () => { expect(testMatchExists(true, '')).false; }); - it('All whitespacce string values treated as not existing', () => { + it('Tests false for string values with only whitespace', () => { expect(testMatchExists(true, ' ')).false; }); - it('Non-finite numbers (NaN, Infinity) values are treated as not existing.', () => { + it('Tests false for non-finite numbers (NaN, Infinity)', () => { expect(testMatchExists(true, NaN)).false; expect(testMatchExists(true, Infinity)).false; expect(testMatchExists(true, -Infinity)).false; }); - it('Empty array value treated as not existing', () => { + it('Tests false for empty array value', () => { expect(testMatchExists(true, [])).false; }); - it('Array with only non existing elements treated as not existing', () => { + it('Tests false for arrays with only non existing elements', () => { expect(testMatchExists(true, [''])).false; expect(testMatchExists(true, ['', ' '])).false; expect(testMatchExists(true, [NaN, Infinity])).false; }); - - it('Inverse rule - Exist rule `false` resolves `true` when value does not exist', () => { - expect(testMatchExists(false, undefined)).true; - }); - it('Inverse rule - Exist rule `false` resolves `false` when value exists', () => { - expect(testMatchExists(false, 'hello')).false; + describe('Inverse rule - exists = false', () => { + it('Tests true when value is missing and exists=false', () => { + expect(testMatchExists(false, undefined)).true; + expect(testMatchExists(false, '')).true; + expect(testMatchExists(false, [])).true; + }); + it('Exist rule `false` resolves `false` when value is provided and exists=false', () => { + expect(testMatchExists(false, 'hello')).false; + expect(testMatchExists(false, true)).false; + expect(testMatchExists(false, 123)).false; + }); }); }); diff --git a/packages/validation/test/validateField/conditions/testMatchRange.spec.ts b/packages/validation/test/validateField/conditions/testMatchRange.spec.ts new file mode 100644 index 00000000..6e299a77 --- /dev/null +++ b/packages/validation/test/validateField/conditions/testMatchRange.spec.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { testMatchRange } from '../../../src/validateField/conditions'; + +describe('ConditionalRestriction - testMatchRange', () => { + it('Tests true for number values within range', () => { + expect(testMatchRange({ min: 0, max: 10 }, 5)).true; + expect(testMatchRange({ min: 1000, max: 2000 }, 1005)).true; + expect(testMatchRange({ exclusiveMin: 0 }, 1)).true; + expect(testMatchRange({ exclusiveMax: 0 }, -1)).true; + }); + it('Tests false for number values outside of range', () => { + expect(testMatchRange({ min: 0, max: 10 }, 15)).false; + expect(testMatchRange({ min: 1000, max: 2000 }, 2005)).false; + expect(testMatchRange({ exclusiveMin: 0 }, 0)).false; + expect(testMatchRange({ exclusiveMax: 0 }, 0)).false; + }); + it('Tests false for non-number primitive values', () => { + expect(testMatchRange({ min: 0, max: 10 }, 'hello')).false; + expect(testMatchRange({ min: 0, max: 10 }, '5')).false; + expect(testMatchRange({ min: 0, max: 10 }, true)).false; + expect(testMatchRange({ min: 0, max: 10 }, false)).false; + expect(testMatchRange({ min: 0, max: 10 }, undefined)).false; + }); + it('Tests true for array with at least one number value within range', () => { + expect(testMatchRange({ min: 0, max: 10 }, [5])).true; + expect(testMatchRange({ min: 0, max: 10 }, [5, 6, 7, 8, 9])).true; + expect(testMatchRange({ min: 0, max: 10 }, [5, 15, 25, 35])).true; + }); + it('Tests false for array with no value within range', () => { + expect(testMatchRange({ min: 0, max: 10 }, [])).false; + expect(testMatchRange({ min: 0, max: 10 }, [15, 25, 35])).false; + expect(testMatchRange({ min: 0, max: 10 }, ['5', 'hello'])).false; + }); +}); diff --git a/packages/validation/test/validateField/conditions/testMatchRegex.spec.ts b/packages/validation/test/validateField/conditions/testMatchRegex.spec.ts new file mode 100644 index 00000000..a4768712 --- /dev/null +++ b/packages/validation/test/validateField/conditions/testMatchRegex.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { testMatchRegex } from '../../../src/validateField/conditions'; +import { regexAlphaOnly, regexRepeatedText } from '../../fixtures/restrictions/regexFixtures'; + +describe('ConditionalRestriction - testMatchRegex', () => { + it('Tests true with string value that matches regex', () => { + expect(testMatchRegex(regexAlphaOnly, 'qwerty')).true; + expect(testMatchRegex(regexRepeatedText, '123 asdf 123')).true; + }); + it('Tests false with string value that does not matche regex', () => { + expect(testMatchRegex(regexAlphaOnly, 'letters and spaces and numbers 123')).false; + expect(testMatchRegex(regexRepeatedText, '123 asdf 456')).false; + }); + it('Tests true with empty string if that passes the regex rule', () => { + expect(testMatchRegex(regexAlphaOnly, '')).true; + }); + it('Tests false with non-string primitive values', () => { + expect(testMatchRegex(regexRepeatedText, 123123)).false; + expect(testMatchRegex(regexRepeatedText, true)).false; + expect(testMatchRegex(regexRepeatedText, undefined)).false; + }); + + it('Tests true when value is an array with at least one matching element', () => { + expect(testMatchRegex(regexAlphaOnly, ['asdf'])).true; + expect(testMatchRegex(regexAlphaOnly, ['asdf', '123', 'qwerty12345'])).true; + expect(testMatchRegex(regexAlphaOnly, ['qwerty12345', '1234', 'ok', 'not ok!'])).true; + }); + + it('Tests false when value is an array with no matching elements', () => { + expect(testMatchRegex(regexAlphaOnly, [])).false; + expect(testMatchRegex(regexAlphaOnly, ['123', 'qwerty12345'])).false; + expect(testMatchRegex(regexAlphaOnly, ['qwerty12345', '1234', 'not ok!'])).false; + expect(testMatchRegex(regexAlphaOnly, [123, 456, 789])).false; + }); +}); diff --git a/packages/validation/test/validateField/conditions/testMatchValue.spec.ts b/packages/validation/test/validateField/conditions/testMatchValue.spec.ts new file mode 100644 index 00000000..b8471ddc --- /dev/null +++ b/packages/validation/test/validateField/conditions/testMatchValue.spec.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { expect } from 'chai'; +import { testMatchValue } from '../../../src/validateField/conditions'; + +describe('ConditionalRestriction - testMatchValue', () => { + it('Primitive values that match test true', () => { + expect(testMatchValue('hello', 'hello')).true; + expect(testMatchValue('hello world', 'hello world')).true; + expect(testMatchValue(123, 123)).true; + expect(testMatchValue(0, 0)).true; + expect(testMatchValue(true, true)).true; + expect(testMatchValue(false, false)).true; + }); + it('Primitive values that do not match test false', () => { + expect(testMatchValue('hello', 'goodbye')).false; + expect(testMatchValue('hello world', 'nevermore')).false; + expect(testMatchValue(123, 1234)).false; + expect(testMatchValue(0, 10)).false; + expect(testMatchValue(true, false)).false; + expect(testMatchValue(false, true)).false; + }); + describe('Array values', () => { + it('Array values never match a primitive match rule', () => { + expect(testMatchValue('hello', ['hello'])).false; + expect(testMatchValue('hello world', ['hello world'])).false; + expect(testMatchValue(123, [123])).false; + expect(testMatchValue(0, [0])).false; + expect(testMatchValue(true, [true])).false; + expect(testMatchValue(false, [false])).false; + }); + it('Primitive values never match an array match rule', () => { + expect(testMatchValue(['hello'], 'hello')).false; + expect(testMatchValue(['hello world'], 'hello world')).false; + expect(testMatchValue([123], 123)).false; + expect(testMatchValue([0], 0)).false; + expect(testMatchValue([true], true)).false; + expect(testMatchValue([false], false)).false; + }); + it('Exact arrays teset true', () => { + expect(testMatchValue(['hello', 'world'], ['hello', 'world'])).true; + expect(testMatchValue(['hello world'], ['hello world'])).true; + expect(testMatchValue([123, 456, 789], [123, 456, 789])).true; + expect(testMatchValue([0], [0])).true; + expect(testMatchValue([true, true, true, false], [true, true, true, false])).true; + expect(testMatchValue([false], [false])).true; + }); + it('Arrays with same values in different order test true', () => { + expect(testMatchValue(['hello', 'world'], ['world', 'hello'])).true; + expect(testMatchValue([123, 123, -456, 789], [-456, 123, 789, 123])).true; + expect(testMatchValue([true, true, true, false, false], [false, true, false, true, true])).true; + }); + it('Arrays with extra values test false', () => { + expect(testMatchValue(['hello', 'world'], ['world', 'hello', 'extra'])).false; + expect(testMatchValue([123, 123, -456, 789], [-456, 123, 789, 123, 0])).false; + expect(testMatchValue([true, true, true, false, false], [false, true, false, true, true, true])).false; + }); + it('Arrays with missing values test false', () => { + expect(testMatchValue(['hello', 'world'], ['world'])).false; + expect(testMatchValue([123, 123, -456, 789], [-456, 123, 789])).false; + expect(testMatchValue([true, true, true, false, false], [false, true, false, true])).false; + }); + it('Arrays different missing values test false', () => { + expect(testMatchValue(['hello', 'world'], ['world', 'helo'])).false; + expect(testMatchValue([123, 123, -456, 789], [-456, 124, 123, 789])).false; + expect(testMatchValue([true, true, true, false, false], [false, true, false, true, false])).false; + }); + }); +}); diff --git a/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts b/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts index d7bbcd2c..1f460dc9 100644 --- a/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts +++ b/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts @@ -17,11 +17,22 @@ * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import { ARRAY_TEST_CASE_DEFAULT, type DataRecord } from '@overture-stack/lectern-dictionary'; +import assert from 'assert'; import { expect } from 'chai'; +import { cloneDeep } from 'lodash'; import { resolveFieldRestrictions } from '../../src/validateField/restrictions/resolveFieldRestrictions'; +import { fieldStringConditionalExists } from '../fixtures/fields/conditionalRestrictions/fieldStringConditionalExists'; +import { fieldStringConditionalMultipleConditions } from '../fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleConditions'; +import { fieldStringConditionalMultipleFieldsRegex } from '../fixtures/fields/conditionalRestrictions/fieldStringConditionalMultipleFieldsRegex'; +import { fieldStringNestedConditional } from '../fixtures/fields/conditionalRestrictions/fieldStringNestedConditional'; +import { fieldStringRequiredConditionalRange } from '../fixtures/fields/conditionalRestrictions/fieldStringRequiredConditionalRange'; import { fieldStringArrayMultipleRegex } from '../fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex'; import { fieldStringManyRestrictions } from '../fixtures/fields/multipleRestrictions/fieldStringManyRestrictions'; +import { fieldBooleanNoRestriction } from '../fixtures/fields/noRestrictions/fieldBooleanNoRestriction'; +import { fieldNumberNoRestriction } from '../fixtures/fields/noRestrictions/fieldNumberNoRestriction'; import { fieldStringNoRestriction } from '../fixtures/fields/noRestrictions/fieldStringNoRestriction'; +import { regexAlphaOnly } from '../fixtures/restrictions/regexFixtures'; describe('Field - resolveFieldRestrictions', () => { it('Returns empty array when there are no restrictions', () => { @@ -43,4 +54,366 @@ describe('Field - resolveFieldRestrictions', () => { expect(restrictions.length).equal(2); expect(restrictions.every((restriction) => restriction.type === 'regex')).true; }); + describe('Conditional Restrictions', () => { + it('Returns `then` restrictions when condition is true', () => { + const record: DataRecord = { + [fieldStringNoRestriction.name]: 'has value', + [fieldStringConditionalExists.name]: 'anything', + }; + const restrictions = resolveFieldRestrictions( + record[fieldStringConditionalExists.name], + record, + fieldStringConditionalExists, + ); + expect(restrictions.length).equal(1); + expect(restrictions[0]?.type).equal('required'); + expect(restrictions[0]?.rule).equal(true); + }); + it('Returns `else` restrictions when condition is false', () => { + const record: DataRecord = { + [fieldStringNoRestriction.name]: undefined, + [fieldStringConditionalExists.name]: 'anything', + }; + const restrictions = resolveFieldRestrictions( + record[fieldStringConditionalExists.name], + record, + fieldStringConditionalExists, + ); + expect(restrictions.length).equal(1); + expect(restrictions[0]?.type).equal('empty'); + expect(restrictions[0]?.rule).equal(true); + }); + it('Combines conditional restrictions with other restrictions', () => { + const record: DataRecord = { + [fieldNumberNoRestriction.name]: 15, + [fieldStringRequiredConditionalRange.name]: 'big', + }; + const restrictions = resolveFieldRestrictions( + record[fieldStringRequiredConditionalRange.name], + record, + fieldStringRequiredConditionalRange, + ); + expect(restrictions.length).equal(2); + + // one of the restrictions is 'required' + const requiredRestriction = restrictions.find((restriction) => restriction.type === 'required'); + expect(requiredRestriction).not.undefined; + + // one of the restrictions is 'codeList' + const codeListRestriction = restrictions.find((restriction) => restriction.type === 'codeList'); + expect(codeListRestriction).not.undefined; + }); + it('Does not add any restriction when condition fails and there is no `else`', () => { + const record: DataRecord = { + [fieldNumberNoRestriction.name]: 0, + [fieldStringRequiredConditionalRange.name]: 'anything goes', + }; + const restrictions = resolveFieldRestrictions( + record[fieldStringRequiredConditionalRange.name], + record, + fieldStringRequiredConditionalRange, + ); + expect(restrictions.length).equal(1); + + // one of the restrictions is 'required' + const requiredRestriction = restrictions.find((restriction) => restriction.type === 'required'); + expect(requiredRestriction).not.undefined; + + // no 'codeList' restriction + const codeListRestriction = restrictions.find((restriction) => restriction.type === 'codeList'); + expect(codeListRestriction).undefined; + }); + describe('ConditionalRestrictionTest Case', () => { + // This is the `case` property at the level of the `conditions` array. It specifies how many conditions must match + // for this test to be true and apply the `then` restrictions. If the restriction resolves to `required` this indicates + // the `then` object is returned and therefore the condition tested true. The condition fail case returns the `else` + // with an `empty` restriction. Therefore, `required` is test passes, `empty` is test failed. + + const recordAllFields: DataRecord = { + [fieldStringNoRestriction.name]: 'asdf', + [fieldNumberNoRestriction.name]: 1, + [fieldBooleanNoRestriction.name]: true, + }; + const recordOneField: DataRecord = { + [fieldStringNoRestriction.name]: 'asdf', + }; + const recordNoFields: DataRecord = {}; + + it('Default case is `all`', () => { + expect(ARRAY_TEST_CASE_DEFAULT).equal('all'); + + // also repeating the case=`all` test with no case value specified, makes sure this default is being applied + const restrictionsAllFields = resolveFieldRestrictions( + 'irrelevant', + recordAllFields, + fieldStringConditionalMultipleConditions, + ); + const restrictionsOneField = resolveFieldRestrictions( + 'irrelevant', + recordOneField, + fieldStringConditionalMultipleConditions, + ); + const restrictionsNoFields = resolveFieldRestrictions( + 'irrelevant', + recordNoFields, + fieldStringConditionalMultipleConditions, + ); + + expect(restrictionsAllFields[0]?.type).equal('required'); // test passed + expect(restrictionsOneField[0]?.type).equal('empty'); // test failed + expect(restrictionsNoFields[0]?.type).equal('empty'); // test failed + }); + it('Case `all` requires every condition to be true', () => { + const fieldCaseAll = cloneDeep(fieldStringConditionalMultipleConditions); + assert( + typeof fieldCaseAll.restrictions === 'object' && + 'if' in fieldCaseAll.restrictions && + fieldCaseAll.restrictions?.if, + ); + fieldCaseAll.restrictions.if.case = 'all'; + + const restrictionsAllFields = resolveFieldRestrictions('irrelevant', recordAllFields, fieldCaseAll); + const restrictionsOneField = resolveFieldRestrictions('irrelevant', recordOneField, fieldCaseAll); + const restrictionsNoFields = resolveFieldRestrictions('irrelevant', recordNoFields, fieldCaseAll); + + expect(restrictionsAllFields[0]?.type).equal('required'); + expect(restrictionsOneField[0]?.type).equal('empty'); + expect(restrictionsNoFields[0]?.type).equal('empty'); + }); + it('Case `any` requires at least one condition to be true', () => { + const fieldCaseAny = cloneDeep(fieldStringConditionalMultipleConditions); + assert( + typeof fieldCaseAny.restrictions === 'object' && + 'if' in fieldCaseAny.restrictions && + fieldCaseAny.restrictions?.if, + ); + fieldCaseAny.restrictions.if.case = 'any'; + + const restrictionsAllFields = resolveFieldRestrictions('irrelevant', recordAllFields, fieldCaseAny); + const restrictionsOneField = resolveFieldRestrictions('irrelevant', recordOneField, fieldCaseAny); + const restrictionsNoFields = resolveFieldRestrictions('irrelevant', recordNoFields, fieldCaseAny); + + expect(restrictionsAllFields[0]?.type).equal('required'); // test resolves true + expect(restrictionsOneField[0]?.type).equal('required'); // test resolves true due to at least one field present + expect(restrictionsNoFields[0]?.type).equal('empty'); + }); + it('Case `none` requires all conditions to be false', () => { + const fieldCaseNone = cloneDeep(fieldStringConditionalMultipleConditions); + assert( + typeof fieldCaseNone.restrictions === 'object' && + 'if' in fieldCaseNone.restrictions && + fieldCaseNone.restrictions?.if, + ); + fieldCaseNone.restrictions.if.case = 'none'; + + const restrictionsAllFields = resolveFieldRestrictions('irrelevant', recordAllFields, fieldCaseNone); + const restrictionsOneField = resolveFieldRestrictions('irrelevant', recordOneField, fieldCaseNone); + const restrictionsNoFields = resolveFieldRestrictions('irrelevant', recordNoFields, fieldCaseNone); + + expect(restrictionsAllFields[0]?.type).equal('empty'); // test resolves false + expect(restrictionsOneField[0]?.type).equal('empty'); // test resolves false + expect(restrictionsNoFields[0]?.type).equal('required'); // test resolves true + }); + }); + + describe('RestrictionCondition Case', () => { + /* + The case property inside a condition indicates how many of the fields must pass the match test in order + for the condition to be true. + + The field to test this lists three string fields in the `fields` property and will match each of them with + the alpha-only regex. + + When the condition passes, the restrictions will resolve with `required`, and when it fails it will resolve + to `empty`. + */ + const recordAllMatch: DataRecord = { + first: 'asdf', + second: 'qwerty', + third: 'hello', + }; + const recordOneMatch: DataRecord = { + first: undefined, + second: 'qwerty', // matches alpha-only req + third: '1234', + }; + const recordNoMatches: DataRecord = { + first: undefined, + second: 'hello! world!', //symbols and whitespace don't match + third: '1234', + }; + + it('Default case is `all`', () => { + const restrictionsAllMatch = resolveFieldRestrictions( + 'irrelevant', + recordAllMatch, + fieldStringConditionalMultipleFieldsRegex, + ); + const restrictionsOneMatch = resolveFieldRestrictions( + 'irrelevant', + recordOneMatch, + fieldStringConditionalMultipleFieldsRegex, + ); + const restrictionsNoMatches = resolveFieldRestrictions( + 'irrelevant', + recordNoMatches, + fieldStringConditionalMultipleFieldsRegex, + ); + + expect(restrictionsAllMatch[0]?.type).equal('required'); // test passed + expect(restrictionsOneMatch[0]?.type).equal('empty'); // test failed + expect(restrictionsNoMatches[0]?.type).equal('empty'); // test failed + }); + it('Case `all` requires every field to match', () => { + const fieldCaseAll = cloneDeep(fieldStringConditionalMultipleFieldsRegex); + assert( + typeof fieldCaseAll.restrictions === 'object' && + 'if' in fieldCaseAll.restrictions && + fieldCaseAll.restrictions?.if && + fieldCaseAll.restrictions.if.conditions[0], + ); + fieldCaseAll.restrictions.if.conditions[0].case = 'all'; + + const restrictionsAllMatch = resolveFieldRestrictions('irrelevant', recordAllMatch, fieldCaseAll); + const restrictionsOneMatch = resolveFieldRestrictions('irrelevant', recordOneMatch, fieldCaseAll); + const restrictionsNoMatches = resolveFieldRestrictions('irrelevant', recordNoMatches, fieldCaseAll); + + expect(restrictionsAllMatch[0]?.type).equal('required'); // test passed + expect(restrictionsOneMatch[0]?.type).equal('empty'); // test failed + expect(restrictionsNoMatches[0]?.type).equal('empty'); // test failed + }); + + it('Case `any` requires at lest one field to match', () => { + const fieldCaseAny = cloneDeep(fieldStringConditionalMultipleFieldsRegex); + assert( + typeof fieldCaseAny.restrictions === 'object' && + 'if' in fieldCaseAny.restrictions && + fieldCaseAny.restrictions?.if && + fieldCaseAny.restrictions.if.conditions[0], + ); + fieldCaseAny.restrictions.if.conditions[0].case = 'any'; + + const restrictionsAllMatch = resolveFieldRestrictions('irrelevant', recordAllMatch, fieldCaseAny); + const restrictionsOneMatch = resolveFieldRestrictions('irrelevant', recordOneMatch, fieldCaseAny); + const restrictionsNoMatches = resolveFieldRestrictions('irrelevant', recordNoMatches, fieldCaseAny); + + expect(restrictionsAllMatch[0]?.type).equal('required'); // test passed + expect(restrictionsOneMatch[0]?.type).equal('required'); // test passed + expect(restrictionsNoMatches[0]?.type).equal('empty'); // test failed + }); + + it('Case `none` requires at lest one field to match', () => { + const fieldCaseNone = cloneDeep(fieldStringConditionalMultipleFieldsRegex); + assert( + typeof fieldCaseNone.restrictions === 'object' && + 'if' in fieldCaseNone.restrictions && + fieldCaseNone.restrictions?.if && + fieldCaseNone.restrictions.if.conditions[0], + ); + fieldCaseNone.restrictions.if.conditions[0].case = 'none'; + + const restrictionsAllMatch = resolveFieldRestrictions('irrelevant', recordAllMatch, fieldCaseNone); + const restrictionsOneMatch = resolveFieldRestrictions('irrelevant', recordOneMatch, fieldCaseNone); + const restrictionsNoMatches = resolveFieldRestrictions('irrelevant', recordNoMatches, fieldCaseNone); + + expect(restrictionsAllMatch[0]?.type).equal('empty'); // test passed + expect(restrictionsOneMatch[0]?.type).equal('empty'); // test passed + expect(restrictionsNoMatches[0]?.type).equal('required'); // test failed + }); + }); + describe('Nested Conditions', () => { + /* + The field we are testing with has 3 conditions we can test, structured like: + if(A - `any-string` has repeated text): + then: + if(B - `any-number` is 0 or greater): + then: regex + if(C - `any-boolean` === `true`): + then: required + else: empty + + to test this, we want to try the following cases: + case 1 - A false. only restriction is empty + case 2 - A true, B and C false. No restrictions. + case 3 - A true, B and C true. regex and required restrictions. + case 4 - A true, B true, and C false. only regex required. + case 5 - A true, B false, and C true. only required restriction. + */ + it('Case 1 - Root condition false, only root else restrictions returned', () => { + const record: DataRecord = { + [fieldStringNoRestriction.name]: 'asdf', + [fieldNumberNoRestriction.name]: 1, + [fieldBooleanNoRestriction.name]: true, + }; + const restrictions = resolveFieldRestrictions('irrelevant', record, fieldStringNestedConditional); + expect(restrictions.length).equal(1); + expect(restrictions[0]?.type).equal('empty'); + }); + it('Case 2 - Root condition true, other conditions false, resolves with no restrictions', () => { + const record: DataRecord = { + [fieldStringNoRestriction.name]: 'repeated repeated', + [fieldNumberNoRestriction.name]: -1, + [fieldBooleanNoRestriction.name]: false, + }; + const restrictions = resolveFieldRestrictions('irrelevant', record, fieldStringNestedConditional); + expect(restrictions.length).equal(0); + }); + + it('Case 3 - All conditions true, all then restrictions resolved', () => { + const record: DataRecord = { + [fieldStringNoRestriction.name]: 'repeated repeated', + [fieldNumberNoRestriction.name]: 1, + [fieldBooleanNoRestriction.name]: true, + }; + const restrictions = resolveFieldRestrictions('irrelevant', record, fieldStringNestedConditional); + expect(restrictions.length).equal(2); + + // one of the restrictions is 'required' + const requiredRestriction = restrictions.find((restriction) => restriction.type === 'required'); + expect(requiredRestriction).not.undefined; + expect(requiredRestriction?.rule).equal(true); + + // one of the restrictions is 'regex' + const regexRestriction = restrictions.find((restriction) => restriction.type === 'regex'); + expect(regexRestriction).not.undefined; + expect(regexRestriction?.rule).equal(regexAlphaOnly); + }); + it('Case 4 - Root condition true, number condition true and boolean condition false, returns regex restriction', () => { + const record: DataRecord = { + [fieldStringNoRestriction.name]: 'repeated repeated', + [fieldNumberNoRestriction.name]: 1, + [fieldBooleanNoRestriction.name]: false, + }; + const restrictions = resolveFieldRestrictions('irrelevant', record, fieldStringNestedConditional); + expect(restrictions.length).equal(1); + + // one of the restrictions is 'required' + const requiredRestriction = restrictions.find((restriction) => restriction.type === 'required'); + expect(requiredRestriction).undefined; + + // one of the restrictions is 'regex' + const regexRestriction = restrictions.find((restriction) => restriction.type === 'regex'); + expect(regexRestriction).not.undefined; + expect(regexRestriction?.rule).equal(regexAlphaOnly); + }); + it('Case 5 - Root condition true, number condition true and boolean condition false, returns regex restriction', () => { + const record: DataRecord = { + [fieldStringNoRestriction.name]: 'repeated repeated', + [fieldNumberNoRestriction.name]: -1, + [fieldBooleanNoRestriction.name]: true, + }; + const restrictions = resolveFieldRestrictions('irrelevant', record, fieldStringNestedConditional); + expect(restrictions.length).equal(1); + + // one of the restrictions is 'required' + const requiredRestriction = restrictions.find((restriction) => restriction.type === 'required'); + expect(requiredRestriction).not.undefined; + expect(requiredRestriction?.rule).equal(true); + + // one of the restrictions is 'regex' + const regexRestriction = restrictions.find((restriction) => restriction.type === 'regex'); + expect(regexRestriction).undefined; + }); + }); + }); }); From a4af390bdf042eed15d8fe73fed4a1929d817cce Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 11 Aug 2024 17:50:59 -0400 Subject: [PATCH 18/21] Generated JSON Schema with Conditional Restrictions --- generated/DictionaryMetaSchema.json | 1055 +++++++++++++++++++-------- scripts/src/generateMetaSchema.ts | 25 +- 2 files changed, 770 insertions(+), 310 deletions(-) diff --git a/generated/DictionaryMetaSchema.json b/generated/DictionaryMetaSchema.json index 21f012ae..d0d29b70 100644 --- a/generated/DictionaryMetaSchema.json +++ b/generated/DictionaryMetaSchema.json @@ -1,39 +1,723 @@ { "$ref": "#/definitions/Dictionary", "definitions": { - "ReferenceTag": { - "type": "string", - "pattern": "^#(\\/[-_A-Za-z0-9]+)+$" + "SchemaBooleanField": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/Name" + }, + "description": { + "type": "string" + }, + "isArray": { + "type": "boolean" + }, + "meta": { + "$ref": "#/definitions/Meta" + }, + "unique": { + "type": "boolean" + }, + "valueType": { + "type": "string", + "const": "boolean" + }, + "restrictions": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/BooleanFieldRestrictions" + }, + { + "type": "object", + "properties": { + "if": { + "$ref": "#/definitions/ConditionalRestrictionTest" + }, + "then": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/BooleanFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaBooleanField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/BooleanFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaBooleanField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + }, + "else": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/BooleanFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaBooleanField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/BooleanFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaBooleanField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + } + }, + "required": [ + "if" + ], + "additionalProperties": false + } + ] + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaBooleanField/properties/restrictions/anyOf/0" + } + } + ] + } + }, + "required": [ + "name", + "valueType" + ], + "additionalProperties": false }, - "ReferenceArray": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" + "SchemaIntegerField": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/Name" + }, + "description": { + "$ref": "#/definitions/SchemaBooleanField/properties/description" + }, + "isArray": { + "$ref": "#/definitions/SchemaBooleanField/properties/isArray" + }, + "meta": { + "$ref": "#/definitions/SchemaBooleanField/properties/meta" + }, + "unique": { + "$ref": "#/definitions/SchemaBooleanField/properties/unique" + }, + "valueType": { + "type": "string", + "const": "integer" + }, + "restrictions": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/IntegerFieldRestrictions" + }, + { + "type": "object", + "properties": { + "if": { + "$ref": "#/definitions/ConditionalRestrictionTest" + }, + "then": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/IntegerFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaIntegerField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/IntegerFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaIntegerField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + }, + "else": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/IntegerFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaIntegerField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/IntegerFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaIntegerField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + } + }, + "required": [ + "if" + ], + "additionalProperties": false + } + ] + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaIntegerField/properties/restrictions/anyOf/0" + } + } + ] + } + }, + "required": [ + "name", + "valueType" + ], + "additionalProperties": false + }, + "SchemaNumberField": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/Name" + }, + "description": { + "$ref": "#/definitions/SchemaBooleanField/properties/description" + }, + "isArray": { + "$ref": "#/definitions/SchemaBooleanField/properties/isArray" + }, + "meta": { + "$ref": "#/definitions/SchemaBooleanField/properties/meta" + }, + "unique": { + "$ref": "#/definitions/SchemaBooleanField/properties/unique" + }, + "valueType": { + "type": "string", + "const": "number" + }, + "restrictions": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/NumberFieldRestrictions" + }, + { + "type": "object", + "properties": { + "if": { + "$ref": "#/definitions/ConditionalRestrictionTest" + }, + "then": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/NumberFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaNumberField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/NumberFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaNumberField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + }, + "else": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/NumberFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaNumberField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/NumberFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaNumberField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + } + }, + "required": [ + "if" + ], + "additionalProperties": false + } + ] + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaNumberField/properties/restrictions/anyOf/0" + } + } + ] + } + }, + "required": [ + "name", + "valueType" + ], + "additionalProperties": false + }, + "SchemaStringField": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/Name" + }, + "description": { + "$ref": "#/definitions/SchemaBooleanField/properties/description" + }, + "isArray": { + "$ref": "#/definitions/SchemaBooleanField/properties/isArray" + }, + "meta": { + "$ref": "#/definitions/SchemaBooleanField/properties/meta" + }, + "unique": { + "$ref": "#/definitions/SchemaBooleanField/properties/unique" + }, + "valueType": { + "type": "string", + "const": "string" + }, + "restrictions": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/StringFieldRestrictions" + }, + { + "type": "object", + "properties": { + "if": { + "$ref": "#/definitions/ConditionalRestrictionTest" + }, + "then": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/StringFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaStringField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StringFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaStringField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + }, + "else": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/StringFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaStringField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/StringFieldRestrictions" + }, + { + "$ref": "#/definitions/SchemaStringField/properties/restrictions/anyOf/0/anyOf/1" + } + ] + } + } + ] + } + }, + "required": [ + "if" + ], + "additionalProperties": false + } + ] + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/SchemaStringField/properties/restrictions/anyOf/0" + } + } + ] + } + }, + "required": [ + "name", + "valueType" + ], + "additionalProperties": false + }, + "BooleanFieldRestrictions": { + "type": "object", + "properties": { + "empty": { + "type": "boolean" + }, + "required": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "IntegerFieldRestrictions": { + "type": "object", + "properties": { + "codeList": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 1 + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] + }, + "empty": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "range": { + "type": "object", + "properties": { + "exclusiveMax": { + "$ref": "#/definitions/IntegerFieldRestrictions/properties/codeList/anyOf/0/items" + }, + "exclusiveMin": { + "$ref": "#/definitions/IntegerFieldRestrictions/properties/codeList/anyOf/0/items" + }, + "max": { + "$ref": "#/definitions/IntegerFieldRestrictions/properties/codeList/anyOf/0/items" + }, + "min": { + "$ref": "#/definitions/IntegerFieldRestrictions/properties/codeList/anyOf/0/items" + } }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "NumberFieldRestrictions": { + "type": "object", + "properties": { + "codeList": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + }, + "minItems": 1 + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] + }, + "empty": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "range": { + "type": "object", + "properties": { + "exclusiveMax": { + "type": "number" + }, + "exclusiveMin": { + "type": "number" + }, + "max": { + "type": "number" + }, + "min": { + "type": "number" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "StringFieldRestrictions": { + "type": "object", + "properties": { + "codeList": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] + }, + "minItems": 1 + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] + }, + "empty": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "regex": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/StringFieldRestrictions/properties/regex/anyOf/0/anyOf/0" + } + } + ] + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] + } }, - "minItems": 1 + "additionalProperties": false }, - "References": { + "ConditionalRestrictionTest": { "type": "object", - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/definitions/ReferenceArray/items/anyOf/0" - }, - { - "$ref": "#/definitions/ReferenceArray" - }, - { - "$ref": "#/definitions/References" + "properties": { + "conditions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "match": { + "type": "object", + "properties": { + "codeList": { + "anyOf": [ + { + "$ref": "#/definitions/StringFieldRestrictions/properties/codeList/anyOf/0" + }, + { + "$ref": "#/definitions/NumberFieldRestrictions/properties/codeList/anyOf/0" + }, + { + "$ref": "#/definitions/IntegerFieldRestrictions/properties/codeList/anyOf/0" + } + ] + }, + "count": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/definitions/IntegerFieldRestrictions/properties/range" + } + ] + }, + "exists": { + "type": "boolean" + }, + "range": { + "$ref": "#/definitions/NumberFieldRestrictions/properties/range" + }, + "regex": { + "$ref": "#/definitions/StringFieldRestrictions/properties/regex/anyOf/0" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ConditionalRestrictionTest/properties/conditions/items/properties/match/properties/value/anyOf/0" + } + }, + { + "type": "integer" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ConditionalRestrictionTest/properties/conditions/items/properties/match/properties/value/anyOf/2" + } + }, + { + "type": "number" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ConditionalRestrictionTest/properties/conditions/items/properties/match/properties/value/anyOf/4" + } + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/ConditionalRestrictionTest/properties/conditions/items/properties/match/properties/value/anyOf/6" + } + } + ] + } + }, + "additionalProperties": false + }, + "case": { + "type": "string", + "enum": [ + "all", + "any", + "none" + ] + } + }, + "required": [ + "fields", + "match" + ], + "additionalProperties": false } - ] - } + }, + "case": { + "$ref": "#/definitions/ConditionalRestrictionTest/properties/conditions/items/properties/case" + } + }, + "required": [ + "conditions" + ], + "additionalProperties": false }, "Meta": { "type": "object", @@ -75,6 +759,40 @@ "minLength": 1, "pattern": "^[^.]+$" }, + "ReferenceArray": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "$ref": "#/definitions/ReferenceTag" + } + ] + }, + "minItems": 1 + }, + "ReferenceTag": { + "type": "string", + "pattern": "^#(\\/[-_A-Za-z0-9]+)+$" + }, + "References": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/ReferenceArray/items/anyOf/0" + }, + { + "$ref": "#/definitions/ReferenceArray" + }, + { + "$ref": "#/definitions/References" + } + ] + } + }, "Schema": { "type": "object", "properties": { @@ -153,291 +871,16 @@ "SchemaField": { "anyOf": [ { - "type": "object", - "properties": { - "name": { - "$ref": "#/definitions/Name" - }, - "description": { - "type": "string" - }, - "isArray": { - "type": "boolean" - }, - "meta": { - "$ref": "#/definitions/Meta" - }, - "unique": { - "type": "boolean" - }, - "valueType": { - "type": "string", - "const": "string" - }, - "restrictions": { - "anyOf": [ - { - "type": "object", - "properties": { - "codeList": { - "anyOf": [ - { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "minItems": 1 - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "required": { - "type": "boolean" - }, - "regex": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - } - }, - "additionalProperties": false - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/restrictions/anyOf/0" - } - } - ] - } - }, - "required": [ - "name", - "valueType" - ], - "additionalProperties": false + "$ref": "#/definitions/SchemaStringField" }, { - "type": "object", - "properties": { - "name": { - "$ref": "#/definitions/Name" - }, - "description": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/description" - }, - "isArray": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/isArray" - }, - "meta": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/meta" - }, - "unique": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/unique" - }, - "valueType": { - "type": "string", - "const": "number" - }, - "restrictions": { - "anyOf": [ - { - "type": "object", - "properties": { - "codeList": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "number" - }, - "minItems": 1 - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "required": { - "type": "boolean" - }, - "range": { - "type": "object", - "properties": { - "exclusiveMax": { - "type": "number" - }, - "exclusiveMin": { - "type": "number" - }, - "max": { - "type": "number" - }, - "min": { - "type": "number" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SchemaField/anyOf/1/properties/restrictions/anyOf/0" - } - } - ] - } - }, - "required": [ - "name", - "valueType" - ], - "additionalProperties": false + "$ref": "#/definitions/SchemaNumberField" }, { - "type": "object", - "properties": { - "name": { - "$ref": "#/definitions/Name" - }, - "description": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/description" - }, - "isArray": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/isArray" - }, - "meta": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/meta" - }, - "unique": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/unique" - }, - "valueType": { - "type": "string", - "const": "integer" - }, - "restrictions": { - "anyOf": [ - { - "type": "object", - "properties": { - "codeList": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "integer" - }, - "minItems": 1 - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "required": { - "type": "boolean" - }, - "range": { - "type": "object", - "properties": { - "exclusiveMax": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" - }, - "exclusiveMin": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" - }, - "max": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" - }, - "min": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0/properties/codeList/anyOf/0/items" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/anyOf/0" - } - } - ] - } - }, - "required": [ - "name", - "valueType" - ], - "additionalProperties": false + "$ref": "#/definitions/SchemaIntegerField" }, { - "type": "object", - "properties": { - "name": { - "$ref": "#/definitions/Name" - }, - "description": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/description" - }, - "isArray": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/isArray" - }, - "meta": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/meta" - }, - "unique": { - "$ref": "#/definitions/SchemaField/anyOf/0/properties/unique" - }, - "valueType": { - "type": "string", - "const": "boolean" - }, - "restrictions": { - "anyOf": [ - { - "type": "object", - "properties": { - "required": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/SchemaField/anyOf/3/properties/restrictions/anyOf/0" - } - } - ] - } - }, - "required": [ - "name", - "valueType" - ], - "additionalProperties": false + "$ref": "#/definitions/SchemaBooleanField" } ] }, diff --git a/scripts/src/generateMetaSchema.ts b/scripts/src/generateMetaSchema.ts index c419a3e2..5b71a0c6 100644 --- a/scripts/src/generateMetaSchema.ts +++ b/scripts/src/generateMetaSchema.ts @@ -3,15 +3,23 @@ * It will be output into the file ./generated/DictionaryMetaSchema.json */ import { + BooleanFieldRestrictions, + ConditionalRestrictionTest, Dictionary, - DictionaryBase, DictionaryMeta, + IntegerFieldRestrictions, NameValue, + NumberFieldRestrictions, ReferenceArray, ReferenceTag, References, Schema, + SchemaBooleanField, SchemaField, + SchemaIntegerField, + SchemaNumberField, + SchemaStringField, + StringFieldRestrictions, } from '@overture-stack/lectern-dictionary'; import fs from 'fs'; import { zodToJsonSchema } from 'zod-to-json-schema'; @@ -22,11 +30,20 @@ console.log('Generating JSON Schema Meta-Schema...'); const jsonSchema = zodToJsonSchema(Dictionary, { name: 'Dictionary', definitions: { - ReferenceTag, - ReferenceArray, - References, + SchemaBooleanField, + SchemaIntegerField, + SchemaNumberField, + SchemaStringField, + BooleanFieldRestrictions, + IntegerFieldRestrictions, + NumberFieldRestrictions, + StringFieldRestrictions, + ConditionalRestrictionTest, Meta: DictionaryMeta, Name: NameValue, + ReferenceArray, + ReferenceTag, + References, Schema, SchemaField, }, From 9e8a43bebc75b8c865d1de5c20d711ebcf8edbab Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Sun, 11 Aug 2024 18:20:21 -0400 Subject: [PATCH 19/21] Remove TODO statements that are not needed --- packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts b/packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts index 5ab1cab2..460365e3 100644 --- a/packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts +++ b/packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts @@ -554,6 +554,4 @@ describe('Dictionary Schemas', () => { expect(DictionaryMeta.safeParse(meta).success).false; }); }); - // TODO: References spec - // TODO: Conditional Restrictions }); From 8ff48c9ff009cdcb057f5b579734b195464d3fae Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Mon, 12 Aug 2024 12:17:13 -0400 Subject: [PATCH 20/21] Fix broken table in reference doc --- docs/dictionary-reference.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/dictionary-reference.md b/docs/dictionary-reference.md index d065e061..e06bb0a3 100644 --- a/docs/dictionary-reference.md +++ b/docs/dictionary-reference.md @@ -177,8 +177,7 @@ A requirement condition is defined by providing a field name or list of field na | `exists` | all | Boolean | This condition requires a field to either have a value or have no value. When the `exists` condition is set to `true`, the field must have a value. When `exists` is sdet to `false`, the field must have no value. For array fields, `exists=false` only matches when the array is completely empty, and `exists=true` passes if the array has 1 or more values - `arrayCase` has no interaction with the `exists` condition. | `true` | | `range` | `number`, `integer` | [RangeRule](#rangerule-data-structure) | Maximum and minimum value conditions that a numeric field must pass. | `{ min: 5, exclusiveMax: 10 }` Represents an integer from 5-9. | | `regex` | `string` | String (Regular Expression) | A regular expression pattern that the value must match. | `^NCIT:C\d+$` Value must match an NCI Thesaurus ID. | -| `value` | all | Type of specified fields | Field value matches the value of the specified field. Strings are matched case insensitive. When arrays are matched, the order of their | -| elements is ignored - a field matches this condition if the elements in field are the same elements as in the value match rule. For example, the rule `['abc', 'def']` matches the value `['def', 'abc']` but does not match `['abc', 'def', 'ghi']`. | `some_value`, `[1, 2, 3]` | +| `value` | all | Type of specified fields | Field value matches the value of the specified field. Strings are matched case insensitive. When arrays are matched, the order of their elements is ignored - a field matches this condition if the elements in field are the same elements as in the value match rule. For example, the rule `['abc', 'def']` matches the value `['def', 'abc']` but does not match `['abc', 'def', 'ghi']`. | `some_value`, `[1, 2, 3]` | ### Meta Data Structure From 6538864627c9982be925bf586f19404948e458a2 Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Mon, 19 Aug 2024 10:10:21 -0400 Subject: [PATCH 21/21] Code cleanup by removing unused functions and comments --- .../conditions/testConditionalRestriction.ts | 22 ++----------------- .../validateField/restrictions/testEmpty.ts | 1 - 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts index 097215de..53c1565b 100644 --- a/packages/validation/src/validateField/conditions/testConditionalRestriction.ts +++ b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts @@ -53,11 +53,8 @@ const fieldsPassMatchTest = ( return resultForArrayTestCase(fieldTestResults, arrayCase || ARRAY_TEST_CASE_DEFAULT); }; -const testConditionForSingularValue = ( - condition: RestrictionCondition, - _value: SingleDataValue, - fieldValues: DataRecordValue[], -): boolean => { +const testCondition = (condition: RestrictionCondition, _value: DataRecordValue, record: DataRecord): boolean => { + const fieldValues = condition.fields.map((fieldName) => record[fieldName]); const matchCodeList = condition.match.codeList; if (matchCodeList !== undefined) { if (!fieldsPassMatchTest(fieldValues, (value) => testMatchCodeList(matchCodeList, value), condition.case)) { @@ -101,21 +98,6 @@ const testConditionForSingularValue = ( return true; }; -const testConditionForArray = ( - _condition: RestrictionCondition, - _value: ArrayDataValue, - _fieldValues: DataRecordValue[], -): boolean => { - throw new Error('Unimplemented.'); -}; - -const testCondition = (condition: RestrictionCondition, value: DataRecordValue, record: DataRecord): boolean => { - const recordValues = condition.fields.map((fieldName) => record[fieldName]); - return Array.isArray(value) - ? testConditionForArray(condition, value, recordValues) - : testConditionForSingularValue(condition, value, recordValues); -}; - /** * Check all conditions inside the `if` object of a Conditiohnal Restriction to determine if the condition is met for * a given field and its data record. This will apply all match rules inside each condition versus a field value and data record. diff --git a/packages/validation/src/validateField/restrictions/testEmpty.ts b/packages/validation/src/validateField/restrictions/testEmpty.ts index 7a28a53f..758d7829 100644 --- a/packages/validation/src/validateField/restrictions/testEmpty.ts +++ b/packages/validation/src/validateField/restrictions/testEmpty.ts @@ -57,7 +57,6 @@ const internalTestEmptyArray = createFieldRestrictionTestForArrays( * @returns */ const testEmptyArray = (rule: boolean, values: ArrayDataValue): TestResult => { - // Note: This doesn't apply the if (rule === false) { return valid(); }