mirror of
https://github.com/stackrender/stackrender.git
synced 2026-09-10 03:05:42 +00:00
test: add Phase 2 SQL import tests for all dialects
Cover getImporter().parseSql across MySQL, MariaDB, PostgreSQL, SQLite, Oracle and SQL Server. Each dialect gets a representative CREATE TABLE + foreign key + index fixture, asserting table and column counts, primary key, NOT NULL, UNIQUE and DEFAULT parsing, data-type mapping, and the captured foreign key (direction, cardinality and ON DELETE action). Also parse the bundled PostgreSQL dump as a realistic case and verify malformed input surfaces errors without dropping valid tables or throwing. Data-type fixtures are built from the seed arrays via the same transform the app uses in seedDataTypes, so tests run without booting the WASM SQLite database. The parser is a WASM module whose init() is async; the importer constructor does not await it, so the suite awaits init() once in beforeAll, after which every synchronous parseSql call resolves.
This commit is contained in:
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
import { DatabaseDialect } from "@/lib/database";
|
||||
import { DataInsertType, DataType } from "@/lib/schemas/data-type-schema";
|
||||
import { MysqlDataType } from "@/lib/data_types/mysql_data_types";
|
||||
import { PostgresDataType } from "@/lib/data_types/postgres_data_types";
|
||||
import { SqliteDataTypes } from "@/lib/data_types/sqlite_data_types";
|
||||
import { MariaDbDataType } from "@/lib/data_types/mariadb_data_types";
|
||||
import { OracleDataType } from "@/lib/data_types/oracle_data_types";
|
||||
import { MSSQLDataType } from "@/lib/data_types/mssql_data_types";
|
||||
|
||||
// Build a DataType[] fixture straight from the seed arrays, applying the exact
|
||||
// same transform as seedDataTypes() -> mapToDataType() in the app (see
|
||||
// src/lib/data_types/seed_datatypes.ts). The importer reads `modifiers` and
|
||||
// `synonyms` as JSON strings, so they must be stringified here just like the
|
||||
// production seeding does. This lets the tests exercise the real supported-type
|
||||
// tables without booting the WASM SQLite database.
|
||||
const mapToDataType = (
|
||||
dataTypes: Partial<DataInsertType>[],
|
||||
dialect: DatabaseDialect,
|
||||
): DataType[] =>
|
||||
dataTypes.map(
|
||||
(dataType) =>
|
||||
({
|
||||
...dataType,
|
||||
dialect,
|
||||
modifiers: dataType.modifiers
|
||||
? JSON.stringify(dataType.modifiers)
|
||||
: null,
|
||||
synonyms: dataType.synonyms ? JSON.stringify(dataType.synonyms) : null,
|
||||
}) as DataType,
|
||||
);
|
||||
|
||||
const dataTypesByDialect: Record<DatabaseDialect, DataType[]> = {
|
||||
[DatabaseDialect.MYSQL]: mapToDataType(MysqlDataType, DatabaseDialect.MYSQL),
|
||||
[DatabaseDialect.POSTGRES]: mapToDataType(
|
||||
PostgresDataType,
|
||||
DatabaseDialect.POSTGRES,
|
||||
),
|
||||
[DatabaseDialect.SQLITE]: mapToDataType(
|
||||
SqliteDataTypes,
|
||||
DatabaseDialect.SQLITE,
|
||||
),
|
||||
[DatabaseDialect.MARIADB]: mapToDataType(
|
||||
MariaDbDataType,
|
||||
DatabaseDialect.MARIADB,
|
||||
),
|
||||
[DatabaseDialect.ORACLE]: mapToDataType(
|
||||
OracleDataType,
|
||||
DatabaseDialect.ORACLE,
|
||||
),
|
||||
[DatabaseDialect.MSSQL]: mapToDataType(MSSQLDataType, DatabaseDialect.MSSQL),
|
||||
};
|
||||
|
||||
export const getDataTypes = (dialect: DatabaseDialect): DataType[] =>
|
||||
dataTypesByDialect[dialect];
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { init } from "@guanmingchiu/sqlparser-ts";
|
||||
|
||||
import { DatabaseDialect } from "@/lib/database";
|
||||
import { getImporter } from "@/utils/import/import-utils";
|
||||
import { getDataTypes } from "@/test/fixtures/data-types";
|
||||
import { PostgresSqlExample } from "@/lib/import/import_db";
|
||||
import { TableInsertType } from "@/lib/schemas/table-schema";
|
||||
import { FieldInsertType } from "@/lib/schemas/field-schema";
|
||||
|
||||
// The parser is a WASM module whose init() is async. BaseSqlImporter's
|
||||
// constructor kicks it off but does not await it (fine in the browser, where
|
||||
// the promise resolves long before a user imports SQL). In tests we must await
|
||||
// it once up front; init() caches the module globally, so every subsequent
|
||||
// synchronous parseSql() call across all dialects then works.
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
});
|
||||
|
||||
const getField = (table: TableInsertType, name: string): FieldInsertType =>
|
||||
(table.fields ?? []).find((f) => f.name === name) as FieldInsertType;
|
||||
|
||||
interface DialectCase {
|
||||
name: string;
|
||||
dialect: DatabaseDialect;
|
||||
sql: string;
|
||||
// MySQL / MariaDB / SQL Server surface AUTO_INCREMENT / IDENTITY; the others do not.
|
||||
idAutoIncrement: boolean;
|
||||
// SQLite uses TEXT (no length); the rest carry a VARCHAR length.
|
||||
varcharMaxLength: number | undefined;
|
||||
// Only the MySQL and PostgreSQL fixtures include a CREATE INDEX statement.
|
||||
indexCount: number;
|
||||
// Only the fixtures that spell out ON DELETE CASCADE carry an onDelete action.
|
||||
fkOnDelete: string | undefined;
|
||||
}
|
||||
|
||||
const cases: DialectCase[] = [
|
||||
{
|
||||
name: "MySQL",
|
||||
dialect: DatabaseDialect.MYSQL,
|
||||
idAutoIncrement: true,
|
||||
varcharMaxLength: 255,
|
||||
indexCount: 1,
|
||||
fkOnDelete: "cascade",
|
||||
sql: `
|
||||
CREATE TABLE users (
|
||||
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
CREATE TABLE posts (
|
||||
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_posts_user_id ON posts (user_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "MariaDB",
|
||||
dialect: DatabaseDialect.MARIADB,
|
||||
idAutoIncrement: true,
|
||||
varcharMaxLength: 255,
|
||||
indexCount: 0,
|
||||
fkOnDelete: "cascade",
|
||||
sql: `
|
||||
CREATE TABLE users (
|
||||
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
CREATE TABLE posts (
|
||||
id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "PostgreSQL",
|
||||
dialect: DatabaseDialect.POSTGRES,
|
||||
idAutoIncrement: false,
|
||||
varcharMaxLength: 255,
|
||||
indexCount: 1,
|
||||
fkOnDelete: "cascade",
|
||||
sql: `
|
||||
CREATE TABLE users (
|
||||
id integer PRIMARY KEY,
|
||||
email varchar(255) NOT NULL UNIQUE,
|
||||
status varchar(20) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
CREATE TABLE posts (
|
||||
id integer PRIMARY KEY,
|
||||
user_id integer NOT NULL,
|
||||
title varchar(255) NOT NULL,
|
||||
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_posts_user_id ON posts (user_id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "SQLite",
|
||||
dialect: DatabaseDialect.SQLITE,
|
||||
idAutoIncrement: false,
|
||||
varcharMaxLength: undefined,
|
||||
indexCount: 0,
|
||||
fkOnDelete: "cascade",
|
||||
sql: `
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
);
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Oracle",
|
||||
dialect: DatabaseDialect.ORACLE,
|
||||
idAutoIncrement: false,
|
||||
varcharMaxLength: 255,
|
||||
indexCount: 0,
|
||||
fkOnDelete: undefined,
|
||||
sql: `
|
||||
CREATE TABLE users (
|
||||
id NUMBER PRIMARY KEY,
|
||||
email VARCHAR2(255) NOT NULL UNIQUE,
|
||||
status VARCHAR2(20) DEFAULT 'active' NOT NULL
|
||||
);
|
||||
CREATE TABLE posts (
|
||||
id NUMBER PRIMARY KEY,
|
||||
user_id NUMBER NOT NULL,
|
||||
title VARCHAR2(255) NOT NULL,
|
||||
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "SQL Server",
|
||||
dialect: DatabaseDialect.MSSQL,
|
||||
idAutoIncrement: true,
|
||||
varcharMaxLength: 255,
|
||||
indexCount: 0,
|
||||
fkOnDelete: undefined,
|
||||
sql: `
|
||||
CREATE TABLE users (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
email NVARCHAR(255) NOT NULL UNIQUE,
|
||||
status NVARCHAR(20) NOT NULL DEFAULT 'active'
|
||||
);
|
||||
CREATE TABLE posts (
|
||||
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
title NVARCHAR(255) NOT NULL,
|
||||
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
describe("getImporter().parseSql - CREATE TABLE across dialects", () => {
|
||||
for (const c of cases) {
|
||||
describe(c.name, () => {
|
||||
const parse = () =>
|
||||
getImporter(c.dialect, getDataTypes(c.dialect)).parseSql(c.sql);
|
||||
const typeById = new Map(
|
||||
getDataTypes(c.dialect).map((dt) => [dt.id, dt]),
|
||||
);
|
||||
|
||||
it("parses both tables with their columns and no errors", () => {
|
||||
const r = parse();
|
||||
|
||||
expect(r.errors).toHaveLength(0);
|
||||
expect(r.tables.map((t) => t.name).sort()).toEqual(["posts", "users"]);
|
||||
const users = r.tables.find(
|
||||
(t) => t.name === "users",
|
||||
) as TableInsertType;
|
||||
const posts = r.tables.find(
|
||||
(t) => t.name === "posts",
|
||||
) as TableInsertType;
|
||||
|
||||
expect(users.fields).toHaveLength(3);
|
||||
expect(posts.fields).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("maps the primary key column", () => {
|
||||
const users = parse().tables.find(
|
||||
(t) => t.name === "users",
|
||||
) as TableInsertType;
|
||||
const id = getField(users, "id");
|
||||
|
||||
expect(id.isPrimary).toBe(true);
|
||||
expect(id.nullable).toBe(false);
|
||||
expect(id.autoIncrement).toBe(c.idAutoIncrement);
|
||||
// id resolves to a numeric family type (integer, or numeric for Oracle NUMBER)
|
||||
expect(["integer", "numeric"]).toContain(
|
||||
typeById.get(id.typeId as string)?.type,
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a NOT NULL UNIQUE text column with its length", () => {
|
||||
const users = parse().tables.find(
|
||||
(t) => t.name === "users",
|
||||
) as TableInsertType;
|
||||
const email = getField(users, "email");
|
||||
|
||||
expect(email.unique).toBe(true);
|
||||
expect(email.nullable).toBe(false);
|
||||
expect(email.isPrimary).toBe(false);
|
||||
expect(typeById.get(email.typeId as string)?.type).toBe("text");
|
||||
expect(email.maxLength ?? undefined).toBe(c.varcharMaxLength);
|
||||
});
|
||||
|
||||
it("parses the DEFAULT value", () => {
|
||||
const users = parse().tables.find(
|
||||
(t) => t.name === "users",
|
||||
) as TableInsertType;
|
||||
const status = getField(users, "status");
|
||||
|
||||
expect(status.defaultValue).toBe("active");
|
||||
expect(status.nullable).toBe(false);
|
||||
});
|
||||
|
||||
it("captures the foreign key from posts to users", () => {
|
||||
const r = parse();
|
||||
const byName = new Map(r.tables.map((t) => [t.name, t.id]));
|
||||
|
||||
expect(r.relationships).toHaveLength(1);
|
||||
const rel = r.relationships[0];
|
||||
|
||||
// convention: source is the referenced (parent) table, target holds the FK
|
||||
expect(rel.sourceTableId).toBe(byName.get("users"));
|
||||
expect(rel.targetTableId).toBe(byName.get("posts"));
|
||||
expect(rel.cardinality).toBe("one_to_many");
|
||||
expect(rel.onDelete).toBe(c.fkOnDelete);
|
||||
});
|
||||
|
||||
it("parses CREATE INDEX statements", () => {
|
||||
const r = parse();
|
||||
|
||||
expect(r.indexes).toHaveLength(c.indexCount);
|
||||
if (c.indexCount > 0) {
|
||||
const posts = r.tables.find(
|
||||
(t) => t.name === "posts",
|
||||
) as TableInsertType;
|
||||
|
||||
expect(r.indexes[0].name).toBe("idx_posts_user_id");
|
||||
expect(r.indexes[0].tableId).toBe(posts.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("getImporter().parseSql - realistic and malformed input", () => {
|
||||
it("parses the bundled PostgreSQL dump", () => {
|
||||
const r = getImporter(
|
||||
DatabaseDialect.POSTGRES,
|
||||
getDataTypes(DatabaseDialect.POSTGRES),
|
||||
).parseSql(PostgresSqlExample);
|
||||
|
||||
expect(r.tables.length).toBe(4);
|
||||
expect(r.relationships.length).toBe(3);
|
||||
expect(r.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collects errors from a malformed statement without dropping valid tables", () => {
|
||||
const r = getImporter(
|
||||
DatabaseDialect.POSTGRES,
|
||||
getDataTypes(DatabaseDialect.POSTGRES),
|
||||
).parseSql(
|
||||
"CREATE TABLE good (id integer PRIMARY KEY); CREATE TABLE bad (;",
|
||||
);
|
||||
|
||||
expect(r.tables.map((t) => t.name)).toContain("good");
|
||||
expect(r.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("throws when there is nothing parseable", () => {
|
||||
const importer = getImporter(
|
||||
DatabaseDialect.POSTGRES,
|
||||
getDataTypes(DatabaseDialect.POSTGRES),
|
||||
);
|
||||
|
||||
expect(() => importer.parseSql("this is not sql at all")).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user