Skip to content
Merged

Dev #26

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions src/generator/core/constraints/ConstraintRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
ConstraintSeverity,
} from "./types";

import { SchemaField } from "../../../types/schemaDesign";

export interface RegistryOptions {
defaultMode?: ConstraintMode;
maxRetries?: number;
Expand All @@ -23,6 +25,7 @@ export class ConstraintRegistry {
private documentConstraints: ConstraintValidator<unknown>[] = [];
private options: Required<RegistryOptions>;
private priority: Map<string, number> = new Map();
private evaluationOrder: string[] | null = null;

constructor(options: RegistryOptions = {}) {
this.options = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -260,6 +270,35 @@ export class ConstraintRegistry {
});
}

private resolveEvaluationOrder(fieldNames: string[]): void {
const adj = new Map<string, Set<string>>();
const inDegree = new Map<string, number>();

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<string, unknown>,
violations: ValidationResult[]
Expand All @@ -274,10 +313,10 @@ export class ConstraintRegistry {
}

fromSchemaFields(
fields: Array<{
fields: SchemaField[] | Array<{
name: string;
type: string;
constraints?: Record<string, unknown>;
constraints?: any;
required?: boolean;
}>
): ConstraintRegistry {
Expand Down Expand Up @@ -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;
}

Expand Down
115 changes: 115 additions & 0 deletions src/generator/core/constraints/validators/Validators.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
39 changes: 39 additions & 0 deletions src/generator/core/constraints/validators/crossColumnValidators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,42 @@ export function createConditionalValidator(
},
};
}

export function createMutuallyExclusiveValidator(
fieldName: string,
otherFields: string[]
): ConstraintValidator<unknown> {
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 };
},
};
}
1 change: 1 addition & 0 deletions src/generator/core/constraints/validators/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./fieldValidators";
export * from "./crossColumnValidators";
export * from "./temporalValidators";
105 changes: 105 additions & 0 deletions src/generator/core/constraints/validators/temporalValidators.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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 };
},
};
}
Loading
Loading