mirror of
https://github.com/stackrender/stackrender.git
synced 2026-09-10 03:05:42 +00:00
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:
Vendored
+178
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user