Skip to content
Merged
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
62 changes: 18 additions & 44 deletions src/generator/adapters/BaseAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, Set<string>>();
const graph = new ColumnDependencyGraph(fields);
const orderedNames = graph.getTopologicalSort();

const nameToField = new Map<string, SchemaField>();

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<string>();
const tempVisited = new Set<string>();
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 {
Expand Down
158 changes: 158 additions & 0 deletions src/generator/constraint.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading