Skip to content

Commit 6e82dba

Browse files
authored
Feat(#27): add input validation (#28)
1 parent d61facb commit 6e82dba

File tree

2 files changed

+497
-44
lines changed

2 files changed

+497
-44
lines changed

src/emitter.ts

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import type {
1313
import {
1414
type EmitContext,
1515
emitFile,
16+
getFormat,
17+
getMaxLength,
18+
getMaxValue,
19+
getMinLength,
20+
getMinValue,
21+
getPattern,
1622
resolvePath,
1723
walkPropertiesInherited,
1824
} from "@typespec/compiler";
@@ -45,6 +51,231 @@ function extractDefaultValue(
4551
}
4652
}
4753

54+
/**
55+
* Determines if a scalar type is an integer type.
56+
*/
57+
function isIntegerType(type: Scalar): boolean {
58+
const integerTypes = [
59+
"integer",
60+
"int64",
61+
"int32",
62+
"int16",
63+
"int8",
64+
"uint64",
65+
"uint32",
66+
"uint16",
67+
"uint8",
68+
"safeint",
69+
];
70+
71+
// Check the type itself first
72+
if (integerTypes.includes(type.name)) {
73+
return true;
74+
}
75+
76+
// Walk up the base scalar chain
77+
let baseType = type.baseScalar;
78+
while (baseType) {
79+
if (integerTypes.includes(baseType.name)) {
80+
return true;
81+
}
82+
baseType = baseType.baseScalar;
83+
}
84+
85+
return false;
86+
}
87+
88+
/**
89+
* Determines if a scalar type is a float type.
90+
*/
91+
function isFloatType(type: Scalar): boolean {
92+
const floatTypes = ["float", "float32", "float64", "decimal", "decimal128"];
93+
94+
// Check the type itself first
95+
if (floatTypes.includes(type.name)) {
96+
return true;
97+
}
98+
99+
// Walk up the base scalar chain
100+
let baseType = type.baseScalar;
101+
while (baseType) {
102+
if (floatTypes.includes(baseType.name)) {
103+
return true;
104+
}
105+
baseType = baseType.baseScalar;
106+
}
107+
108+
return false;
109+
}
110+
111+
/**
112+
* Determines if a scalar type is a date/time type.
113+
*/
114+
function isDateTimeType(type: Scalar): boolean {
115+
const dateTimeTypes = [
116+
"utcDateTime",
117+
"offsetDateTime",
118+
"plainDate",
119+
"plainTime",
120+
];
121+
122+
// Check the type itself first
123+
if (dateTimeTypes.includes(type.name)) {
124+
return true;
125+
}
126+
127+
// Walk up the base scalar chain
128+
let baseType = type.baseScalar;
129+
while (baseType) {
130+
if (dateTimeTypes.includes(baseType.name)) {
131+
return true;
132+
}
133+
baseType = baseType.baseScalar;
134+
}
135+
136+
return false;
137+
}
138+
139+
/**
140+
* Gets the base scalar name for a type (for datetime type identification).
141+
*/
142+
function getBaseScalarName(type: Scalar): string {
143+
const dateTimeTypes = [
144+
"utcDateTime",
145+
"offsetDateTime",
146+
"plainDate",
147+
"plainTime",
148+
];
149+
150+
// Check the type itself first
151+
if (dateTimeTypes.includes(type.name)) {
152+
return type.name;
153+
}
154+
155+
// Walk up the base scalar chain to find a datetime type
156+
let baseType = type.baseScalar;
157+
while (baseType) {
158+
if (dateTimeTypes.includes(baseType.name)) {
159+
return baseType.name;
160+
}
161+
baseType = baseType.baseScalar;
162+
}
163+
164+
return type.name;
165+
}
166+
167+
interface ValidationConstraints {
168+
minLength?: number;
169+
maxLength?: number;
170+
minValue?: number;
171+
maxValue?: number;
172+
pattern?: string;
173+
format?: string;
174+
isInteger?: boolean;
175+
isFloat?: boolean;
176+
isDateTime?: boolean;
177+
dateTimeType?: string;
178+
enumValues?: string[];
179+
}
180+
181+
/**
182+
* Builds a validation function for ElectroDB based on constraints.
183+
*/
184+
function buildValidationFunction(
185+
constraints: ValidationConstraints,
186+
): ((value: unknown) => void) | undefined {
187+
const checks: string[] = [];
188+
189+
// String length validation
190+
if (constraints.minLength !== undefined) {
191+
checks.push(
192+
`if (typeof value === "string" && value.length < ${constraints.minLength}) throw new Error("Value must be at least ${constraints.minLength} characters")`,
193+
);
194+
}
195+
if (constraints.maxLength !== undefined) {
196+
checks.push(
197+
`if (typeof value === "string" && value.length > ${constraints.maxLength}) throw new Error("Value must be at most ${constraints.maxLength} characters")`,
198+
);
199+
}
200+
201+
// Numeric validation
202+
if (constraints.minValue !== undefined) {
203+
checks.push(
204+
`if (typeof value === "number" && value < ${constraints.minValue}) throw new Error("Value must be at least ${constraints.minValue}")`,
205+
);
206+
}
207+
if (constraints.maxValue !== undefined) {
208+
checks.push(
209+
`if (typeof value === "number" && value > ${constraints.maxValue}) throw new Error("Value must be at most ${constraints.maxValue}")`,
210+
);
211+
}
212+
213+
// Integer validation
214+
if (constraints.isInteger) {
215+
checks.push(
216+
`if (typeof value === "number" && !Number.isInteger(value)) throw new Error("Value must be an integer")`,
217+
);
218+
}
219+
220+
// Float validation (ensure it's a finite number)
221+
if (constraints.isFloat) {
222+
checks.push(
223+
`if (typeof value === "number" && !Number.isFinite(value)) throw new Error("Value must be a finite number")`,
224+
);
225+
}
226+
227+
// Pattern validation
228+
if (constraints.pattern) {
229+
const escapedPattern = constraints.pattern.replace(/\\/g, "\\\\");
230+
checks.push(
231+
`if (typeof value === "string" && !new RegExp("${escapedPattern}").test(value)) throw new Error("Value must match pattern ${escapedPattern}")`,
232+
);
233+
}
234+
235+
// DateTime validation
236+
if (constraints.isDateTime && constraints.dateTimeType) {
237+
switch (constraints.dateTimeType) {
238+
case "utcDateTime":
239+
checks.push(
240+
`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"); }`,
241+
);
242+
break;
243+
case "offsetDateTime":
244+
checks.push(
245+
`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"); }`,
246+
);
247+
break;
248+
case "plainDate":
249+
checks.push(
250+
`if (typeof value === "string" && !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) throw new Error("Value must be a valid date (YYYY-MM-DD)")`,
251+
);
252+
break;
253+
case "plainTime":
254+
checks.push(
255+
`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)")`,
256+
);
257+
break;
258+
}
259+
}
260+
261+
// Enum validation
262+
if (constraints.enumValues && constraints.enumValues.length > 0) {
263+
const allowedValues = JSON.stringify(constraints.enumValues);
264+
checks.push(
265+
`if (!${allowedValues}.includes(value)) throw new Error("Value must be one of: ${constraints.enumValues.join(", ")}")`,
266+
);
267+
}
268+
269+
if (checks.length === 0) {
270+
return undefined;
271+
}
272+
273+
// Create the validation function as a string to be serialized
274+
const functionBody = checks.join("; ");
275+
// biome-ignore lint/security/noGlobalEval: This is safe since we control the input
276+
return eval(`(value) => { ${functionBody} }`);
277+
}
278+
48279
function emitIntrinsincScalar(type: Scalar) {
49280
switch (type.name) {
50281
case "boolean":
@@ -269,6 +500,93 @@ function emitModelProperty(prop: ModelProperty): Attribute {
269500
const getLabel = (ctx: EmitContext, prop: ModelProperty) =>
270501
ctx.program.stateMap(StateKeys.label).get(prop);
271502

503+
/**
504+
* Extracts validation constraints from a ModelProperty and its type.
505+
*/
506+
function getValidationConstraints(
507+
ctx: EmitContext,
508+
prop: ModelProperty,
509+
): ValidationConstraints {
510+
const constraints: ValidationConstraints = {};
511+
const program = ctx.program;
512+
513+
// Get constraints from the property itself
514+
const propMinLength = getMinLength(program, prop);
515+
const propMaxLength = getMaxLength(program, prop);
516+
const propMinValue = getMinValue(program, prop);
517+
const propMaxValue = getMaxValue(program, prop);
518+
const propPattern = getPattern(program, prop);
519+
const propFormat = getFormat(program, prop);
520+
521+
if (propMinLength !== undefined) constraints.minLength = propMinLength;
522+
if (propMaxLength !== undefined) constraints.maxLength = propMaxLength;
523+
if (propMinValue !== undefined) constraints.minValue = propMinValue;
524+
if (propMaxValue !== undefined) constraints.maxValue = propMaxValue;
525+
if (propPattern !== undefined) constraints.pattern = propPattern;
526+
if (propFormat !== undefined) constraints.format = propFormat;
527+
528+
// Get constraints from the type (Scalar types may have constraints applied to them)
529+
if (prop.type.kind === "Scalar") {
530+
let scalarType: Scalar | undefined = prop.type;
531+
532+
// Walk up the scalar hierarchy to collect constraints
533+
while (scalarType) {
534+
const typeMinLength = getMinLength(program, scalarType);
535+
const typeMaxLength = getMaxLength(program, scalarType);
536+
const typeMinValue = getMinValue(program, scalarType);
537+
const typeMaxValue = getMaxValue(program, scalarType);
538+
const typePattern = getPattern(program, scalarType);
539+
const typeFormat = getFormat(program, scalarType);
540+
541+
// Only set if not already set (property constraints take precedence)
542+
if (typeMinLength !== undefined && constraints.minLength === undefined)
543+
constraints.minLength = typeMinLength;
544+
if (typeMaxLength !== undefined && constraints.maxLength === undefined)
545+
constraints.maxLength = typeMaxLength;
546+
if (typeMinValue !== undefined && constraints.minValue === undefined)
547+
constraints.minValue = typeMinValue;
548+
if (typeMaxValue !== undefined && constraints.maxValue === undefined)
549+
constraints.maxValue = typeMaxValue;
550+
if (typePattern !== undefined && constraints.pattern === undefined)
551+
constraints.pattern = typePattern;
552+
if (typeFormat !== undefined && constraints.format === undefined)
553+
constraints.format = typeFormat;
554+
555+
scalarType = scalarType.baseScalar;
556+
}
557+
558+
// Check if the base type requires integer or float validation
559+
if (isIntegerType(prop.type)) {
560+
constraints.isInteger = true;
561+
} else if (isFloatType(prop.type)) {
562+
constraints.isFloat = true;
563+
}
564+
565+
// Check for datetime types
566+
if (isDateTimeType(prop.type)) {
567+
constraints.isDateTime = true;
568+
constraints.dateTimeType = getBaseScalarName(prop.type);
569+
}
570+
}
571+
572+
// Check for enum types
573+
if (prop.type.kind === "Enum") {
574+
constraints.enumValues = Array.from(prop.type.members).map(
575+
([key, member]) => `${member.value ?? key}`,
576+
);
577+
}
578+
579+
// Check for literal unions (e.g., "home" | "work" | "other")
580+
if (prop.type.kind === "Union") {
581+
const literals = isLiteralUnion(prop.type);
582+
if (literals) {
583+
constraints.enumValues = literals;
584+
}
585+
}
586+
587+
return constraints;
588+
}
589+
272590
function emitAttribute(ctx: EmitContext, prop: ModelProperty): Attribute {
273591
const type = emitType(prop.type);
274592

@@ -318,6 +636,14 @@ function emitAttribute(ctx: EmitContext, prop: ModelProperty): Attribute {
318636
}
319637
}
320638

639+
// Add validation if constraints are present
640+
const constraints = getValidationConstraints(ctx, prop);
641+
const validateFn = buildValidationFunction(constraints);
642+
if (validateFn) {
643+
// @ts-expect-error - validate is a valid ElectroDB attribute property
644+
attr.validate = validateFn;
645+
}
646+
321647
return attr;
322648
}
323649

0 commit comments

Comments
 (0)