From 172b80a2e9ad116ba52259370223d45c4b7fb35d Mon Sep 17 00:00:00 2001 From: Matthijs van Henten <440737+mvhenten@users.noreply.github.com> Date: Wed, 17 Dec 2025 12:39:30 +0100 Subject: [PATCH] Feat(#27): add input validation --- src/emitter.ts | 326 ++++++++++++++++++++++++++++++++++++++++++ test/entities.test.js | 215 ++++++++++++++++++++++------ 2 files changed, 497 insertions(+), 44 deletions(-) diff --git a/src/emitter.ts b/src/emitter.ts index 1b2f356..265ddf3 100644 --- a/src/emitter.ts +++ b/src/emitter.ts @@ -13,6 +13,12 @@ import type { import { type EmitContext, emitFile, + getFormat, + getMaxLength, + getMaxValue, + getMinLength, + getMinValue, + getPattern, resolvePath, walkPropertiesInherited, } from "@typespec/compiler"; @@ -45,6 +51,231 @@ function extractDefaultValue( } } +/** + * Determines if a scalar type is an integer type. + */ +function isIntegerType(type: Scalar): boolean { + const integerTypes = [ + "integer", + "int64", + "int32", + "int16", + "int8", + "uint64", + "uint32", + "uint16", + "uint8", + "safeint", + ]; + + // Check the type itself first + if (integerTypes.includes(type.name)) { + return true; + } + + // Walk up the base scalar chain + let baseType = type.baseScalar; + while (baseType) { + if (integerTypes.includes(baseType.name)) { + return true; + } + baseType = baseType.baseScalar; + } + + return false; +} + +/** + * Determines if a scalar type is a float type. + */ +function isFloatType(type: Scalar): boolean { + const floatTypes = ["float", "float32", "float64", "decimal", "decimal128"]; + + // Check the type itself first + if (floatTypes.includes(type.name)) { + return true; + } + + // Walk up the base scalar chain + let baseType = type.baseScalar; + while (baseType) { + if (floatTypes.includes(baseType.name)) { + return true; + } + baseType = baseType.baseScalar; + } + + return false; +} + +/** + * Determines if a scalar type is a date/time type. + */ +function isDateTimeType(type: Scalar): boolean { + const dateTimeTypes = [ + "utcDateTime", + "offsetDateTime", + "plainDate", + "plainTime", + ]; + + // Check the type itself first + if (dateTimeTypes.includes(type.name)) { + return true; + } + + // Walk up the base scalar chain + let baseType = type.baseScalar; + while (baseType) { + if (dateTimeTypes.includes(baseType.name)) { + return true; + } + baseType = baseType.baseScalar; + } + + return false; +} + +/** + * Gets the base scalar name for a type (for datetime type identification). + */ +function getBaseScalarName(type: Scalar): string { + const dateTimeTypes = [ + "utcDateTime", + "offsetDateTime", + "plainDate", + "plainTime", + ]; + + // Check the type itself first + if (dateTimeTypes.includes(type.name)) { + return type.name; + } + + // Walk up the base scalar chain to find a datetime type + let baseType = type.baseScalar; + while (baseType) { + if (dateTimeTypes.includes(baseType.name)) { + return baseType.name; + } + baseType = baseType.baseScalar; + } + + return type.name; +} + +interface ValidationConstraints { + minLength?: number; + maxLength?: number; + minValue?: number; + maxValue?: number; + pattern?: string; + format?: string; + isInteger?: boolean; + isFloat?: boolean; + isDateTime?: boolean; + dateTimeType?: string; + enumValues?: string[]; +} + +/** + * Builds a validation function for ElectroDB based on constraints. + */ +function buildValidationFunction( + constraints: ValidationConstraints, +): ((value: unknown) => void) | undefined { + const checks: string[] = []; + + // String length validation + if (constraints.minLength !== undefined) { + checks.push( + `if (typeof value === "string" && value.length < ${constraints.minLength}) throw new Error("Value must be at least ${constraints.minLength} characters")`, + ); + } + if (constraints.maxLength !== undefined) { + checks.push( + `if (typeof value === "string" && value.length > ${constraints.maxLength}) throw new Error("Value must be at most ${constraints.maxLength} characters")`, + ); + } + + // Numeric validation + if (constraints.minValue !== undefined) { + checks.push( + `if (typeof value === "number" && value < ${constraints.minValue}) throw new Error("Value must be at least ${constraints.minValue}")`, + ); + } + if (constraints.maxValue !== undefined) { + checks.push( + `if (typeof value === "number" && value > ${constraints.maxValue}) throw new Error("Value must be at most ${constraints.maxValue}")`, + ); + } + + // Integer validation + if (constraints.isInteger) { + checks.push( + `if (typeof value === "number" && !Number.isInteger(value)) throw new Error("Value must be an integer")`, + ); + } + + // Float validation (ensure it's a finite number) + if (constraints.isFloat) { + checks.push( + `if (typeof value === "number" && !Number.isFinite(value)) throw new Error("Value must be a finite number")`, + ); + } + + // Pattern validation + if (constraints.pattern) { + const escapedPattern = constraints.pattern.replace(/\\/g, "\\\\"); + checks.push( + `if (typeof value === "string" && !new RegExp("${escapedPattern}").test(value)) throw new Error("Value must match pattern ${escapedPattern}")`, + ); + } + + // DateTime validation + if (constraints.isDateTime && constraints.dateTimeType) { + switch (constraints.dateTimeType) { + case "utcDateTime": + checks.push( + `if (typeof value === "string") { const d = new Date(value); if (isNaN(d.getTime())) throw new Error("Value must be a valid UTC date-time string"); }`, + ); + break; + case "offsetDateTime": + checks.push( + `if (typeof value === "string") { const d = new Date(value); if (isNaN(d.getTime())) throw new Error("Value must be a valid offset date-time string"); }`, + ); + break; + case "plainDate": + checks.push( + `if (typeof value === "string" && !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) throw new Error("Value must be a valid date (YYYY-MM-DD)")`, + ); + break; + case "plainTime": + checks.push( + `if (typeof value === "string" && !/^\\d{2}:\\d{2}(:\\d{2})?(\\.\\d+)?$/.test(value)) throw new Error("Value must be a valid time (HH:MM:SS)")`, + ); + break; + } + } + + // Enum validation + if (constraints.enumValues && constraints.enumValues.length > 0) { + const allowedValues = JSON.stringify(constraints.enumValues); + checks.push( + `if (!${allowedValues}.includes(value)) throw new Error("Value must be one of: ${constraints.enumValues.join(", ")}")`, + ); + } + + if (checks.length === 0) { + return undefined; + } + + // Create the validation function as a string to be serialized + const functionBody = checks.join("; "); + // biome-ignore lint/security/noGlobalEval: This is safe since we control the input + return eval(`(value) => { ${functionBody} }`); +} + function emitIntrinsincScalar(type: Scalar) { switch (type.name) { case "boolean": @@ -269,6 +500,93 @@ function emitModelProperty(prop: ModelProperty): Attribute { const getLabel = (ctx: EmitContext, prop: ModelProperty) => ctx.program.stateMap(StateKeys.label).get(prop); +/** + * Extracts validation constraints from a ModelProperty and its type. + */ +function getValidationConstraints( + ctx: EmitContext, + prop: ModelProperty, +): ValidationConstraints { + const constraints: ValidationConstraints = {}; + const program = ctx.program; + + // Get constraints from the property itself + const propMinLength = getMinLength(program, prop); + const propMaxLength = getMaxLength(program, prop); + const propMinValue = getMinValue(program, prop); + const propMaxValue = getMaxValue(program, prop); + const propPattern = getPattern(program, prop); + const propFormat = getFormat(program, prop); + + if (propMinLength !== undefined) constraints.minLength = propMinLength; + if (propMaxLength !== undefined) constraints.maxLength = propMaxLength; + if (propMinValue !== undefined) constraints.minValue = propMinValue; + if (propMaxValue !== undefined) constraints.maxValue = propMaxValue; + if (propPattern !== undefined) constraints.pattern = propPattern; + if (propFormat !== undefined) constraints.format = propFormat; + + // Get constraints from the type (Scalar types may have constraints applied to them) + if (prop.type.kind === "Scalar") { + let scalarType: Scalar | undefined = prop.type; + + // Walk up the scalar hierarchy to collect constraints + while (scalarType) { + const typeMinLength = getMinLength(program, scalarType); + const typeMaxLength = getMaxLength(program, scalarType); + const typeMinValue = getMinValue(program, scalarType); + const typeMaxValue = getMaxValue(program, scalarType); + const typePattern = getPattern(program, scalarType); + const typeFormat = getFormat(program, scalarType); + + // Only set if not already set (property constraints take precedence) + if (typeMinLength !== undefined && constraints.minLength === undefined) + constraints.minLength = typeMinLength; + if (typeMaxLength !== undefined && constraints.maxLength === undefined) + constraints.maxLength = typeMaxLength; + if (typeMinValue !== undefined && constraints.minValue === undefined) + constraints.minValue = typeMinValue; + if (typeMaxValue !== undefined && constraints.maxValue === undefined) + constraints.maxValue = typeMaxValue; + if (typePattern !== undefined && constraints.pattern === undefined) + constraints.pattern = typePattern; + if (typeFormat !== undefined && constraints.format === undefined) + constraints.format = typeFormat; + + scalarType = scalarType.baseScalar; + } + + // Check if the base type requires integer or float validation + if (isIntegerType(prop.type)) { + constraints.isInteger = true; + } else if (isFloatType(prop.type)) { + constraints.isFloat = true; + } + + // Check for datetime types + if (isDateTimeType(prop.type)) { + constraints.isDateTime = true; + constraints.dateTimeType = getBaseScalarName(prop.type); + } + } + + // Check for enum types + if (prop.type.kind === "Enum") { + constraints.enumValues = Array.from(prop.type.members).map( + ([key, member]) => `${member.value ?? key}`, + ); + } + + // Check for literal unions (e.g., "home" | "work" | "other") + if (prop.type.kind === "Union") { + const literals = isLiteralUnion(prop.type); + if (literals) { + constraints.enumValues = literals; + } + } + + return constraints; +} + function emitAttribute(ctx: EmitContext, prop: ModelProperty): Attribute { const type = emitType(prop.type); @@ -318,6 +636,14 @@ function emitAttribute(ctx: EmitContext, prop: ModelProperty): Attribute { } } + // Add validation if constraints are present + const constraints = getValidationConstraints(ctx, prop); + const validateFn = buildValidationFunction(constraints); + if (validateFn) { + // @ts-expect-error - validate is a valid ElectroDB attribute property + attr.validate = validateFn; + } + return attr; } diff --git a/test/entities.test.js b/test/entities.test.js index c034cc9..a1e5b28 100644 --- a/test/entities.test.js +++ b/test/entities.test.js @@ -14,21 +14,20 @@ suite("Job Entity", () => { test("Job entity has all required attributes", () => { const attrs = Job.attributes; - assert.deepEqual(attrs.pk, { - type: "string", - required: true, - }); + // UUID scalar has @minLength(25) @maxLength(25), so it gets validation + assert.equal(attrs.pk.type, "string"); + assert.equal(attrs.pk.required, true); + assert.equal(typeof attrs.pk.validate, "function"); - assert.deepEqual(attrs.jobId, { - type: "string", - required: true, - }); + assert.equal(attrs.jobId.type, "string"); + assert.equal(attrs.jobId.required, true); + assert.equal(typeof attrs.jobId.validate, "function"); - assert.deepEqual(attrs.personId, { - type: "string", - required: true, - }); + assert.equal(attrs.personId.type, "string"); + assert.equal(attrs.personId.required, true); + assert.equal(typeof attrs.personId.validate, "function"); + // description is plain string without constraints assert.deepEqual(attrs.description, { type: "string", required: true, @@ -73,22 +72,22 @@ suite("Task Entity - Default Values", () => { }); suite("Enum default values", () => { - test("priority has enum default value", () => { - assert.deepEqual(Task.attributes.priority, { - type: ["LOW", "MEDIUM", "HIGH"], - required: true, - default: "MEDIUM", - }); + test("priority has enum default value with validation", () => { + // Enums get validation to ensure value is in allowed set + assert.deepEqual(Task.attributes.priority.type, ["LOW", "MEDIUM", "HIGH"]); + assert.equal(Task.attributes.priority.required, true); + assert.equal(Task.attributes.priority.default, "MEDIUM"); + assert.equal(typeof Task.attributes.priority.validate, "function"); }); }); suite("Number default values", () => { - test("count has number default value", () => { - assert.deepEqual(Task.attributes.count, { - type: "number", - required: true, - default: 0, - }); + test("count has number default value with integer validation", () => { + // int32 types get integer validation + assert.equal(Task.attributes.count.type, "number"); + assert.equal(Task.attributes.count.required, true); + assert.equal(Task.attributes.count.default, 0); + assert.equal(typeof Task.attributes.count.validate, "function"); }); }); @@ -133,6 +132,132 @@ suite("Task Entity - Default Values", () => { }); }); +suite("Validation Functions", () => { + suite("String length validation (UUID - @minLength(25) @maxLength(25))", () => { + test("accepts strings of exactly 25 characters", () => { + const validate = Job.attributes.pk.validate; + // Should not throw for valid length + assert.doesNotThrow(() => validate("1234567890123456789012345")); + }); + + test("rejects strings shorter than 25 characters", () => { + const validate = Job.attributes.pk.validate; + assert.throws( + () => validate("too-short"), + /Value must be at least 25 characters/ + ); + }); + + test("rejects strings longer than 25 characters", () => { + const validate = Job.attributes.pk.validate; + assert.throws( + () => validate("this-string-is-way-too-long-for-uuid"), + /Value must be at most 25 characters/ + ); + }); + }); + + suite("String maxLength validation (String64 - @maxLength(64))", () => { + test("accepts strings up to 64 characters", () => { + const validate = Person.attributes.firstName.validate; + assert.doesNotThrow(() => validate("John")); + assert.doesNotThrow(() => validate("A".repeat(64))); + }); + + test("rejects strings longer than 64 characters", () => { + const validate = Person.attributes.firstName.validate; + assert.throws( + () => validate("A".repeat(65)), + /Value must be at most 64 characters/ + ); + }); + }); + + suite("Integer validation (int16, int32)", () => { + test("accepts integer values", () => { + const validate = Person.attributes.age.validate; + assert.doesNotThrow(() => validate(25)); + assert.doesNotThrow(() => validate(0)); + assert.doesNotThrow(() => validate(-10)); + }); + + test("rejects non-integer values", () => { + const validate = Person.attributes.age.validate; + assert.throws( + () => validate(25.5), + /Value must be an integer/ + ); + assert.throws( + () => validate(3.14159), + /Value must be an integer/ + ); + }); + }); + + suite("DateTime validation (utcDateTime)", () => { + test("accepts valid ISO date strings", () => { + const validate = Person.attributes.birthDate.validate; + assert.doesNotThrow(() => validate("2023-01-15T10:30:00Z")); + assert.doesNotThrow(() => validate("2023-01-15")); + assert.doesNotThrow(() => validate("2023-01-15T10:30:00.000Z")); + }); + + test("rejects invalid date strings", () => { + const validate = Person.attributes.birthDate.validate; + assert.throws( + () => validate("not-a-date"), + /Value must be a valid UTC date-time string/ + ); + assert.throws( + () => validate("invalid"), + /Value must be a valid UTC date-time string/ + ); + }); + }); + + suite("Enum validation (Priority enum)", () => { + test("accepts valid enum values", () => { + const validate = Task.attributes.priority.validate; + assert.doesNotThrow(() => validate("LOW")); + assert.doesNotThrow(() => validate("MEDIUM")); + assert.doesNotThrow(() => validate("HIGH")); + }); + + test("rejects invalid enum values", () => { + const validate = Task.attributes.priority.validate; + assert.throws( + () => validate("INVALID"), + /Value must be one of: LOW, MEDIUM, HIGH/ + ); + assert.throws( + () => validate("low"), + /Value must be one of: LOW, MEDIUM, HIGH/ + ); + }); + }); + + suite("Enum validation with custom values (PersonStatus)", () => { + test("accepts valid enum values", () => { + const validate = Person.attributes.status.validate; + assert.doesNotThrow(() => validate("01")); + assert.doesNotThrow(() => validate("02")); + assert.doesNotThrow(() => validate("03")); + }); + + test("rejects invalid enum values", () => { + const validate = Person.attributes.status.validate; + assert.throws( + () => validate("ACTIVE"), + /Value must be one of: 01, 02, 03/ + ); + assert.throws( + () => validate("1"), + /Value must be one of: 01, 02, 03/ + ); + }); + }); +}); + suite("Person Entity", () => { test("Person entity has correct model configuration", () => { assert.deepEqual(Person.model, { @@ -143,40 +268,42 @@ suite("Person Entity", () => { }); suite("Basic Attributes", () => { - test("pk attribute is string and required", () => { - assert.deepEqual(Person.attributes.pk, { - type: "string", - required: true, - }); + test("pk attribute is string and required with validation", () => { + // UUID scalar has @minLength(25) @maxLength(25) + assert.equal(Person.attributes.pk.type, "string"); + assert.equal(Person.attributes.pk.required, true); + assert.equal(typeof Person.attributes.pk.validate, "function"); }); - test("personId attribute is string and required", () => { - assert.deepEqual(Person.attributes.personId, { - type: "string", - required: true, - }); + test("personId attribute is string and required with validation", () => { + // UUID scalar has @minLength(25) @maxLength(25) + assert.equal(Person.attributes.personId.type, "string"); + assert.equal(Person.attributes.personId.required, true); + assert.equal(typeof Person.attributes.personId.validate, "function"); }); - test("birthDate (utcDateTime) is mapped to string type", () => { - assert.deepEqual(Person.attributes.birthDate, { - type: "string", - required: true, - }); + test("birthDate (utcDateTime) is mapped to string type with datetime validation", () => { + assert.equal(Person.attributes.birthDate.type, "string"); + assert.equal(Person.attributes.birthDate.required, true); + // utcDateTime types get datetime validation + assert.equal(typeof Person.attributes.birthDate.validate, "function"); }); - test("age (int16) is mapped to number type", () => { - assert.deepEqual(Person.attributes.age, { - type: "number", - required: true, - }); + test("age (int16) is mapped to number type with integer validation", () => { + assert.equal(Person.attributes.age.type, "number"); + assert.equal(Person.attributes.age.required, true); + // int16 types get integer validation + assert.equal(typeof Person.attributes.age.validate, "function"); }); }); suite("@label decorator", () => { test("firstName has label 'fn'", () => { + // String64 has @maxLength(64) so it gets validation assert.equal(Person.attributes.firstName.label, "fn"); assert.equal(Person.attributes.firstName.type, "string"); assert.equal(Person.attributes.firstName.required, true); + assert.equal(typeof Person.attributes.firstName.validate, "function"); }); });