diff --git a/src/generator/core/constraints/ConstraintRegistry.ts b/src/generator/core/constraints/ConstraintRegistry.ts index 6459b7d..a1962f0 100644 --- a/src/generator/core/constraints/ConstraintRegistry.ts +++ b/src/generator/core/constraints/ConstraintRegistry.ts @@ -11,6 +11,8 @@ import { ConstraintSeverity, } from "./types"; +import { SchemaField } from "../../../types/schemaDesign"; + export interface RegistryOptions { defaultMode?: ConstraintMode; maxRetries?: number; @@ -23,6 +25,7 @@ export class ConstraintRegistry { private documentConstraints: ConstraintValidator[] = []; private options: Required; private priority: Map = new Map(); + private evaluationOrder: string[] | null = null; constructor(options: RegistryOptions = {}) { this.options = { @@ -115,8 +118,15 @@ export class ConstraintRegistry { random: context?.random ?? (() => Math.random()), ...context, }; + + if (!this.evaluationOrder) { + this.resolveEvaluationOrder(Object.keys(document)); + } + + const fieldOrder = this.evaluationOrder || Object.keys(document); - for (const fieldName of Object.keys(document)) { + for (const fieldName of fieldOrder) { + if (document[fieldName] === undefined) continue; const fieldConstraints = this.getFieldConstraints(fieldName); const sortedConstraints = this.sortByPriority( fieldName, @@ -260,6 +270,35 @@ export class ConstraintRegistry { }); } + private resolveEvaluationOrder(fieldNames: string[]): void { + const adj = new Map>(); + const inDegree = new Map(); + + for (const name of fieldNames) { + adj.set(name, new Set()); + inDegree.set(name, 0); + } + + // Inspect validators to find dependencies + for (const [fieldName, validators] of this.fieldConstraints) { + for (const validator of validators) { + // This is a bit heuristic, but we can look for 'targetField' or 'comparisonField' in validator metadata/config + // Since we don't have direct access to the source config here easily unless we store it, + // we might need to rely on the register method passing dependencies. + // For now, let's assume we can detect it from some standard property if we added it. + // OR we can just use the priority system plus a simple check. + } + } + + // For Week 1, we will stick to priority-based sorting but enhance sortByPriority + // In a future update, we can implement a full topological sort if we store dependencies per validator. + this.evaluationOrder = [...fieldNames].sort((a, b) => { + const aP = this.priority.get(a) ?? 0; + const bP = this.priority.get(b) ?? 0; + return bP - aP; + }); + } + private applyCorrections( document: Record, violations: ValidationResult[] @@ -274,10 +313,10 @@ export class ConstraintRegistry { } fromSchemaFields( - fields: Array<{ + fields: SchemaField[] | Array<{ name: string; type: string; - constraints?: Record; + constraints?: any; required?: boolean; }> ): ConstraintRegistry { @@ -403,8 +442,24 @@ export class ConstraintRegistry { this.register(`${field.name}:cross_column`, crossColValidator); this.registerFieldConstraint(field.name, crossColValidator); } + + if (constraints.temporal) { + const temporal = constraints.temporal as any; + const { createTemporalValidator } = require("./validators/temporalValidators"); + const validator = createTemporalValidator(field.name, temporal); + this.register(`${field.name}:temporal`, validator); + this.registerFieldConstraint(field.name, validator); + } + + if (constraints.mutuallyExclusive && Array.isArray(constraints.mutuallyExclusive)) { + const { createMutuallyExclusiveValidator } = require("./validators/crossColumnValidators"); + const validator = createMutuallyExclusiveValidator(field.name, constraints.mutuallyExclusive); + this.register(`${field.name}:mutually_exclusive`, validator); + this.registerFieldConstraint(field.name, validator); + } } + this.evaluationOrder = null; // Reset order when schema changes return this; } diff --git a/src/generator/core/constraints/validators/Validators.test.ts b/src/generator/core/constraints/validators/Validators.test.ts new file mode 100644 index 0000000..2304b97 --- /dev/null +++ b/src/generator/core/constraints/validators/Validators.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { createTemporalValidator } from "./temporalValidators"; +import { createMutuallyExclusiveValidator } from "./crossColumnValidators"; + +describe("New Constraint Validators", () => { + describe("createTemporalValidator", () => { + it("should validate 'before' constraint", () => { + const validator = createTemporalValidator("createdAt", { + operator: "before", + targetField: "expiresAt" + }); + + const beforeDate = new Date("2023-01-01"); + const afterDate = new Date("2023-12-31"); + + const result = validator.validate(beforeDate, { + document: { createdAt: beforeDate, expiresAt: afterDate }, + random: () => 0.5 + }); + + expect(result.valid).toBe(true); + + const invalidResult = validator.validate(afterDate, { + document: { createdAt: afterDate, expiresAt: beforeDate }, + random: () => 0.5 + }); + expect(invalidResult.valid).toBe(false); + }); + + it("should validate 'after' constraint", () => { + const validator = createTemporalValidator("updatedAt", { + operator: "after", + targetField: "createdAt" + }); + + const createdAt = new Date("2023-01-01"); + const updatedAt = new Date("2023-01-02"); + + const result = validator.validate(updatedAt, { + document: { createdAt, updatedAt }, + random: () => 0.5 + }); + + expect(result.valid).toBe(true); + }); + + it("should validate 'within_days' constraint", () => { + const validator = createTemporalValidator("endDate", { + operator: "within_days", + targetField: "startDate", + value: 30 + }); + + const startDate = new Date("2023-01-01"); + const validEndDate = new Date("2023-01-15"); + const invalidEndDate = new Date("2023-02-15"); + + expect(validator.validate(validEndDate, { + document: { startDate, endDate: validEndDate }, + random: () => 0.5 + }).valid).toBe(true); + + expect(validator.validate(invalidEndDate, { + document: { startDate, endDate: invalidEndDate }, + random: () => 0.5 + }).valid).toBe(false); + }); + + it("should validate 'older_than' constraint", () => { + const validator = createTemporalValidator("birthDate", { + operator: "older_than", + value: 18 + }); + + const now = new Date(); + const eighteenYearsAgo = new Date(now.getFullYear() - 18, now.getMonth(), now.getDate()); + const twentyYearsAgo = new Date(now.getFullYear() - 20, now.getMonth(), now.getDate()); + const tenYearsAgo = new Date(now.getFullYear() - 10, now.getMonth(), now.getDate()); + + expect(validator.validate(twentyYearsAgo, { + document: { birthDate: twentyYearsAgo }, + random: () => 0.5 + }).valid).toBe(true); + + expect(validator.validate(tenYearsAgo, { + document: { birthDate: tenYearsAgo }, + random: () => 0.5 + }).valid).toBe(false); + }); + }); + + describe("createMutuallyExclusiveValidator", () => { + it("should validate that only one field is set", () => { + const validator = createMutuallyExclusiveValidator("fieldA", ["fieldA", "fieldB", "fieldC"]); + + // Case 1: Only fieldA is set + expect(validator.validate("val", { + document: { fieldA: "val" }, + random: () => 0.5 + }).valid).toBe(true); + + // Case 2: fieldA and fieldB are set + expect(validator.validate("val", { + document: { fieldA: "val", fieldB: "val2" }, + random: () => 0.5 + }).valid).toBe(false); + + // Case 3: None are set (fieldA is undefined) + expect(validator.validate(undefined, { + document: {}, + random: () => 0.5 + }).valid).toBe(true); + }); + }); +}); diff --git a/src/generator/core/constraints/validators/crossColumnValidators.ts b/src/generator/core/constraints/validators/crossColumnValidators.ts index b178428..df9d52a 100644 --- a/src/generator/core/constraints/validators/crossColumnValidators.ts +++ b/src/generator/core/constraints/validators/crossColumnValidators.ts @@ -309,3 +309,42 @@ export function createConditionalValidator( }, }; } + +export function createMutuallyExclusiveValidator( + fieldName: string, + otherFields: string[] +): ConstraintValidator { + return { + metadata: { + name: `${fieldName}:mutually_exclusive`, + type: "cross_column", + severity: "error", + description: `${fieldName} is mutually exclusive with [${otherFields.join(", ")}]`, + }, + validate(value: unknown, context: ConstraintContext): ValidationResult { + if (!context.document) return { valid: true }; + + const isSet = value !== undefined && value !== null; + if (!isSet) return { valid: true }; + + const conflicts = otherFields.filter( + (f) => f !== fieldName && context.document![f] !== undefined && context.document![f] !== null + ); + + if (conflicts.length > 0) { + return { + valid: false, + value, + expected: "only one field should be set", + actual: `conflicts with ${conflicts.join(", ")}`, + errorMessage: `${fieldName} cannot be set when [${conflicts.join( + ", " + )}] are also set`, + retryable: true, + }; + } + + return { valid: true }; + }, + }; +} diff --git a/src/generator/core/constraints/validators/index.ts b/src/generator/core/constraints/validators/index.ts index c1c3dc8..e16d877 100644 --- a/src/generator/core/constraints/validators/index.ts +++ b/src/generator/core/constraints/validators/index.ts @@ -1,2 +1,3 @@ export * from "./fieldValidators"; export * from "./crossColumnValidators"; +export * from "./temporalValidators"; diff --git a/src/generator/core/constraints/validators/temporalValidators.ts b/src/generator/core/constraints/validators/temporalValidators.ts new file mode 100644 index 0000000..e8f3c79 --- /dev/null +++ b/src/generator/core/constraints/validators/temporalValidators.ts @@ -0,0 +1,105 @@ +import { + ConstraintValidator, + ConstraintContext, + ValidationResult, +} from "../types"; + +export interface TemporalValidatorOptions { + operator: "before" | "after" | "within_days" | "older_than"; + targetField?: string; + referenceDate?: Date | string; + value?: number; // Days or Years depending on operator +} + +export function createTemporalValidator( + fieldName: string, + options: TemporalValidatorOptions +): ConstraintValidator { + const { operator, targetField, referenceDate, value: temporalValue } = options; + + return { + metadata: { + name: `${fieldName}:temporal:${operator}`, + type: "temporal", + severity: "error", + description: `Field ${fieldName} must be ${operator}${ + targetField ? ` ${targetField}` : referenceDate ? ` ${referenceDate}` : "" + }${temporalValue ? ` (${temporalValue})` : ""}`, + }, + validate(value: any, context: ConstraintContext): ValidationResult { + if (value === null || value === undefined) return { valid: true }; + + const dateValue = new Date(value); + if (isNaN(dateValue.getTime())) { + return { + valid: false, + value, + errorMessage: `${fieldName} must be a valid date`, + }; + } + + let reference: Date; + if (targetField && context.document) { + const compValue = context.document[targetField]; + if (compValue === null || compValue === undefined) return { valid: true }; + reference = new Date(compValue as any); + if (isNaN(reference.getTime())) return { valid: true }; + } else if (referenceDate) { + reference = new Date(referenceDate); + } else { + reference = new Date(); // Default to now if nothing specified + } + + let valid = true; + let actualVal = dateValue.toISOString(); + let expectedVal = ""; + + const diffMs = dateValue.getTime() - reference.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + console.log(`Debug Temporal: op=${operator}, val=${dateValue.toISOString()}, ref=${reference.toISOString()}, diffDays=${diffDays}`); + + switch (operator) { + case "before": + valid = dateValue.getTime() < reference.getTime(); + expectedVal = `before ${reference.toISOString()}`; + break; + case "after": + valid = dateValue.getTime() > reference.getTime(); + expectedVal = `after ${reference.toISOString()}`; + break; + case "within_days": + if (temporalValue === undefined) return { valid: true }; + valid = Math.abs(diffDays) <= temporalValue; + expectedVal = `within ${temporalValue} days of ${reference.toISOString()}`; + break; + case "older_than": + if (temporalValue === undefined) return { valid: true }; + const ageDate = new Date(reference.getTime()); + ageDate.setFullYear(reference.getFullYear() - temporalValue); + + if (isNaN(ageDate.getTime())) { + valid = true; // Fallback + expectedVal = "valid date context"; + } else { + valid = dateValue.getTime() <= ageDate.getTime(); + expectedVal = `older than ${temporalValue} years (before ${ageDate.toISOString()})`; + } + break; + } + + if (!valid) { + return { + valid: false, + value, + expected: expectedVal, + actual: actualVal, + errorMessage: `${fieldName} (${actualVal}) failed temporal constraint ${operator} ${expectedVal}`, + retryable: true, + }; + } + + return { valid: true }; + }, + }; +} diff --git a/src/generator/index.ts b/src/generator/index.ts index cf700d6..647929a 100644 --- a/src/generator/index.ts +++ b/src/generator/index.ts @@ -22,6 +22,7 @@ export { DynamoDBAdapter } from "./adapters/DynamoDBAdapter"; export { SQLServerAdapter } from "./adapters/SQLServerAdapter"; export { RedisAdapter } from "./adapters/RedisAdapter"; export { DependencyGraph } from "./core/DependencyGraph"; +import { ConstraintRegistry } from "./core/constraints/ConstraintRegistry"; import { logger } from "../utils"; import type { TestDataConfig, @@ -282,9 +283,36 @@ export class TestDataGeneratorService { colConfig.count, ); + // Phase 2: Constraint Validation Integration + const registry = new ConstraintRegistry().fromSchemaFields(schemaCol.fields); + + async function* validatedStream() { + for await (const doc of docStream) { + const results = registry.validateDocument(doc.data); + if (results.length > 0) { + // Heuristic: automatically try to correct common errors if they provide 'expected' + const correctedData = { ...doc.data }; + let fixed = false; + for (const res of results) { + if (res.fieldName && res.expected !== undefined) { + correctedData[res.fieldName] = res.expected; + fixed = true; + } + } + if (fixed) { + yield { ...doc, data: correctedData }; + } else { + yield doc; + } + } else { + yield doc; + } + } + } + const ids = await this.adapter.writeBatchStream( fullName, - docStream, + validatedStream(), config.batchSize, allowedReferenceFields, schemaCol.fields,