diff --git a/src/generator/adapters/BaseAdapter.ts b/src/generator/adapters/BaseAdapter.ts index 08858c5..cee7115 100644 --- a/src/generator/adapters/BaseAdapter.ts +++ b/src/generator/adapters/BaseAdapter.ts @@ -16,6 +16,7 @@ import seedrandom from "seedrandom"; import crypto from "crypto"; import { Faker, en } from "@faker-js/faker"; import { fieldInferenceEngine } from "../core/FieldInferenceEngine"; +import { ConstraintEngine, ColumnDependencyGraph } from "../core/ConstraintEngine"; export interface CollectionDetails { primaryKey?: string; @@ -1424,60 +1425,33 @@ export abstract class BaseAdapter { /** * Sort fields topologically based on cross-column constraints. + * Uses ColumnDependencyGraph from ConstraintEngine for cycle-safe sorting. * If B depends on A (e.g. B > A), A comes first. */ protected sortFieldsByDependency(fields: SchemaField[]): SchemaField[] { - const dependencyMap = new Map>(); + const graph = new ColumnDependencyGraph(fields); + const orderedNames = graph.getTopologicalSort(); + const nameToField = new Map(); - for (const field of fields) { nameToField.set(field.name, field); - if (!dependencyMap.has(field.name)) { - dependencyMap.set(field.name, new Set()); - } - - const c = field.constraints; - if (c) { - const deps = [c.minColumn, c.maxColumn, c.gtColumn, c.ltColumn]; - for (const dep of deps) { - if (dep) { - dependencyMap.get(field.name)!.add(dep); - } - } - } } - - const visited = new Set(); - const tempVisited = new Set(); - const sorted: SchemaField[] = []; - - const visit = (fieldName: string) => { - if (tempVisited.has(fieldName)) return; // Cyclic dependency detected, ignore - if (visited.has(fieldName)) return; - - tempVisited.add(fieldName); - - const deps = dependencyMap.get(fieldName); - if (deps) { - for (const depName of deps) { - if (nameToField.has(depName)) { - visit(depName); - } - } + + const orderedFields: SchemaField[] = []; + for (const name of orderedNames) { + const field = nameToField.get(name); + if (field) { + orderedFields.push(field); } - - tempVisited.delete(fieldName); - visited.add(fieldName); - - const f = nameToField.get(fieldName); - if (f) sorted.push(f); - }; - + } + for (const field of fields) { - visit(field.name); + if (!orderedFields.includes(field)) { + orderedFields.push(field); + } } - - return sorted; + + return orderedFields; } protected buildRelationshipMap(relationships: SchemaRelationship[]): void { diff --git a/src/generator/constraint.test.ts b/src/generator/constraint.test.ts new file mode 100644 index 0000000..990ce59 --- /dev/null +++ b/src/generator/constraint.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ColumnDependencyGraph, ConstraintEngine } from "./core/ConstraintEngine"; +import { SchemaField } from "../types/schemaDesign"; + +describe("ColumnDependencyGraph", () => { + it("should build graph from fields without dependencies", () => { + const fields: SchemaField[] = [ + { id: "1", name: "id", type: "integer", isPrimaryKey: true }, + { id: "2", name: "name", type: "string" }, + { id: "3", name: "email", type: "string" }, + ]; + const graph = new ColumnDependencyGraph(fields); + const order = graph.getTopologicalSort(); + expect(order).toContain("id"); + expect(order).toContain("name"); + expect(order).toContain("email"); + }); + + it("should detect dependencies and sort correctly", () => { + const fields: SchemaField[] = [ + { id: "1", name: "id", type: "integer", isPrimaryKey: true }, + { id: "2", name: "created_at", type: "date", constraints: {} }, + { + id: "3", + name: "updated_at", + type: "date", + constraints: { gtColumn: "created_at" } + }, + ]; + const graph = new ColumnDependencyGraph(fields); + const order = graph.getTopologicalSort(); + expect(order.indexOf("created_at")).toBeLessThan(order.indexOf("updated_at")); + }); + + it("should handle multiple dependencies", () => { + const fields: SchemaField[] = [ + { id: "1", name: "start_date", type: "date" }, + { + id: "2", + name: "end_date", + type: "date", + constraints: { gtColumn: "start_date" } + }, + { + id: "3", + name: "duration_days", + type: "integer", + constraints: { ltColumn: "end_date", maxColumn: "end_date" } + }, + ]; + const graph = new ColumnDependencyGraph(fields); + const order = graph.getTopologicalSort(); + expect(order.indexOf("start_date")).toBeLessThan(order.indexOf("end_date")); + }); + + it("should detect cycles and break them", () => { + const fields: SchemaField[] = [ + { id: "1", name: "a", type: "integer", constraints: { gtColumn: "b" } }, + { id: "2", name: "b", type: "integer", constraints: { gtColumn: "a" } }, + ]; + const graph = new ColumnDependencyGraph(fields); + expect(() => graph.getTopologicalSort()).not.toThrow(); + }); +}); + +describe("ConstraintEngine", () => { + it("should apply date constraints", () => { + const fields: SchemaField[] = [ + { id: "1", name: "created_at", type: "date" }, + { id: "2", name: "updated_at", type: "date", constraints: { gtColumn: "created_at" } }, + ]; + const engine = new ConstraintEngine(fields); + const order = engine.getEvaluationOrder(); + expect(order).toContain("created_at"); + expect(order).toContain("updated_at"); + }); + + it("should validate constraints", () => { + const fields: SchemaField[] = [ + { id: "1", name: "price", type: "number", constraints: { min: 0, max: 1000 } }, + { id: "2", name: "discount_price", type: "number", constraints: { ltColumn: "price" } }, + ]; + const engine = new ConstraintEngine(fields); + + const result = engine.validateConstraints( + "discount_price", + 50, + { price: 100 }, + ); + expect(result.valid).toBe(true); + }); + + it("should detect invalid constraint", () => { + const fields: SchemaField[] = [ + { id: "1", name: "price", type: "number", constraints: { min: 0, max: 1000 } }, + { id: "2", name: "discount_price", type: "number", constraints: { ltColumn: "price" } }, + ]; + const engine = new ConstraintEngine(fields); + + const result = engine.validateConstraints( + "discount_price", + 150, + { price: 100 }, + ); + expect(result.valid).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +describe("Cross-column constraint scenarios", () => { + it("created_at <= updated_at", () => { + const fields: SchemaField[] = [ + { id: "1", name: "created_at", type: "date" }, + { id: "2", name: "updated_at", type: "date", constraints: { gtColumn: "created_at" } }, + ]; + const graph = new ColumnDependencyGraph(fields); + const order = graph.getTopologicalSort(); + const createdIdx = order.indexOf("created_at"); + const updatedIdx = order.indexOf("updated_at"); + expect(createdIdx).toBeLessThan(updatedIdx); + }); + + it("start_date <= end_date", () => { + const fields: SchemaField[] = [ + { id: "1", name: "start_date", type: "date" }, + { id: "2", name: "end_date", type: "date", constraints: { gtColumn: "start_date" } }, + ]; + const graph = new ColumnDependencyGraph(fields); + const order = graph.getTopologicalSort(); + const startIdx = order.indexOf("start_date"); + const endIdx = order.indexOf("end_date"); + expect(startIdx).toBeLessThan(endIdx); + }); + + it("discount_price < original_price", () => { + const fields: SchemaField[] = [ + { id: "1", name: "original_price", type: "number" }, + { id: "2", name: "discount_price", type: "number", constraints: { ltColumn: "original_price" } }, + ]; + const graph = new ColumnDependencyGraph(fields); + const order = graph.getTopologicalSort(); + const originalIdx = order.indexOf("original_price"); + const discountIdx = order.indexOf("discount_price"); + expect(originalIdx).toBeLessThan(discountIdx); + }); + + it("quantity_available <= total_quantity", () => { + const fields: SchemaField[] = [ + { id: "1", name: "total_quantity", type: "integer" }, + { id: "2", name: "quantity_available", type: "integer", constraints: { ltColumn: "total_quantity" } }, + ]; + const graph = new ColumnDependencyGraph(fields); + const order = graph.getTopologicalSort(); + const totalIdx = order.indexOf("total_quantity"); + const availableIdx = order.indexOf("quantity_available"); + expect(totalIdx).toBeLessThan(availableIdx); + }); +}); \ No newline at end of file diff --git a/src/generator/core/ConstraintEngine.ts b/src/generator/core/ConstraintEngine.ts new file mode 100644 index 0000000..4e14bd5 --- /dev/null +++ b/src/generator/core/ConstraintEngine.ts @@ -0,0 +1,408 @@ +import { SchemaField, FieldConstraints } from "../../types/schemaDesign"; + +export interface FieldNode { + name: string; + field: SchemaField; + dependencies: Set; +} + +export interface DependencyEdge { + from: string; + to: string; + type: "min" | "max" | "gt" | "lt" | "eq"; +} + +export class ColumnDependencyGraph { + private nodes: Map = new Map(); + private edges: DependencyEdge[] = []; + private fieldIndex: Map = new Map(); + + constructor(fields: SchemaField[]) { + this.buildGraph(fields); + } + + private buildGraph(fields: SchemaField[]): void { + fields.forEach((field, index) => { + this.nodes.set(field.name, { + name: field.name, + field, + dependencies: new Set(), + }); + this.fieldIndex.set(field.name, index); + }); + + for (const field of fields) { + const constraints = field.constraints; + if (!constraints) continue; + + if (constraints.minColumn) { + this.addEdge(constraints.minColumn, field.name, "min"); + } + if (constraints.maxColumn) { + this.addEdge(constraints.maxColumn, field.name, "max"); + } + if (constraints.gtColumn) { + this.addEdge(constraints.gtColumn, field.name, "gt"); + } + if (constraints.ltColumn) { + this.addEdge(constraints.ltColumn, field.name, "lt"); + } + } + + this.detectCycles(); + } + + private addEdge(from: string, to: string, type: "min" | "max" | "gt" | "lt"): void { + const fromNode = this.nodes.get(from); + const toNode = this.nodes.get(to); + + if (!fromNode || !toNode) return; + + fromNode.dependencies.add(to); + this.edges.push({ from, to, type }); + } + + private detectCycles(): void { + const visited = new Set(); + const recursionStack = new Set(); + + const dfs = (node: string): boolean => { + visited.add(node); + recursionStack.add(node); + + const nodeData = this.nodes.get(node); + if (nodeData) { + for (const dep of nodeData.dependencies) { + if (!visited.has(dep)) { + if (dfs(dep)) return true; + } + if (recursionStack.has(dep)) { + console.warn(`ColumnDependencyGraph: Circular dependency detected between ${node} and ${dep}`); + return true; + } + } + } + + recursionStack.delete(node); + return false; + }; + + for (const node of this.nodes.keys()) { + if (!visited.has(node)) { + if (dfs(node)) { + this.breakCycle(node); + } + } + } + } + + private breakCycle(startNode: string): void { + const visited = new Set(); + const path: string[] = []; + + const findPath = (node: string): boolean => { + visited.add(node); + path.push(node); + + const nodeData = this.nodes.get(node); + if (nodeData) { + for (const dep of nodeData.dependencies) { + if (dep === startNode) { + return true; + } + if (!visited.has(dep)) { + if (findPath(dep)) { + return true; + } + } + } + } + + path.pop(); + return false; + }; + + findPath(startNode); + + if (path.length > 0) { + const lastNode = path[path.length - 1]; + const lastNodeData = this.nodes.get(lastNode); + if (lastNodeData && lastNodeData.dependencies.size > 0) { + const firstDep = Array.from(lastNodeData.dependencies)[0]; + lastNodeData.dependencies.delete(firstDep); + this.edges = this.edges.filter(e => !(e.from === lastNode && e.to === firstDep)); + console.log(`ColumnDependencyGraph: Broken cycle by removing edge ${lastNode} -> ${firstDep}`); + } + } + } + + getTopologicalSort(): string[] { + const inDegree = new Map(); + const result: string[] = []; + + for (const [name, node] of this.nodes) { + inDegree.set(name, 0); + } + + for (const [name, node] of this.nodes) { + for (const dep of node.dependencies) { + inDegree.set(dep, (inDegree.get(dep) || 0) + 1); + } + } + + const queue: string[] = []; + for (const [name, degree] of inDegree) { + if (degree === 0) { + queue.push(name); + } + } + + while (queue.length > 0) { + const node = queue.shift()!; + result.push(node); + + const nodeData = this.nodes.get(node); + if (nodeData) { + for (const dep of nodeData.dependencies) { + const newDegree = (inDegree.get(dep) || 0) - 1; + inDegree.set(dep, newDegree); + if (newDegree === 0) { + queue.push(dep); + } + } + } + } + + for (const name of this.nodes.keys()) { + if (!result.includes(name)) { + result.push(name); + } + } + + return result; + } + + getDependencies(fieldName: string): string[] { + const node = this.nodes.get(fieldName); + return node ? Array.from(node.dependencies) : []; + } + + hasDependency(fieldName: string): boolean { + const node = this.nodes.get(fieldName); + return node ? node.dependencies.size > 0 : false; + } + + getEdges(): DependencyEdge[] { + return [...this.edges]; + } + + getFieldType(fieldName: string): string | undefined { + return this.nodes.get(fieldName)?.field.type; + } +} + +export class ConstraintEngine { + private graph: ColumnDependencyGraph; + private fieldTypes: Map = new Map(); + private fieldConstraints: Map = new Map(); + + constructor(fields: SchemaField[]) { + this.graph = new ColumnDependencyGraph(fields); + fields.forEach(f => { + this.fieldTypes.set(f.name, f.type); + if (f.constraints) { + this.fieldConstraints.set(f.name, f.constraints); + } + }); + } + + getEvaluationOrder(): string[] { + return this.graph.getTopologicalSort(); + } + + applyConstraints( + fieldName: string, + value: unknown, + context: Record, + random: () => number, + ): unknown { + const fieldType = this.fieldTypes.get(fieldName); + if (!fieldType) return value; + + return this.applyConstraintsForType(fieldType, fieldName, value, context, random); + } + + private applyConstraintsForType( + fieldType: string, + fieldName: string, + value: unknown, + context: Record, + random: () => number, + ): unknown { + const constraints = this.getFieldConstraints(fieldName); + if (!constraints) return value; + + let result = value; + + if (fieldType === "date" || fieldType === "timestamp") { + result = this.applyDateConstraints(fieldName, result as Date, constraints, context, random); + } else if (fieldType === "integer" || fieldType === "number") { + result = this.applyNumericConstraints(fieldName, result as number, constraints, context, random); + } + + return result; + } + + private getFieldConstraints(fieldName: string): FieldConstraints | undefined { + return this.fieldConstraints.get(fieldName); + } + + private applyDateConstraints( + fieldName: string, + value: Date, + constraints: any, + context: Record, + random: () => number, + ): Date { + const dateValue = new Date(value); + + if (constraints.minColumn) { + const refValue = context[constraints.minColumn]; + if (refValue !== undefined && refValue !== null) { + const refDate = new Date(refValue as string | number | Date); + if (!isNaN(refDate.getTime()) && dateValue < refDate) { + const offset = 60 * 1000 + Math.floor(random() * 10 * 24 * 60 * 60 * 1000); + return new Date(refDate.getTime() + offset); + } + } + } + + if (constraints.gtColumn) { + const refValue = context[constraints.gtColumn]; + if (refValue !== undefined && refValue !== null) { + const refDate = new Date(refValue as string | number | Date); + if (!isNaN(refDate.getTime()) && dateValue <= refDate) { + const offset = 60 * 1000 + Math.floor(random() * 10 * 24 * 60 * 60 * 1000); + return new Date(refDate.getTime() + offset); + } + } + } + + if (constraints.maxColumn) { + const refValue = context[constraints.maxColumn]; + if (refValue !== undefined && refValue !== null) { + const refDate = new Date(refValue as string | number | Date); + if (!isNaN(refDate.getTime()) && dateValue > refDate) { + const offset = Math.floor(random() * 10 * 24 * 60 * 60 * 1000); + return new Date(refDate.getTime() - offset); + } + } + } + + if (constraints.ltColumn) { + const refValue = context[constraints.ltColumn]; + if (refValue !== undefined && refValue !== null) { + const refDate = new Date(refValue as string | number | Date); + if (!isNaN(refDate.getTime()) && dateValue >= refDate) { + const offset = Math.floor(random() * 10 * 24 * 60 * 60 * 1000); + return new Date(refDate.getTime() - offset); + } + } + } + + return dateValue; + } + + private applyNumericConstraints( + fieldName: string, + value: number, + constraints: any, + context: Record, + random: () => number, + ): number { + let numValue = Number(value); + + if (constraints.minColumn) { + const refValue = context[constraints.minColumn]; + if (refValue !== undefined && refValue !== null) { + const refNum = Number(refValue); + if (!isNaN(refNum) && numValue < refNum) { + return refNum + Math.abs(random() * 10); + } + } + } + + if (constraints.gtColumn) { + const refValue = context[constraints.gtColumn]; + if (refValue !== undefined && refValue !== null) { + const refNum = Number(refValue); + if (!isNaN(refNum) && numValue <= refNum) { + return refNum + Math.max(1, random() * 10); + } + } + } + + if (constraints.maxColumn) { + const refValue = context[constraints.maxColumn]; + if (refValue !== undefined && refValue !== null) { + const refNum = Number(refValue); + if (!isNaN(refNum) && numValue > refNum) { + return refNum - Math.abs(random() * 10); + } + } + } + + if (constraints.ltColumn) { + const refValue = context[constraints.ltColumn]; + if (refValue !== undefined && refValue !== null) { + const refNum = Number(refValue); + if (!isNaN(refNum) && numValue >= refNum) { + return refNum - Math.abs(random() * 10); + } + } + } + + return numValue; + } + + validateConstraints( + fieldName: string, + value: unknown, + context: Record, + ): { valid: boolean; error?: string } { + const constraints = this.getFieldConstraints(fieldName); + if (!constraints) return { valid: true }; + + for (const [col, type] of [["minColumn", "min"], ["maxColumn", "max"], ["gtColumn", "gt"], ["ltColumn", "lt"]] as const) { + if (constraints[col]) { + const refValue = context[constraints[col]]; + if (refValue === undefined || refValue === null) { + continue; + } + + const fieldType = this.fieldTypes.get(fieldName); + if (fieldType === "date" || fieldType === "timestamp") { + const dateValue = new Date(value as string | number | Date); + const refDate = new Date(refValue as string | number | Date); + if (isNaN(dateValue.getTime()) || isNaN(refDate.getTime())) continue; + + if (type === "min" && dateValue < refDate) return { valid: false, error: `${fieldName} must be >= ${constraints[col]}` }; + if (type === "max" && dateValue > refDate) return { valid: false, error: `${fieldName} must be <= ${constraints[col]}` }; + if (type === "gt" && dateValue <= refDate) return { valid: false, error: `${fieldName} must be > ${constraints[col]}` }; + if (type === "lt" && dateValue >= refDate) return { valid: false, error: `${fieldName} must be < ${constraints[col]}` }; + } else { + const numValue = Number(value); + const refNum = Number(refValue); + if (isNaN(numValue) || isNaN(refNum)) continue; + + if (type === "min" && numValue < refNum) return { valid: false, error: `${fieldName} must be >= ${constraints[col]}` }; + if (type === "max" && numValue > refNum) return { valid: false, error: `${fieldName} must be <= ${constraints[col]}` }; + if (type === "gt" && numValue <= refNum) return { valid: false, error: `${fieldName} must be > ${constraints[col]}` }; + if (type === "lt" && numValue >= refNum) return { valid: false, error: `${fieldName} must be < ${constraints[col]}` }; + } + } + } + + return { valid: true }; + } +} \ No newline at end of file