test: add Phase 3 SQL render tests for all dialects

Cover getRenderer().renderDDL across MySQL, MariaDB, PostgreSQL, SQLite,
Oracle and SQL Server. A shared DatabaseType fixture (users + posts with a
primary key, auto-increment, NOT NULL UNIQUE column with a length, DEFAULT
and a posts to users foreign key) is rendered per dialect, asserting the
emitted DDL creates both tables and columns and carries the primary key,
unique constraint, dialect-specific auto-increment spelling (AUTO_INCREMENT,
SERIAL, AUTOINCREMENT, IDENTITY), variable-length text type, DEFAULT value
and the foreign key with ON DELETE CASCADE.

The fixture is built from the seed data types so field type ids hydrate the
way the app hydrates them, and embeds relationship source/target objects
because the SQLite renderer orders tables from the raw database before the
migration step re-hydrates. Assertions match quote-agnostic patterns rather
than exact strings, since identifier quoting and formatting differ by dialect.
This commit is contained in:
Alberto Arena
2026-08-05 07:26:31 +02:00
parent 585f5c7a7f
commit 91fe5d6cf2
2 changed files with 289 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
import { getDataTypes } from "./data-types";
import { DatabaseDialect } from "@/lib/database";
import { ForeignKeyActions } from "@/lib/field";
import { DataType } from "@/lib/schemas/data-type-schema";
import { DatabaseType } from "@/lib/schemas/database-schema";
import { FieldType } from "@/lib/schemas/field-schema";
import { IndexType } from "@/lib/schemas/index-schema";
import { TableType } from "@/lib/schemas/table-schema";
import {
Cardinality,
RelationshipType,
} from "@/lib/schemas/relationship-schema";
// Pick a data type by preferred name, falling back to a category predicate.
// The seed arrays list types in dialect-specific order, so selecting by
// category alone would grab e.g. CHAR before VARCHAR or TINYINT before INTEGER;
// naming the canonical type keeps the rendered DDL representative per dialect.
const pick = (
types: DataType[],
names: string[],
fallback: (t: DataType) => boolean,
): DataType => {
for (const name of names) {
const match = types.find((t) => t.name?.toLowerCase() === name);
if (match) return match;
}
return types.find(fallback) as DataType;
};
// Resolve a representative type per category for a dialect, straight from the
// seed data types, so the fixture uses real type ids the renderer can hydrate.
const resolveTypes = (dialect: DatabaseDialect) => {
const types = getDataTypes(dialect);
const integer = pick(
types,
["integer", "int", "number"],
(t) => t.type === "integer" || t.type === "numeric",
);
const varchar = pick(
types,
["varchar", "varchar2", "nvarchar", "character varying"],
(t) => t.type === "text",
);
return { integer, varchar };
};
const field = (over: Partial<FieldType>): FieldType =>
({
isPrimary: false,
nullable: true,
unique: false,
autoIncrement: false,
sequence: 0,
...over,
}) as FieldType;
/**
* A minimal but representative two-table schema (users, posts) with a
* primary key, an auto-increment column, a NOT NULL UNIQUE text column with a
* length, a DEFAULT, and a posts -> users foreign key. Ids are stable strings
* so assertions and failures are readable. Relationship source/target objects
* are left for prepareForMigration/optimizeOps to hydrate from the ids, exactly
* as the app does before rendering.
*/
export const buildSampleDatabase = (dialect: DatabaseDialect): DatabaseType => {
const { integer, varchar } = resolveTypes(dialect);
const usersFields: FieldType[] = [
field({
id: "users.id",
tableId: "users",
name: "id",
typeId: integer.id,
isPrimary: true,
nullable: false,
autoIncrement: true,
}),
field({
id: "users.email",
tableId: "users",
name: "email",
typeId: varchar.id,
nullable: false,
unique: true,
maxLength: 255,
}),
field({
id: "users.status",
tableId: "users",
name: "status",
typeId: varchar.id,
nullable: false,
maxLength: 20,
defaultValue: "active",
}),
];
const postsFields: FieldType[] = [
field({
id: "posts.id",
tableId: "posts",
name: "id",
typeId: integer.id,
isPrimary: true,
nullable: false,
autoIncrement: true,
}),
field({
id: "posts.user_id",
tableId: "posts",
name: "user_id",
typeId: integer.id,
nullable: false,
}),
field({
id: "posts.title",
tableId: "posts",
name: "title",
typeId: varchar.id,
nullable: false,
maxLength: 255,
}),
];
const users = {
id: "users",
name: "users",
fields: usersFields,
indices: [] as IndexType[],
sequence: 0,
} as TableType;
const posts = {
id: "posts",
name: "posts",
fields: postsFields,
indices: [] as IndexType[],
sequence: 1,
} as TableType;
const tables: TableType[] = [users, posts];
// Embed the source/target table and field objects the way the app's data
// layer hands them to the renderer. The SQLite renderer orders tables up
// front by reading relationship.sourceTable / targetTable off the original
// database (before prepareForMigration re-hydrates), so these must be present.
const relationships: RelationshipType[] = [
{
id: "rel_posts_users",
name: null,
sourceTableId: "users",
targetTableId: "posts",
sourceFieldId: "users.id",
targetFieldId: "posts.user_id",
sourceTable: users,
targetTable: posts,
sourceField: usersFields[0],
targetField: postsFields[1],
cardinality: Cardinality.one_to_many,
onDelete: ForeignKeyActions.CASCADE,
databaseId: "db",
} as RelationshipType,
];
return {
id: "db",
name: "testdb",
dialect,
numOfTables: tables.length,
createdAt: null,
tables,
relationships,
} as DatabaseType;
};
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from "vitest";
import { DatabaseDialect } from "@/lib/database";
import { getRenderer } from "@/utils/render/render-uttils";
import { getDataTypes } from "@/test/fixtures/data-types";
import { buildSampleDatabase } from "@/test/fixtures/database";
// getRenderer().renderDDL() takes a DatabaseType and emits dialect DDL. It runs
// the migration diff (empty database -> the fixture), so the output is the full
// CREATE for every table plus the foreign key. Assertions match on substrings
// and quote-agnostic patterns rather than exact strings, since formatting and
// identifier quoting differ per dialect and are not what these tests pin down.
interface RenderCase {
name: string;
dialect: DatabaseDialect;
// Dialect-specific spelling of an auto-increment / identity column.
autoIncrement: RegExp;
// Dialect-specific spelling of a variable-length string type.
varchar: RegExp;
}
const cases: RenderCase[] = [
{
name: "MySQL",
dialect: DatabaseDialect.MYSQL,
autoIncrement: /AUTO_INCREMENT/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
{
name: "MariaDB",
dialect: DatabaseDialect.MARIADB,
autoIncrement: /AUTO_INCREMENT/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
{
name: "PostgreSQL",
dialect: DatabaseDialect.POSTGRES,
// auto-increment integers become SERIAL in Postgres
autoIncrement: /SERIAL/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
{
name: "SQLite",
dialect: DatabaseDialect.SQLITE,
autoIncrement: /AUTOINCREMENT/,
// SQLite has no VARCHAR; the text column renders as TEXT
varchar: /TEXT\s*\(\s*255\s*\)/i,
},
{
name: "Oracle",
dialect: DatabaseDialect.ORACLE,
autoIncrement: /IDENTITY/,
varchar: /VARCHAR2\s*\(\s*255\s*\)/i,
},
{
name: "SQL Server",
dialect: DatabaseDialect.MSSQL,
autoIncrement: /IDENTITY/,
varchar: /VARCHAR\s*\(\s*255\s*\)/i,
},
];
const render = (dialect: DatabaseDialect): Promise<string> =>
getRenderer(dialect, getDataTypes(dialect))!.renderDDL(
buildSampleDatabase(dialect),
);
describe("getRenderer().renderDDL - emitted DDL across dialects", () => {
for (const c of cases) {
describe(c.name, () => {
it("creates both tables with their columns", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/CREATE TABLE\s+[`"]?users[`"]?/i);
expect(sql).toMatch(/CREATE TABLE\s+[`"]?posts[`"]?/i);
for (const col of ["id", "email", "status", "user_id", "title"]) {
expect(sql).toContain(col);
}
});
it("emits primary key, unique and auto-increment", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/PRIMARY KEY/i);
expect(sql).toMatch(/UNIQUE/i);
expect(sql).toMatch(c.autoIncrement);
});
it("emits the text column type with its length", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(c.varchar);
});
it("emits the DEFAULT value", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/DEFAULT\s+'active'/i);
});
it("emits the foreign key from posts to users with ON DELETE CASCADE", async () => {
const sql = await render(c.dialect);
expect(sql).toMatch(/FOREIGN KEY\s*\(\s*user_id\s*\)/i);
expect(sql).toMatch(/REFERENCES\s+[`"]?users[`"]?\s*\(\s*id\s*\)/i);
expect(sql).toMatch(/ON DELETE CASCADE/i);
});
});
}
});