From 49c8f00e2adc0994fb48006d256a463e8440c10e Mon Sep 17 00:00:00 2001 From: Jon Eubank Date: Mon, 19 Aug 2024 10:32:59 -0400 Subject: [PATCH] Conditional Restrictions - Add conditional restriction logic to field meta schemas and implement validation logic (#223) * 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. * Field restrictions can be an array of restriction objects * Add tests for field restrictions of all forms * Test validation library uses all restrictions in array * Add document to detail major version changes * 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 * Tests for references in meta and regex arrays * ConditionalRestrictionTest has optional case with proper default in validation * Single match object per condition, instead of array * Fix recursive conditional restriction parsing and add tests * Named container and volume in compose file * Fix import path * Empty field restriction validation tests * Remove development test file * Remove script restriction from test dictionary * 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. * Adds tests for conditional restriction match rules * Generated JSON Schema with Conditional Restrictions * Remove TODO statements that are not needed * Fix broken table in reference doc * Code cleanup by removing unused functions and comments --- apps/server/docker-compose.yaml | 10 +- apps/server/src/services/dictionaryService.ts | 22 +- apps/server/src/services/schemaService.ts | 40 +- .../test/fixtures/schemas/references.ts | 7 - .../integration/fixtures/updateNewFile.json | 1 - docs/dictionary-reference.md | 37 +- docs/lectern-2.0-changes.md | 48 + generated/DictionaryMetaSchema.json | 1064 ++++++++++++----- .../src/metaSchema/dictionarySchemas.ts | 137 ++- .../src/metaSchema/referenceSchemas.ts | 2 +- .../src/metaSchema/restrictionsSchemas.ts | 110 +- packages/dictionary/src/references.ts | 339 ++++-- .../src/utils/resolveRestrictions.ts | 51 - packages/dictionary/src/utils/schemaUtils.ts | 21 +- packages/dictionary/src/utils/typeUtils.ts | 77 ++ packages/dictionary/test/diff.spec.ts | 4 +- .../dictionary/test/fixtures/diff/initial.ts | 2 +- .../dictionary/test/fixtures/diff/updated.ts | 2 +- .../references/codeList_references/input.ts | 5 +- .../references/codeList_references/output.ts | 2 +- .../empty_references_section/input.ts | 2 +- .../empty_references_section/output.ts | 2 +- .../nested_meta_references/input.ts | 29 + .../nested_meta_references/output.ts | 25 + .../input.ts | 25 +- .../output.ts | 15 +- .../references/no_references_section/input.ts | 2 +- .../no_references_section/output.ts | 2 +- .../input.ts} | 2 +- .../regex_reference_with_array/output.ts | 21 + .../input.ts | 53 + .../output.ts | 43 + .../dictionarySchemas.spec.ts} | 193 +-- .../metaSchema/restrictionsSchemas.spec.ts | 190 +++ packages/dictionary/test/references.spec.ts | 97 +- .../booleanRegex.spec.ts} | 2 +- .../test/{ => utils}/versionUtils.spec.ts | 2 +- .../parseValues/matchCodeListFormatting.ts | 25 +- .../validation/src/parseValues/parseValues.ts | 4 +- .../validation/src/utils/isValidValueType.ts | 4 +- .../src/utils/resultForArrayTestCase.ts | 29 + packages/validation/src/utils/typeUtils.ts | 95 -- .../src/validateField/FieldRestrictionRule.ts | 16 +- .../src/validateField/conditions/index.ts | 25 + .../conditions/testConditionalRestriction.ts | 112 ++ .../conditions/testMatchCodeList.ts | 49 + .../conditions/testMatchCount.ts | 48 + .../conditions/testMatchExists.ts | 60 + .../conditions/testMatchRange.ts | 31 +- .../conditions/testMatchRegex.ts | 35 + .../conditions/testMatchValue.ts | 63 + .../validateField/resolveFieldRestrictions.ts | 98 -- .../src/validateField/restrictions/index.ts | 2 + .../restrictions/resolveFieldRestrictions.ts | 100 ++ .../validateField/restrictions/testEmpty.ts | 81 ++ .../validateField/restrictions/testRegex.ts | 19 +- .../src/validateField/validateField.ts | 18 +- .../src/validateSchema/validateSchema.ts | 2 +- .../fieldStringConditionalExists.ts | 29 + ...ieldStringConditionalMultipleConditions.ts | 48 + ...eldStringConditionalMultipleFieldsRegex.ts | 34 + .../fieldStringNestedConditional.ts | 64 + .../fieldStringRequiredConditionalRange.ts | 35 + .../fieldStringArrayMultipleRegex.ts | 22 + .../schemaRestrictions/fieldStringUnique.ts | 2 +- .../fieldStringUniqueArray.ts | 2 +- .../fixtures/restrictions/regexFixtures.ts | 5 +- .../conditions/testMatchCodeList.spec.ts | 45 + .../conditions/testMatchCount.spec.ts | 65 + .../conditions/testMatchExists.spec.ts | 73 ++ .../conditions/testMatchRange.spec.ts | 53 + .../conditions/testMatchRegex.spec.ts | 54 + .../conditions/testMatchValue.spec.ts | 86 ++ .../resolveFieldRestrictions.spec.ts | 419 +++++++ .../test/validateField/validateField.spec.ts | 16 + .../validateRecord/validateRecord.spec.ts | 1 - scripts/src/generateMetaSchema.ts | 25 +- 77 files changed, 3688 insertions(+), 992 deletions(-) create mode 100644 docs/lectern-2.0-changes.md delete mode 100644 packages/dictionary/src/utils/resolveRestrictions.ts 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 rename packages/dictionary/test/fixtures/references/{script_references => no_referece_tags}/input.ts (53%) rename packages/dictionary/test/fixtures/references/{script_references => no_referece_tags}/output.ts (64%) 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 rename packages/dictionary/test/{dictionaryTypes.spec.ts => metaSchema/dictionarySchemas.spec.ts} (77%) create mode 100644 packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts rename packages/dictionary/test/{dataTypes.spec.ts => types/booleanRegex.spec.ts} (98%) rename packages/dictionary/test/{ => utils}/versionUtils.spec.ts (98%) create mode 100644 packages/validation/src/utils/resultForArrayTestCase.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 rename apps/server/test/functional/normalize.spec.ts => packages/validation/src/validateField/conditions/testMatchRange.ts (53%) create mode 100644 packages/validation/src/validateField/conditions/testMatchRegex.ts create mode 100644 packages/validation/src/validateField/conditions/testMatchValue.ts delete mode 100644 packages/validation/src/validateField/resolveFieldRestrictions.ts create mode 100644 packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts create mode 100644 packages/validation/src/validateField/restrictions/testEmpty.ts 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/fixtures/fields/multipleRestrictions/fieldStringArrayMultipleRegex.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/testMatchExists.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 create mode 100644 packages/validation/test/validateField/resolveFieldRestrictions.spec.ts 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 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/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 diff --git a/docs/dictionary-reference.md b/docs/dictionary-reference.md index 64bf0176..e06bb0a3 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,14 @@ 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 new file mode 100644 index 00000000..9f389539 --- /dev/null +++ b/docs/lectern-2.0-changes.md @@ -0,0 +1,48 @@ +# 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. +- 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 + +- 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 diff --git a/generated/DictionaryMetaSchema.json b/generated/DictionaryMetaSchema.json index a43a52d4..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" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] + "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" + } + } + ] + } }, - "minItems": 1 + "required": [ + "name", + "valueType" + ], + "additionalProperties": false }, - "References": { + "SchemaNumberField": { "type": "object", - "additionalProperties": { - "anyOf": [ - { - "$ref": "#/definitions/ReferenceArray/items/anyOf/0" + "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/ReferenceArray" + "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" + } }, - { - "$ref": "#/definitions/References" + "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" + } + ] + } + }, + "additionalProperties": false + }, + "ConditionalRestrictionTest": { + "type": "object", + "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,302 +871,16 @@ "SchemaField": { "anyOf": [ { - "type": "object", - "properties": { - "name": { - "$ref": "#/definitions/Name" - }, - "description": { - "type": "string" - }, - "isArray": { - "type": "boolean" - }, - "meta": { - "$ref": "#/definitions/Meta" - }, - "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" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "minItems": 1 - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "regex": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/ReferenceTag" - } - ] - }, - "unique": { - "type": "boolean" - } - }, - "additionalProperties": false - } - }, - "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" - }, - "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": { - "type": "object", - "properties": { - "exclusiveMax": { - "type": "number" - }, - "exclusiveMin": { - "type": "number" - }, - "max": { - "type": "number" - }, - "min": { - "type": "number" - } - }, - "additionalProperties": false - }, - "unique": { - "type": "boolean" - } - }, - "additionalProperties": false - } - }, - "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" - }, - "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": { - "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" - }, - "max": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/properties/codeList/anyOf/0/items" - }, - "min": { - "$ref": "#/definitions/SchemaField/anyOf/2/properties/restrictions/properties/codeList/anyOf/0/items" - } - }, - "additionalProperties": false - }, - "unique": { - "type": "boolean" - } - }, - "additionalProperties": false - } - }, - "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" - }, - "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" - } - ] - }, - "unique": { - "type": "boolean" - } - }, - "additionalProperties": false - } - }, - "required": [ - "name", - "valueType" - ], - "additionalProperties": false + "$ref": "#/definitions/SchemaBooleanField" } ] }, diff --git a/packages/dictionary/src/metaSchema/dictionarySchemas.ts b/packages/dictionary/src/metaSchema/dictionarySchemas.ts index 1e159b3d..63bb0738 100644 --- a/packages/dictionary/src/metaSchema/dictionarySchemas.ts +++ b/packages/dictionary/src/metaSchema/dictionarySchemas.ts @@ -17,17 +17,18 @@ * 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, RestrictionIntegerRange, RestrictionNumberRange, RestrictionRegex, - RestrictionScript, } from './restrictionsSchemas'; /** @@ -66,43 +67,112 @@ export type SchemaFieldValueType = zod.infer; /* ****************************** * * Field Type Restriction Objects * * ****************************** */ -export const StringFieldRestrictions = zod +export const BooleanFieldRestrictions = zod + .object({ empty: zod.boolean(), required: zod.boolean() }) + .partial() + .strict(); +export type BooleanFieldRestrictions = zod.infer; + +const BooleanFieldConditionalRestriction: ZodType> = zod .object({ - codeList: RestrictionCodeListString.or(ReferenceTag), + 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 + .object({ + codeList: RestrictionCodeListInteger.or(ReferenceTag), + empty: zod.boolean(), required: zod.boolean(), - script: RestrictionScript.or(ReferenceTag), - regex: RestrictionRegex.or(ReferenceTag), - unique: zod.boolean(), + range: RestrictionIntegerRange, }) - .partial(); -export type StringFieldRestrictions = zod.infer; + .partial() + .strict(); +export type IntegerFieldRestrictions = zod.infer; + +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 .object({ codeList: RestrictionCodeListNumber.or(ReferenceTag), + empty: zod.boolean(), required: zod.boolean(), - script: RestrictionScript.or(ReferenceTag), range: RestrictionNumberRange, - unique: zod.boolean(), }) - .partial(); + .partial() + .strict(); export type NumberFieldRestrictions = zod.infer; -export const IntegerFieldRestrictions = zod +const NumberFieldConditionalRestriction: ZodType> = zod .object({ - codeList: RestrictionCodeListInteger.or(ReferenceTag), + 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 + .object({ + codeList: RestrictionCodeListString.or(ReferenceTag), + empty: zod.boolean(), required: zod.boolean(), - script: RestrictionScript.or(ReferenceTag), - range: RestrictionIntegerRange, - unique: zod.boolean(), + regex: RestrictionRegex.or(ReferenceTag), }) - .partial(); -export type IntegerFieldRestrictions = zod.infer; + .partial() + .strict(); +export type StringFieldRestrictions = zod.infer; -export const BooleanFieldRestrictions = zod - .object({ required: zod.boolean(), script: RestrictionScript.or(ReferenceTag), unique: zod.boolean() }) - .partial(); -export type BooleanFieldRestrictions = zod.infer; +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([ + BooleanFieldRestrictions, + IntegerFieldRestrictions, + NumberFieldRestrictions, + StringFieldRestrictions, +]); +export type AnyFieldRestrictions = zod.infer; /* ***************** * * Field Definitions * @@ -113,22 +183,23 @@ 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; -export const SchemaStringField = SchemaFieldBase.merge( +export const SchemaBooleanField = SchemaFieldBase.merge( zod.object({ - valueType: zod.literal(SchemaFieldValueType.Values.string), - restrictions: StringFieldRestrictions.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.optional(), + restrictions: NumberFieldRestrictionsObject.or(NumberFieldRestrictionsObject.array()).optional(), }), ).strict(); export type SchemaNumberField = zod.infer; @@ -136,18 +207,18 @@ export type SchemaNumberField = zod.infer; export const SchemaIntegerField = SchemaFieldBase.merge( zod.object({ valueType: zod.literal(SchemaFieldValueType.Values.integer), - restrictions: IntegerFieldRestrictions.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.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..1497cc40 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'; @@ -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; @@ -45,7 +43,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 +101,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 +114,99 @@ 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 MatchRule = zod.union([ + MatchRuleCodeList, + MatchRuleCount, + MatchRuleExists, + MatchRuleRange, + MatchRuleRegex, + MatchRuleValue, +]); +export type MatchRule = zod.infer; + +export const ConditionMatch = zod + .object({ + codeList: MatchRuleCodeList, + count: MatchRuleCount, + exists: MatchRuleExists, + range: MatchRuleRange, + regex: MatchRuleRegex, + value: MatchRuleValue, + }) + .partial(); +type ConditionMatch = zod.infer; + +export const RestrictionCondition = zod.object({ + fields: zod.string().array(), + match: ConditionMatch, + case: ArrayTestCase.optional(), +}); +export type RestrictionCondition = zod.infer; + +export const ConditionalRestrictionTest = zod.object({ + conditions: zod.array(RestrictionCondition), + case: ArrayTestCase.optional(), +}); +export type ConditionalRestrictionTest = zod.infer; + +export type ConditionalRestriction = { + if: ConditionalRestrictionTest; + then?: + | TRestrictionObject + | ConditionalRestriction + | (TRestrictionObject | ConditionalRestriction)[]; + else?: + | TRestrictionObject + | ConditionalRestriction + | (TRestrictionObject | ConditionalRestriction)[]; +}; diff --git a/packages/dictionary/src/references.ts b/packages/dictionary/src/references.ts index 8c3a9df3..04198831 100644 --- a/packages/dictionary/src/references.ts +++ b/packages/dictionary/src/references.ts @@ -17,101 +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 resolveRestriction = (value: string | string[]) => - resolveAllReferences(value, references, discovered, visited); - const resolveNoArrays = (value: string | string[], restrictionName: string) => { - const output = resolveRestriction(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 - 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)); - } - break; - case 'number': - if (field.restrictions.script !== undefined) { - field.restrictions.script = TypeUtils.asArray(resolveRestriction(field.restrictions.script)); - } - break; - case 'integer': - if (field.restrictions.script !== undefined) { - field.restrictions.script = TypeUtils.asArray(resolveRestriction(field.restrictions.script)); - } - 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('.'); @@ -120,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, @@ -127,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); @@ -170,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)) { @@ -186,6 +153,174 @@ 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) => { + 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; + }); + + 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); + 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. @@ -193,13 +328,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. @@ -207,15 +339,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 7f6b5208..69231cfc 100644 --- a/packages/dictionary/src/utils/schemaUtils.ts +++ b/packages/dictionary/src/utils/schemaUtils.ts @@ -1,18 +1,33 @@ +import { TypeUtils } from '.'; 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) => field.restrictions?.required); + schema.fields.filter((field) => + 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) => !field.restrictions?.required); + schema.fields.filter((field) => + 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/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/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/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/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/script_references/input.ts b/packages/dictionary/test/fixtures/references/no_referece_tags/input.ts similarity index 53% rename from packages/dictionary/test/fixtures/references/script_references/input.ts rename to packages/dictionary/test/fixtures/references/no_referece_tags/input.ts index f386584d..c0a3697b 100644 --- a/packages/dictionary/test/fixtures/references/script_references/input.ts +++ b/packages/dictionary/test/fixtures/references/no_referece_tags/input.ts @@ -9,18 +9,23 @@ const content: Dictionary = { description: 'Donor Entity', fields: [ { - name: 'count', - valueType: 'number', + name: 'donor_submitter_id', + valueType: 'string', + description: 'Unique identifier for donor; assigned by data provider', + meta: { + displayName: 'Submitter Donor ID', + key: true, + }, restrictions: { - script: '#/IS_EVEN', + regex: '^[\\w]*$', }, }, { - name: 'score', + name: 'gender', valueType: 'string', description: 'Donor Biological Sex', restrictions: { - script: ['(value) => value/1000 > 9', '#/IS_EVEN'], + codeList: ['Male', 'Female', 'Other'], }, }, { @@ -36,7 +41,15 @@ const content: Dictionary = { }, ], references: { - IS_EVEN: '(value) => value % 2', + 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/script_references/output.ts b/packages/dictionary/test/fixtures/references/no_referece_tags/output.ts similarity index 64% rename from packages/dictionary/test/fixtures/references/script_references/output.ts rename to packages/dictionary/test/fixtures/references/no_referece_tags/output.ts index 169b1914..f59a1365 100644 --- a/packages/dictionary/test/fixtures/references/script_references/output.ts +++ b/packages/dictionary/test/fixtures/references/no_referece_tags/output.ts @@ -9,18 +9,23 @@ const content: Dictionary = { description: 'Donor Entity', fields: [ { - name: 'count', - valueType: 'number', + name: 'donor_submitter_id', + valueType: 'string', + description: 'Unique identifier for donor; assigned by data provider', + meta: { + displayName: 'Submitter Donor ID', + key: true, + }, restrictions: { - script: ['(value) => value % 2'], + regex: '^[\\w]*$', }, }, { - name: 'score', + name: 'gender', valueType: 'string', description: 'Donor Biological Sex', restrictions: { - script: ['(value) => value/1000 > 9', '(value) => value % 2'], + codeList: ['Male', 'Female', 'Other'], }, }, { 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/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/dictionaryTypes.spec.ts b/packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts similarity index 77% rename from packages/dictionary/test/dictionaryTypes.spec.ts rename to packages/dictionary/test/metaSchema/dictionarySchemas.spec.ts index 2637f8ad..460365e3 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,75 +40,116 @@ 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('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; }); - }); - 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; + 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; }); - }); - 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('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', () => { diff --git a/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts b/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts new file mode 100644 index 00000000..42bdcc25 --- /dev/null +++ b/packages/dictionary/test/metaSchema/restrictionsSchemas.spec.ts @@ -0,0 +1,190 @@ +/* + * 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 assert from 'assert'; +import { expect } from 'chai'; +import { + Integer, + RestrictionIntegerRange, + RestrictionNumberRange, + SchemaBooleanField, + SchemaField, + SchemaIntegerField, + SchemaNumberField, + SchemaStringField, + StringFieldRestrictions, + type ConditionalRestriction, + type StringFieldRestrictionsObject, +} from '../../src'; + +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 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; + }); + }); + + 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 }, + }, + { + if: { + conditions: [{ fields: ['third-dependent-field'], match: { range: { max: 10, min: 0 } } }], + }, + then: { regex: 'asdf' }, + else: { codeList: ['a', 's', 'd', 'f'] }, + }, + ], + 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/references.spec.ts b/packages/dictionary/test/references.spec.ts index 823d8ba9..3f20af4a 100644 --- a/packages/dictionary/test/references.spec.ts +++ b/packages/dictionary/test/references.spec.ts @@ -20,69 +20,88 @@ 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 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 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'; -import nonExistingReferencesInput from './fixtures/references/non_existing_references/input'; -import cyclicReferencesInput from './fixtures/references/cyclic_references/input'; +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'; 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', () => { + 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( + "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'"); + }); + 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('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); + 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( - `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'", - ); - }); - 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'"); + 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); + }); }); }); 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; diff --git a/packages/validation/src/parseValues/matchCodeListFormatting.ts b/packages/validation/src/parseValues/matchCodeListFormatting.ts index 72767463..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 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 @@ -36,10 +53,10 @@ 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; + 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/resultForArrayTestCase.ts b/packages/validation/src/utils/resultForArrayTestCase.ts new file mode 100644 index 00000000..fd0d15ec --- /dev/null +++ b/packages/validation/src/utils/resultForArrayTestCase.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/FieldRestrictionRule.ts b/packages/validation/src/validateField/FieldRestrictionRule.ts index 41117cb7..8446325d 100644 --- a/packages/validation/src/validateField/FieldRestrictionRule.ts +++ b/packages/validation/src/validateField/FieldRestrictionRule.ts @@ -22,13 +22,16 @@ import type { RestrictionCodeList, RestrictionRange, RestrictionRegex, - RestrictionScript, } from '@overture-stack/lectern-dictionary'; 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; @@ -45,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/conditions/index.ts b/packages/validation/src/validateField/conditions/index.ts new file mode 100644 index 00000000..c49a91d4 --- /dev/null +++ b/packages/validation/src/validateField/conditions/index.ts @@ -0,0 +1,25 @@ +/* + * 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'; +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 new file mode 100644 index 00000000..53c1565b --- /dev/null +++ b/packages/validation/src/validateField/conditions/testConditionalRestriction.ts @@ -0,0 +1,112 @@ +/* + * 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, + ArrayTestCase, + type ArrayDataValue, + type ConditionalRestrictionTest, + type DataRecord, + type DataRecordValue, + type RestrictionCondition, + type SingleDataValue, +} from '@overture-stack/lectern-dictionary'; +import { resultForArrayTestCase } from '../../utils/resultForArrayTestCase'; +import { testMatchCount } from './testMatchCount'; +import { testMatchCodeList } from './testMatchCodeList'; +import { testMatchExists } from './testMatchExists'; +import { testMatchRange } from './testMatchRange'; +import { testMatchRegex } from './testMatchRegex'; +import { testMatchValue } from './testMatchValue'; + +/** + * 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[], + matchTest: (value: DataRecordValue) => boolean, + arrayCase: ArrayTestCase | undefined, +): boolean => { + const fieldTestResults = values.map((value) => matchTest(value)); + return resultForArrayTestCase(fieldTestResults, arrayCase || ARRAY_TEST_CASE_DEFAULT); +}; + +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)) { + return false; + } + } + const matchCount = condition.match.count; + if (matchCount !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchCount(matchCount, value), condition.case)) { + return false; + } + } + + const matchExists = condition.match.exists; + if (matchExists !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchExists(matchExists, value), condition.case)) { + return false; + } + } + + const matchRange = condition.match.range; + if (matchRange !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchRange(matchRange, value), condition.case)) { + return false; + } + } + + const matchRegex = condition.match.regex; + if (matchRegex !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchRegex(matchRegex, value), condition.case)) { + return false; + } + } + + const matchValue = condition.match.value; + if (matchValue !== undefined) { + if (!fieldsPassMatchTest(fieldValues, (value) => testMatchValue(matchValue, value), condition.case)) { + return false; + } + } + return true; +}; + +/** + * 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 || ARRAY_TEST_CASE_DEFAULT); +}; 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..e6fe8e92 --- /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/apps/server/test/functional/normalize.spec.ts b/packages/validation/src/validateField/conditions/testMatchRange.ts similarity index 53% rename from apps/server/test/functional/normalize.spec.ts rename to packages/validation/src/validateField/conditions/testMatchRange.ts index b4358339..ec5e86e5 100644 --- a/apps/server/test/functional/normalize.spec.ts +++ b/packages/validation/src/validateField/conditions/testMatchRange.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved + * 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 @@ -17,19 +17,18 @@ * 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'; +import { type DataRecordValue, type MatchRuleRange } from '@overture-stack/lectern-dictionary'; +import { isNumberArray } from '@overture-stack/lectern-dictionary/dist/utils/typeUtils'; +import { testRange } from '../restrictions'; -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]); - }); -}); +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/resolveFieldRestrictions.ts b/packages/validation/src/validateField/resolveFieldRestrictions.ts deleted file mode 100644 index 1f6952ed..00000000 --- a/packages/validation/src/validateField/resolveFieldRestrictions.ts +++ /dev/null @@ -1,98 +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. - */ - -import type { DataRecord, DataRecordValue, SchemaField } from '@overture-stack/lectern-dictionary'; -import type { FieldRestrictionRule } from './FieldRestrictionRule'; - -/** - * Convert the restrictions found in a SchemaField definition into a list of rules that apply for this specific value - * and DataRecord. - */ -export const resolveFieldRestrictions = ( - _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 []; - } - - 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; - } - } -}; diff --git a/packages/validation/src/validateField/restrictions/index.ts b/packages/validation/src/validateField/restrictions/index.ts index 682deae8..2f7b542b 100644 --- a/packages/validation/src/validateField/restrictions/index.ts +++ b/packages/validation/src/validateField/restrictions/index.ts @@ -17,7 +17,9 @@ * 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 './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 new file mode 100644 index 00000000..b655b77a --- /dev/null +++ b/packages/validation/src/validateField/restrictions/resolveFieldRestrictions.ts @@ -0,0 +1,100 @@ +/* + * 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 AnyFieldRestrictions, + type DataRecord, + type DataRecordValue, + type SchemaField, + type SchemaRestrictions, +} from '@overture-stack/lectern-dictionary'; +import type { FieldRestrictionRule } from '../FieldRestrictionRule'; +import { testConditionalRestriction } from '../conditions/testConditionalRestriction'; + +const extractRulesFromRestriction = (restrictions: AnyFieldRestrictions): FieldRestrictionRule[] => { + const rules: FieldRestrictionRule[] = []; + + 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 }); + } + } + if ('regex' in restrictions) { + if (restrictions.regex) { + rules.push({ type: 'regex', rule: restrictions.regex }); + } + } + if ('required' in restrictions) { + if (restrictions.required) { + rules.push({ type: 'required', rule: restrictions.required }); + } + } + + 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. 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, + field: SchemaField, +): FieldRestrictionRule[] => recursiveResolveRestrictions(field.restrictions, value, record); diff --git a/packages/validation/src/validateField/restrictions/testEmpty.ts b/packages/validation/src/validateField/restrictions/testEmpty.ts new file mode 100644 index 00000000..758d7829 --- /dev/null +++ b/packages/validation/src/validateField/restrictions/testEmpty.ts @@ -0,0 +1,81 @@ +/* + * 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 => { + 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/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..a2b318aa 100644 --- a/packages/validation/src/validateField/validateField.ts +++ b/packages/validation/src/validateField/validateField.ts @@ -21,18 +21,26 @@ 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'; 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(); - // } } }; 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/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/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/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; 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/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 new file mode 100644 index 00000000..94d3d9bf --- /dev/null +++ b/packages/validation/test/validateField/conditions/testMatchExists.spec.ts @@ -0,0 +1,73 @@ +/* + * 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'; + +describe('ConditionalRestriction - testMatchExists', () => { + 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('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('Tests true for value `false`', () => { + expect(testMatchExists(true, false)).true; + }); + it('Tests false for `undefined`', () => { + expect(testMatchExists(true, undefined)).false; + }); + it('Tests false for empty string values', () => { + expect(testMatchExists(true, '')).false; + }); + it('Tests false for string values with only whitespace', () => { + expect(testMatchExists(true, ' ')).false; + }); + 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('Tests false for empty array value', () => { + expect(testMatchExists(true, [])).false; + }); + it('Tests false for arrays with only non existing elements', () => { + expect(testMatchExists(true, [''])).false; + expect(testMatchExists(true, ['', ' '])).false; + expect(testMatchExists(true, [NaN, Infinity])).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 new file mode 100644 index 00000000..1f460dc9 --- /dev/null +++ b/packages/validation/test/validateField/resolveFieldRestrictions.spec.ts @@ -0,0 +1,419 @@ +/* + * 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 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', () => { + 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; + }); + 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; + }); + }); + }); +}); 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; + }); + }); }); }); 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); 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, },