diff --git a/CHANGELOG.md b/CHANGELOG.md index b01878ee7..213ae467c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ The main work (all changes without a GitHub username in brackets in the below li - @matter/model - Feature: The constraint evaluator now supports simple mathematical expressions + - Feature: The constraint evaluator now supports limits on the number of Unicode codepoints in a string - @project-chip/matter.js - Feature: (Breaking) Added Fabric Label for Controller as required property to initialize the Controller diff --git a/packages/model/src/aspects/Constraint.ts b/packages/model/src/aspects/Constraint.ts index 443eb8522..c51c0955e 100644 --- a/packages/model/src/aspects/Constraint.ts +++ b/packages/model/src/aspects/Constraint.ts @@ -5,6 +5,7 @@ */ import { Lexer } from "#parser/Lexer.js"; +import { BasicToken } from "#parser/Token.js"; import { TokenStream } from "#parser/TokenStream.js"; import { camelize } from "@matter/general"; import { FieldValue } from "../common/index.js"; @@ -22,6 +23,7 @@ export class Constraint extends Aspect implements Constra declare max?: Constraint.Expression; declare in?: FieldValue; declare entry?: Constraint; + declare cpMax?: number; declare parts?: Constraint[]; /** @@ -76,6 +78,9 @@ export class Constraint extends Aspect implements Constra if (ast.entry !== undefined) { this.entry = new Constraint(ast.entry); } + if (ast.cpMax !== undefined) { + this.cpMax = ast.cpMax; + } if (ast.parts !== undefined) { this.parts = ast.parts.map(p => new Constraint(p)); } @@ -222,6 +227,11 @@ export namespace Constraint { */ entry?: Ast; + /** + * Constraint on codepoints in a string. + */ + cpMax?: number; + /** * List of sub-constraints in a sequence. */ @@ -258,10 +268,13 @@ namespace Serializer { if (ast.entry) { return `${serializeAtom(ast)}[${serialize(ast.entry)}]`; } + if (ast.cpMax) { + return `${serializeAtom(ast)}{${ast.cpMax}}`; + } return serializeAtom(ast); } - function serializeValue(value: Constraint.Expression): string { + function serializeValue(value: Constraint.Expression, inExpr = false): string { if (typeof value !== "object" || value === null || Array.isArray(value) || value instanceof Date) { return FieldValue.serialize(value); } @@ -269,7 +282,13 @@ namespace Serializer { switch (value.type) { case "+": case "-": - return `(${serializeValue(value.lhs)} ${value.type} ${serializeValue(value.rhs)})`; + const sum = `${serializeValue(value.lhs, true)} ${value.type} ${serializeValue(value.rhs, true)}`; + if (inExpr) { + // Ideally only add parenthesis if precedence requires. But nested expressions are not used + // anywhere as yet (and probably won't be) so don't try to be fancy, just correct + return `(${sum})`; + } + return sum; default: return FieldValue.serialize(value); @@ -351,28 +370,63 @@ namespace Parser { } function parsePart(): Constraint.Ast | undefined { - const result = parsePartWithoutArray(); + const result = parsePartWithoutSubconstraint(); - if (result !== undefined && tokens.token?.type === "[") { - tokens.next(); + if (result === undefined) { + return result; + } - const entry = parseParts(); + switch (tokens.token?.type) { + case "[": + { + tokens.next(); - if (tokens.token?.type !== ("]" as any)) { - constraint.error("MISSING_ENTRY_END", 'Entry constraint does not end with "]"'); - } + const entry = parseParts(); - tokens.next(); + if (tokens.token?.type !== ("]" as any)) { + constraint.error("MISSING_ENTRY_END", 'Entry constraint does not end with "]"'); + } - if (entry !== undefined) { - result.entry = entry; - } + tokens.next(); + + if (entry !== undefined) { + result.entry = entry; + } + } + break; + + case "{": + { + tokens.next(); + + if (tokens.token?.type !== ("value" as any)) { + constraint.error( + "MISSING_CODEPOINT_MAX", + "Codepoint constraint does not specify maximum codepoint length", + ); + if (tokens.peeked?.type === "}") { + tokens.next(); + } + } else { + result.cpMax = FieldValue.numericValue( + (tokens.token as unknown as BasicToken.Number).value, + ); + tokens.next(); + } + + if (tokens.token?.type !== ("}" as any)) { + constraint.error("MISSING_CODEPOINT_END", 'Codepoint constraint does not end with "}"'); + } + + tokens.next(); + } + break; } return result; } - function parsePartWithoutArray(): Constraint.Ast | undefined { + function parsePartWithoutSubconstraint(): Constraint.Ast | undefined { const { token } = tokens; if (!token) { @@ -486,6 +540,37 @@ namespace Parser { tokens.next(); return ref; + case "-": + case "+": { + tokens.next(); + + let number = tokens.token?.type === "value" ? tokens.token.value : undefined; + + if (number !== undefined) { + tokens.next(); + + if (token.type === "-") { + if (typeof number === "number") { + number *= -1; + } else if ( + FieldValue.is(number, FieldValue.percent) || + FieldValue.is(number, FieldValue.celsius) + ) { + (number as FieldValue.Percent | FieldValue.Celsius).value *= -1; + } else { + number = undefined; + } + } + } + + if (number === undefined) { + constraint.error("MISSING_NUMBER", `Unary "${token.type}" not followed by numeric value`); + return; + } + + return number; + } + case "(": { tokens.next(); diff --git a/packages/model/src/parser/Lexer.ts b/packages/model/src/parser/Lexer.ts index 8735d0168..fb6e6c773 100644 --- a/packages/model/src/parser/Lexer.ts +++ b/packages/model/src/parser/Lexer.ts @@ -117,33 +117,7 @@ function* lex( return; } - function tokenizeNumber(sign: number) { - markStart(); - if (sign === -1) { - // Skip "-" prefix - next(); - } - - if (current.value === "0") { - if (peeked.value === "x") { - next(); - next(); - return tokenizeDigits(16, sign, hexadecimalValueOf); - } else if (peeked.value === "b") { - next(); - next(); - return tokenizeDigits(2, sign, binaryValueOf); - } - } - - return tokenizeDigits(10, sign, decimalValueOf); - } - - function tokenizeDigits( - base: number, - sign: number, - valueOf: (digit: string[1] | undefined) => number | undefined, - ): BasicToken { + function tokenizeDigits(base: number, valueOf: (digit: string[1] | undefined) => number | undefined): BasicToken { // The first digit may not actually be a digit if number is hexadecimal or binary let num = valueOf(current.value); if (num === undefined) { @@ -161,8 +135,6 @@ function* lex( num = num * base + digitValue; } - num *= sign; - if (base === 10 && peeked.value === ".") { next(); let fraction = ""; @@ -204,22 +176,34 @@ function* lex( case "]": case "(": case ")": + case "{": + case "}": + case "-": case "+": case "/": case "*": yield { type: current.value, startLine: line, startChar: char }; break; - case "-": - if (peeked.value !== undefined && peeked.value >= "0" && peeked.value <= "9") { - yield tokenizeNumber(-1); - } else { - yield { type: current.value, startLine: line, startChar: char }; + case "0": + markStart(); + + if (current.value === "0") { + if (peeked.value === "x") { + next(); + next(); + yield tokenizeDigits(16, hexadecimalValueOf); + break; + } + + if (peeked.value === "b") { + next(); + next(); + yield tokenizeDigits(2, binaryValueOf); + } } - break; - case "0": - yield tokenizeNumber(1); + yield tokenizeDigits(10, decimalValueOf); break; case "1": @@ -231,7 +215,7 @@ function* lex( case "7": case "8": case "9": - yield tokenizeDigits(10, 1, decimalValueOf); + yield tokenizeDigits(10, decimalValueOf); break; case "!": diff --git a/packages/model/src/parser/Token.ts b/packages/model/src/parser/Token.ts index 141d1b91a..251ba1fe1 100644 --- a/packages/model/src/parser/Token.ts +++ b/packages/model/src/parser/Token.ts @@ -43,6 +43,8 @@ export namespace BasicToken { | "]" | "(" | ")" + | "{" + | "}" | "-" | "+" | "*" diff --git a/packages/model/test/aspects/ConstraintTest.ts b/packages/model/test/aspects/ConstraintTest.ts index 3eef981bd..63ed0efcd 100644 --- a/packages/model/test/aspects/ConstraintTest.ts +++ b/packages/model/test/aspects/ConstraintTest.ts @@ -10,6 +10,8 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin ["0", { value: 0 }], ["desc", { desc: true }], ["4", { value: 4 }], + ["-4", { value: -4 }], + ["+4", { value: 4 }, "4"], ["4%", { value: { type: "percent", value: 4 } }], ["4°C", { value: { type: "celsius", value: 4 } }], ["3.141592", { value: 3.141592 }], @@ -19,8 +21,15 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin ["0x4 to 0x44", { min: 4, max: 68 }, "4 to 68"], ["0xff to 0xffff", { min: 255, max: 65535 }, "255 to 65535"], ["4[44]", { value: 4, entry: { value: 44 } }], + ["4{44}", { value: 4, cpMax: 44 }], ["4, 44", { parts: [{ value: 4 }, { value: 44 }] }], ["in foo", { in: { type: "reference", name: "foo" } }], + ["-2.5°C to 2.5°C", { min: { type: "celsius", value: -2.5 }, max: { type: "celsius", value: 2.5 } }], + [ + "0 to NumberOfPositions-1", + { min: 0, max: { type: "-", lhs: { type: "reference", name: "numberOfPositions" }, rhs: 1 } }, + "0 to numberOfPositions - 1", + ], [ "4[44, 444], 5[max 55, min 555]", { @@ -41,7 +50,7 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin }, ], [ - "(foo - 2)", + "foo - 2", { value: { type: "-", @@ -54,7 +63,7 @@ const TEST_CONSTRAINTS: [text: string, ast: Constraint.Ast, expectedText?: strin }, ], [ - "4 to (foo + 2)", + "4 to foo + 2", { min: 4, diff --git a/packages/node/src/behavior/state/validation/constraint.ts b/packages/node/src/behavior/state/validation/constraint.ts index ff9c6b875..1f1453f7c 100644 --- a/packages/node/src/behavior/state/validation/constraint.ts +++ b/packages/node/src/behavior/state/validation/constraint.ts @@ -9,7 +9,7 @@ import { Constraint, FieldValue, Metatype, ValueModel } from "#model"; import { ConstraintError } from "../../errors.js"; import { ValueSupervisor } from "../../supervision/ValueSupervisor.js"; import { Val } from "../Val.js"; -import { assertArray, assertBoolean, assertNumeric, assertSequence } from "./assertions.js"; +import { assertArray, assertBoolean, assertNumeric, assertSequence, assertString } from "./assertions.js"; /** * Creates a function that validates values based on the constraint in the @@ -62,7 +62,38 @@ export function createConstraintValidator( } }; - case Metatype.string: + case Metatype.string: { + const validateLength: ValueSupervisor.Validate = (value: Val, _session, location) => { + assertSequence(value, location); + if (!constraint.test(value.length, location.siblings)) { + throw new ConstraintError( + schema, + location, + `String length of ${value.length} is not within bounds defined by constraint`, + ); + } + }; + + const { cpMax } = constraint; + if (cpMax === undefined) { + return validateLength; + } + + return (value: Val, _session, location) => { + validateLength(value, _session, location); + assertString(value, location); + + const codepointCount = [...value].length; + if (codepointCount > cpMax) { + throw new ConstraintError( + schema, + location, + `Codepoint count of ${codepointCount} is not within bounds defined by constraint`, + ); + } + }; + } + case Metatype.bytes: return (value: Val, _session, location) => { assertSequence(value, location); @@ -70,7 +101,7 @@ export function createConstraintValidator( throw new ConstraintError( schema, location, - `Lenght of value ${value} is not within bounds defined by constraint`, + `Byte length of ${value.length} is not within bounds defined by constraint`, ); } }; diff --git a/packages/node/test/behavior/state/validation/constraintTest.ts b/packages/node/test/behavior/state/validation/constraintTest.ts index 724ed9366..c0ce3331b 100644 --- a/packages/node/test/behavior/state/validation/constraintTest.ts +++ b/packages/node/test/behavior/state/validation/constraintTest.ts @@ -41,7 +41,7 @@ const AllTests = Tests({ error: { type: ConstraintError, message: - 'Validating Test.test: Constraint "min (minVal + 1)": Value 4 is not within bounds defined by constraint', + 'Validating Test.test: Constraint "min minVal + 1": Value 4 is not within bounds defined by constraint', }, }, "accepts if reference value is missing": { record: { test: 3 } }, @@ -114,6 +114,50 @@ const AllTests = Tests({ }, }, }), + + "range with expression": Tests(Fields({ constraint: "0 to NumberOfPositions-1" }, { name: "NumberOfPositions" }), { + "accepts if under": { + record: { test: 1, numberOfPositions: 2 }, + }, + "rejects if over": { + record: { test: 2, numberOfPositions: 2 }, + error: { + type: ConstraintError, + message: + 'Validating Test.test: Constraint "0 to numberOfPositions - 1": Value 2 is not within bounds defined by constraint', + }, + }, + }), + + "string length": Tests(Fields({ type: "string", constraint: "max 2" }), { + "accepts if under": { + record: { test: "ab" }, + }, + + "rejects if over": { + record: { test: "abc" }, + error: { + type: ConstraintError, + message: + 'Validating Test.test: Constraint "max 2": String length of 3 is not within bounds defined by constraint', + }, + }, + }), + + "string length with codepoints": Tests(Fields({ type: "string", constraint: "max 8{1}" }), { + "accepts if under": { + record: { test: "𩸽" }, + }, + + "rejects if over": { + record: { test: "𩸽定" }, + error: { + type: ConstraintError, + message: + 'Validating Test.test: Constraint "max 8{1}": Codepoint count of 2 is not within bounds defined by constraint', + }, + }, + }), }); testValidation("constraint", AllTests);