diff --git a/src/expression.ts b/src/expression.ts index 5b39d34..d5a825a 100644 --- a/src/expression.ts +++ b/src/expression.ts @@ -3,6 +3,7 @@ import type { RowLike } from "./query/values"; import type { Any } from "./types"; import type { OrderBySpec } from "./query/order-by"; import { compileOrderBy } from "./query/order-by"; +import { escapeLiteral } from "pg"; export class QueryAlias { constructor(public name: string) {} @@ -12,14 +13,17 @@ export class Context { // The list of tables in the current context (including aliases for subqueries) public namespace: Map; public usedAliases: Set; + // Whether we are in a DDL context (e.g., CREATE TABLE) where query parameters are not allowed: + public inDdl: boolean; - private constructor(namespace: Map) { + private constructor(namespace: Map, inDdl: boolean) { this.namespace = namespace; this.usedAliases = new Set(namespace.values()); + this.inDdl = inDdl; } - static new() { - return new Context(new Map()); + static new(opts?: { inDdl?: boolean }) { + return new Context(new Map(), opts?.inDdl ?? false); } withReference(ref: string) { @@ -28,7 +32,7 @@ export class Context { } const newNamespace = new Map(this.namespace); newNamespace.set(new QueryAlias(ref), ref); - return new Context(newNamespace); + return new Context(newNamespace, this.inDdl); } withAliases(aliases: QueryAlias[]) { @@ -56,7 +60,7 @@ export class Context { newNamespace.set(alias, aliasName); } - return new Context(newNamespace); + return new Context(newNamespace, this.inDdl); } getAlias(alias: QueryAlias): string { @@ -95,8 +99,9 @@ export class LiteralExpression extends Expression { super(); } - compile() { - return sql`cast(${this.value} as ${sql.raw(this.type)})`; + compile(ctx: Context) { + const value = ctx.inDdl ? sql.raw(escapeLiteral(String(this.value))) : this.value; + return sql`cast(${value} as ${sql.raw(this.type)})`; } } @@ -105,8 +110,9 @@ export class LiteralUnknownExpression extends Expression { super(); } - compile() { - return sql`${this.value}`; + compile(ctx: Context) { + const value = ctx.inDdl ? sql.raw(escapeLiteral(String(this.value))) : this.value; + return sql`${value}`; } } diff --git a/src/gen/gen.ts b/src/gen/gen.ts index 7ffdac4..2ce586d 100644 --- a/src/gen/gen.ts +++ b/src/gen/gen.ts @@ -270,15 +270,18 @@ const main = async () => { await output.write(`export class ${className} extends AnynonarrayBase {\n`); if (isInstantiatable) { + const stripTypes = (t: string) => t.split(".").slice(1); // remove "Types." prefix await output.write( - ` static new(v: SerializeParam): ${asType(type, { - nullable: false, - })};\n`, + ` static new(v: SerializeParam): ${stripTypes( + asType(type, { + nullable: false, + }), + )};\n`, ); - await output.write(` static new(v: null): ${asType(type, { nullable: true })};\n`); - await output.write(` static new(v: Expression): ${asType(type)};\n`); + await output.write(` static new(v: null): ${stripTypes(asType(type, { nullable: true }))};\n`); + await output.write(` static new(v: Expression): ${stripTypes(asType(type))};\n`); await output.write( - ` static new(v: SerializeParam | null | Expression): ${asType(type)} { return new ${asType(type)}(v); }\n`, + ` static new(v: SerializeParam | null | Expression): ${stripTypes(asType(type))} { return new this(v); }\n`, ); if (hasParser) { diff --git a/src/grammar/create-table.test.ts b/src/grammar/create-table.test.ts new file mode 100644 index 0000000..8aafda7 --- /dev/null +++ b/src/grammar/create-table.test.ts @@ -0,0 +1,1052 @@ +import { describe, it, expect } from "vitest"; +import { createTable, column, TableOpts } from "./create-table"; +import { Int4, Text, Bool, Numeric, Timestamp, Uuid, Jsonb } from "../types"; +import { dummyDb, withDb } from "../test/db"; +import { testDb } from "../db.test"; +import { sql } from "kysely"; + +describe("CREATE TABLE parser", () => { + describe("basic table creation", () => { + it("should compile a simple table with basic columns", () => { + const table = createTable( + class Users { + id = column(Int4, { primaryKey: true }); + name = column(Text, { notNull: true }); + email = column(Text); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "Users" ("id" int4 PRIMARY KEY, "name" text NOT NULL, "email" text)'); + expect(result.parameters).toEqual([]); + }); + + it("should use tableName property when provided", () => { + const table = createTable( + class UserTable { + static tableName = "custom_users"; + id = column(Int4); + name = column(Text); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "custom_users" ("id" int4, "name" text)'); + }); + + it("should handle multiple data types", () => { + const table = createTable( + class MixedTypes { + intCol = column(Int4); + textCol = column(Text); + boolCol = column(Bool); + numericCol = column(Numeric); + timestampCol = column(Timestamp); + uuidCol = column(Uuid); + jsonCol = column(Jsonb); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "MixedTypes" ("intCol" int4, "textCol" text, "boolCol" bool, "numericCol" numeric, "timestampCol" timestamp, "uuidCol" uuid, "jsonCol" jsonb)', + ); + }); + }); + + describe("column constraints", () => { + it("should compile NULL constraint", () => { + const table = createTable( + class TableWithNull { + id = column(Int4, { null: true }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithNull" ("id" int4 NULL)'); + }); + + it("should compile NOT NULL constraint", () => { + const table = createTable( + class TableWithNotNull { + id = column(Int4, { notNull: true }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithNotNull" ("id" int4 NOT NULL)'); + }); + + it("should compile PRIMARY KEY constraint", () => { + const table = createTable( + class TableWithPK { + id = column(Int4, { primaryKey: true }); + name = column(Text); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithPK" ("id" int4 PRIMARY KEY, "name" text)'); + }); + + it("should compile UNIQUE constraint", () => { + const table = createTable( + class TableWithUnique { + email = column(Text, { unique: true }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithUnique" ("email" text UNIQUE)'); + }); + + it("should compile DEFAULT constraint", () => { + const table = createTable( + class TableWithDefault { + status = column(Text, { default: Text.new("active") }); + count = column(Int4, { default: Int4.new(0) }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithDefault" ("status" text DEFAULT cast(\'active\' as text), "count" int4 DEFAULT cast(\'0\' as int4))', + ); + expect(result.parameters).toEqual([]); + }); + + it("should compile CHECK constraint", () => { + const table = createTable( + class TableWithCheck { + age = column(Int4, { check: [(): Bool<0 | 1> => this.age["<="](100)] }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithCheck" ("age" int4 CHECK (("age" <= \'100\')))'); + expect(result.parameters).toEqual([]); + }); + + it("should compile CHECK constraint with NO INHERIT", () => { + const table = createTable( + class TableWithCheckNoInherit { + age = column(Int4, { check: [(): Bool<0 | 1> => this.age["<"](150), { noInherit: true }] as const }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithCheckNoInherit" ("age" int4 CHECK (("age" < \'150\')) NO INHERIT)', + ); + expect(result.parameters).toEqual([]); + }); + + it("should compile GENERATED ALWAYS AS STORED", () => { + const table = createTable( + class TableWithGenerated { + first_name = column(Text); + last_name = column(Text); + full_name = column(Text, { + generatedAlwaysAs: [() => this.first_name.concat(Text.new(" "), this.last_name), { stored: true }], + }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithGenerated" ("first_name" text, "last_name" text, "full_name" text GENERATED ALWAYS AS (concat("first_name", cast(\' \' as text), "last_name")) STORED)', + ); + expect(result.parameters).toEqual([]); + }); + + it("should compile GENERATED ALWAYS AS IDENTITY", () => { + const table = createTable( + class TableWithIdentity { + id = column(Int4, { generatedAlwaysAsIdentity: true }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithIdentity" ("id" int4 GENERATED ALWAYS AS IDENTITY)'); + }); + + it("should compile GENERATED BY DEFAULT AS IDENTITY", () => { + const table = createTable( + class TableWithDefaultIdentity { + id = column(Int4, { generatedByDefaultAsIdentity: true }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithDefaultIdentity" ("id" int4 GENERATED BY DEFAULT AS IDENTITY)'); + }); + + it("should compile REFERENCES constraint with all options", () => { + const table = createTable( + class TableWithReferences { + user_id = column(Int4, { + references: [ + sql`users`, + sql`id`, + { match: "full", onDelete: { cascade: true }, onUpdate: { restrict: true } }, + ], + }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithReferences" ("user_id" int4 REFERENCES users (id) MATCH FULL ON DELETE CASCADE ON UPDATE RESTRICT)', + ); + }); + + it("should compile REFERENCES with SET NULL actions", () => { + const table = createTable( + class TableWithSetNull { + user_id = column(Int4, { + references: [sql`users`, sql`id`, { onDelete: { setNull: true }, onUpdate: { setNull: true } }], + }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithSetNull" ("user_id" int4 REFERENCES users (id) ON DELETE SET NULL ON UPDATE SET NULL)', + ); + }); + + it("should compile REFERENCES with SET DEFAULT actions", () => { + const table = createTable( + class TableWithSetDefault { + user_id = column(Int4, { + references: [sql`users`, sql`id`, { onDelete: { setDefault: true }, onUpdate: { setDefault: true } }], + }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithSetDefault" ("user_id" int4 REFERENCES users (id) ON DELETE SET DEFAULT ON UPDATE SET DEFAULT)', + ); + }); + + it("should compile multiple constraints on single column", () => { + const table = createTable( + class TableWithMultipleConstraints { + email = column( + Text, + { notNull: true, unique: true, check: [(): Bool<0 | 1> => this.email.length().lt(50)] }, + { check: [() => Bool.new(true)] }, + ); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithMultipleConstraints" ("email" text NOT NULL CHECK ((length("email") < \'50\')) UNIQUE CHECK (cast(\'true\' as bool)))', + ); + expect(result.parameters).toEqual([]); + }); + + it("should compile named constraints", () => { + const table = createTable( + class TableWithNamedConstraints { + age = column( + Int4, + {}, + { + constraint: sql`age_check`, + check: [(): Bool<0 | 1> => this.age["<="](150)], + }, + ); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithNamedConstraints" ("age" int4 CONSTRAINT age_check CHECK (("age" <= \'150\')))', + ); + expect(result.parameters).toEqual([]); + }); + + it("should compile DEFERRABLE constraints", () => { + const table = createTable( + class TableWithDeferrable { + email = column( + Text, + {}, + { + unique: true, + deferrable: true, + initially: "deferred", + }, + ); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithDeferrable" ("email" text UNIQUE DEFERRABLE INITIALLY DEFERRED)'); + }); + + it("should compile NOT DEFERRABLE constraints", () => { + const table = createTable( + class TableWithNotDeferrable { + email = column( + Text, + {}, + { + unique: true, + notDeferrable: true, + initially: "immediate", + }, + ); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithNotDeferrable" ("email" text UNIQUE NOT DEFERRABLE INITIALLY IMMEDIATE)', + ); + }); + }); + + describe("column options", () => { + it("should compile STORAGE options", () => { + const table = createTable( + class TableWithStorage { + col1 = column(Text, { storage: "plain" }); + col2 = column(Text, { storage: "external" }); + col3 = column(Text, { storage: "extended" }); + col4 = column(Text, { storage: "main" }); + col5 = column(Text, { storage: "default" }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithStorage" ("col1" text STORAGE PLAIN, "col2" text STORAGE EXTERNAL, "col3" text STORAGE EXTENDED, "col4" text STORAGE MAIN, "col5" text STORAGE DEFAULT)', + ); + }); + + it("should compile COMPRESSION options", () => { + const table = createTable( + class TableWithCompression { + col1 = column(Text, { compression: "default" }); + col2 = column(Text, { compression: sql`pglz` }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithCompression" ("col1" text COMPRESSION DEFAULT, "col2" text COMPRESSION pglz)', + ); + }); + + it("should compile COLLATE options", () => { + const table = createTable( + class TableWithCollate { + name = column(Text, { collate: sql`en_US` }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithCollate" ("name" text COLLATE en_US)'); + }); + + it("should compile all column options together", () => { + const table = createTable( + class TableWithAllOptions { + data = column(Text, { + storage: "extended", + compression: sql`pglz`, + collate: sql`C`, + }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithAllOptions" ("data" text STORAGE EXTENDED COMPRESSION pglz COLLATE C)', + ); + }); + }); + + describe("table constraints", () => { + it("should compile table-level CHECK constraint", () => { + const table = createTable( + class TableWithTableCheck { + static opts(t: TableWithTableCheck): TableOpts { + return { tableConstraints: [{ check: [() => t.col1.gt(5)] }] }; + } + col1 = column(Int4); + col2 = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithTableCheck" ("col1" int4, "col2" int4, CHECK (("col1" > \'5\')))', + ); + expect(result.parameters).toEqual([]); + }); + + it("should compile table-level UNIQUE constraint", () => { + const table = createTable( + class TableWithTableUnique { + static opts(): TableOpts { + return { + tableConstraints: [{ unique: ["col1", "col2"] as ["col1", "col2"] }], + }; + } + col1 = column(Int4); + col2 = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithTableUnique" ("col1" int4, "col2" int4, UNIQUE ("col1", "col2"))', + ); + }); + + it("should compile table-level UNIQUE with NULLS DISTINCT", () => { + const table = createTable( + class TableWithUniqueNulls { + static opts(): TableOpts { + return { + tableConstraints: [{ unique: [["col1"], { nulls: "distinct" }] as [["col1"], { nulls: "distinct" }] }], + }; + } + col1 = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE "TableWithUniqueNulls" ("col1" int4, UNIQUE ("col1") NULLS DISTINCT)'); + }); + + it("should compile table-level UNIQUE with NULLS NOT DISTINCT", () => { + const table = createTable( + class TableWithUniqueNullsNot { + static opts(): TableOpts { + return { + tableConstraints: [ + { unique: [["col1"], { nulls: "notDistinct" }] as [["col1"], { nulls: "notDistinct" }] }, + ], + }; + } + col1 = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithUniqueNullsNot" ("col1" int4, UNIQUE ("col1") NULLS NOT DISTINCT)', + ); + }); + + it("should compile table-level PRIMARY KEY constraint", () => { + const table = createTable( + class TableWithTablePK { + static opts(): TableOpts { + return { + tableConstraints: [{ primaryKey: ["col1", "col2"] as ["col1", "col2"] }], + }; + } + col1 = column(Int4); + col2 = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithTablePK" ("col1" int4, "col2" int4, PRIMARY KEY ("col1", "col2"))', + ); + }); + + it("should compile table-level FOREIGN KEY constraint", () => { + const table = createTable( + class TableWithTableFK { + static opts(): TableOpts { + return { + tableConstraints: [ + { + foreignKey: [ + [sql.ref("user_id")], + { + references: [sql`users`, [sql`id`]], + match: "simple", + onDelete: { cascade: true }, + onUpdate: { restrict: true }, + }, + ], + }, + ], + }; + } + user_id = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithTableFK" ("user_id" int4, FOREIGN KEY ("user_id") REFERENCES users (id) MATCH SIMPLE ON DELETE CASCADE ON UPDATE RESTRICT)', + ); + }); + + it("should compile named table constraints", () => { + const table = createTable( + class TableWithNamedTableConstraints { + static opts(): TableOpts { + return { + tableConstraints: [{ constraint: sql`unique_combo`, unique: ["col1", "col2"] }], + }; + } + col1 = column(Int4); + col2 = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithNamedTableConstraints" ("col1" int4, "col2" int4, CONSTRAINT unique_combo UNIQUE ("col1", "col2"))', + ); + }); + + it("should compile multiple table constraints", () => { + const table = createTable( + class TableWithMultipleTableConstraints { + static opts(): TableOpts { + return { + tableConstraints: [ + { primaryKey: ["id"] as ["id"] }, + { unique: ["email"] as ["email"] }, + { check: [() => Bool.new(true)] }, + ], + }; + } + id = column(Int4); + email = column(Text); + age = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "TableWithMultipleTableConstraints" ("id" int4, "email" text, "age" int4, PRIMARY KEY ("id"), UNIQUE ("email"), CHECK (cast(\'true\' as bool)))', + ); + expect(result.parameters).toEqual([]); + }); + }); + + describe("CREATE TABLE options", () => { + it("should compile IF NOT EXISTS", () => { + const table = createTable( + class SimpleTable { + id = column(Int4); + }, + { ifNotExists: true }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TABLE IF NOT EXISTS "SimpleTable" ("id" int4)'); + }); + + it("should compile TEMPORARY table", () => { + const table = createTable( + class TempTable { + static opts(): TableOpts { + return { temporary: true }; + } + id = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TEMPORARY TABLE "TempTable" ("id" int4)'); + }); + + it("should compile UNLOGGED table", () => { + const table = createTable( + class UnloggedTable { + static opts(): TableOpts { + return { unlogged: true }; + } + id = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE UNLOGGED TABLE "UnloggedTable" ("id" int4)'); + }); + + it("should compile TEMPORARY and IF NOT EXISTS together", () => { + const table = createTable( + class TempTable { + static opts(): TableOpts { + return { temporary: true }; + } + id = column(Int4); + }, + { ifNotExists: true }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe('CREATE TEMPORARY TABLE IF NOT EXISTS "TempTable" ("id" int4)'); + }); + + it("should compile TEMPORARY and UNLOGGED together", () => { + const table = createTable( + class TempUnloggedTable { + static opts(): TableOpts { + return { temporary: true, unlogged: true }; + } + id = column(Int4); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + // Both TEMPORARY and UNLOGGED should appear + expect(result.sql).toBe('CREATE TEMPORARY UNLOGGED TABLE "TempUnloggedTable" ("id" int4)'); + }); + }); + + describe("complex scenarios", () => { + it("should compile a realistic users table", () => { + const table = createTable( + class Users { + id = column(Int4, { primaryKey: true, generatedAlwaysAsIdentity: true }); + username = column(Text, { notNull: true, unique: true }); + email = column(Text, { notNull: true, unique: true }); + password_hash = column(Text, { notNull: true }); + created_at = column(Timestamp, { notNull: true, default: Timestamp.new("CURRENT_TIMESTAMP") }); + updated_at = column(Timestamp); + is_active = column(Bool, { notNull: true, default: Bool.new(true) }); + }, + { ifNotExists: true }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE IF NOT EXISTS "Users" ("id" int4 GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "username" text NOT NULL UNIQUE, "email" text NOT NULL UNIQUE, "password_hash" text NOT NULL, "created_at" timestamp NOT NULL DEFAULT cast(\'CURRENT_TIMESTAMP\' as timestamp), "updated_at" timestamp, "is_active" bool NOT NULL DEFAULT cast(\'true\' as bool))', + ); + expect(result.parameters).toEqual([]); + }); + + it("should compile a table with composite keys and foreign keys", () => { + const table = createTable( + class OrderItems { + static opts(): TableOpts { + return { + tableConstraints: [ + { primaryKey: ["order_id", "product_id"] as ["order_id", "product_id"] }, + { + foreignKey: [ + [sql.ref("order_id")], + { references: [sql`orders`, [sql`id`]], onDelete: { cascade: true } }, + ], + }, + { + foreignKey: [ + [sql.ref("product_id")], + { references: [sql`products`, [sql`id`]], onDelete: { restrict: true } }, + ], + }, + ], + }; + } + order_id = column(Int4, { notNull: true }); + product_id = column(Int4, { notNull: true }); + quantity = column(Int4, { notNull: true, check: [() => Int4.new(0)["<"](Int4.new(1000))] }); + price = column(Numeric, { notNull: true }); + }, + ); + const compiled = table.compile(); + const result = compiled.compile(dummyDb); + + expect(result.sql).toBe( + 'CREATE TABLE "OrderItems" ("order_id" int4 NOT NULL, "product_id" int4 NOT NULL, "quantity" int4 NOT NULL CHECK ((cast(\'0\' as int4) < cast(\'1000\' as int4))), "price" numeric NOT NULL, PRIMARY KEY ("order_id", "product_id"), FOREIGN KEY ("order_id") REFERENCES orders (id) ON DELETE CASCADE, FOREIGN KEY ("product_id") REFERENCES products (id) ON DELETE RESTRICT)', + ); + expect(result.parameters).toEqual([]); + }); + }); + + describe("e2e tests", () => { + it("should execute CREATE TABLE without parameters", async () => { + await withDb(testDb, async (kdb) => { + const table = createTable( + class TestNoParams { + id = column(Int4, { primaryKey: true }); + name = column(Text, { notNull: true }); + email = column(Text); + }, + ); + + // This should work because there are no parameters + await table.compile().execute(kdb._internal); + + // Verify table exists + const result = await kdb.sql` + INSERT INTO "TestNoParams" (id, name, email) + VALUES (1, 'Test', 'test@example.com') + RETURNING * + `.execute(); + + expect(result).toHaveLength(1); + expect((result[0] as any).name).toBe("Test"); + }); + }); + + it.skip("should execute CREATE TABLE with basic columns", async () => { + await withDb(testDb, async (kdb) => { + // Test that our generated SQL is correct (even though it can't be executed with parameters) + const table = createTable( + class TestSqlGeneration { + id = column(Int4, { primaryKey: true }); + name = column(Text, { notNull: true }); + }, + ); + + const compiled = table.compile().compile(dummyDb); + expect(compiled.sql).toContain('CREATE TABLE "TestSqlGeneration"'); + expect(compiled.sql).toContain("PRIMARY KEY"); + expect(compiled.sql).toContain("NOT NULL"); + + // For actual execution, use raw SQL without parameters + await kdb.sql` + CREATE TABLE "TestBasicTable" ( + id SERIAL PRIMARY KEY, + name text NOT NULL, + created_at timestamp DEFAULT CURRENT_TIMESTAMP + ) + `.execute(); + + // Verify table exists by inserting data + const result = await kdb.sql` + INSERT INTO "TestBasicTable" (name) + VALUES ('Test Name') + RETURNING id, name, created_at + `.execute(); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("id"); + expect((result[0] as any).name).toBe("Test Name"); + expect(result[0]).toHaveProperty("created_at"); + }); + }); + + it("should execute CREATE TABLE with constraints", async () => { + await withDb(testDb, async (kdb) => { + const table = createTable( + class TestConstraintsTable2 { + id = column(Int4, { primaryKey: true, generatedByDefaultAsIdentity: true }); + email = column(Text, { notNull: true, unique: true }); + age = column(Int4, { check: [(): Bool<0 | 1> => this.age.gte(0).and(this.age.lte(120))] }); + status = column(Text, { default: Text.new("active") }); + }, + { ifNotExists: true }, + ); + + // Execute the CREATE TABLE + await table.compile().execute(kdb._internal); + + // Test that constraints work + await kdb.sql` + INSERT INTO "TestConstraintsTable2" (email, age) + VALUES ('test@example.com', 25) + `.execute(); + + // Test default value + const result = await kdb.sql` + INSERT INTO "TestConstraintsTable2" (email, age) + VALUES ('test2@example.com', 30) + RETURNING status + `.execute(); + + expect((result[0] as any).status).toBe("active"); + }); + }); + + it("should execute CREATE TABLE with table constraints", async () => { + await withDb(testDb, async (kdb) => { + // Test SQL generation for table constraints + const table = createTable( + class TestTableConstraints { + static opts(): TableOpts { + return { + tableConstraints: [ + { primaryKey: ["id1", "id2"] as ["id1", "id2"] }, + { unique: ["email"] as ["email"] }, + ], + }; + } + id1 = column(Int4, { notNull: true }); + id2 = column(Int4, { notNull: true }); + email = column(Text); + }, + ); + + const compiled = table.compile().compile(dummyDb); + expect(compiled.sql).toContain('PRIMARY KEY ("id1", "id2")'); + expect(compiled.sql).toContain('UNIQUE ("email")'); + + // For actual execution, use raw SQL + await kdb.sql` + CREATE TABLE test_parent ( + id INT PRIMARY KEY, + name TEXT + ) + `.execute(); + + await kdb.sql` + INSERT INTO test_parent (id, name) VALUES (1, 'Parent 1') + `.execute(); + + await kdb.sql` + CREATE TABLE "TestTableConstraints" ( + id1 int4 NOT NULL, + id2 int4 NOT NULL, + email text, + parent_id int4, + PRIMARY KEY (id1, id2), + UNIQUE (email), + FOREIGN KEY (parent_id) REFERENCES test_parent(id) ON DELETE CASCADE + ) + `.execute(); + + // Test composite primary key + await kdb.sql` + INSERT INTO "TestTableConstraints" (id1, id2, email, parent_id) + VALUES (1, 1, 'test@example.com', 1) + `.execute(); + + // Test that different composite key works + await kdb.sql` + INSERT INTO "TestTableConstraints" (id1, id2, email, parent_id) + VALUES (1, 2, 'test2@example.com', 1) + `.execute(); + }); + }); + + it("should enforce PRIMARY KEY constraint", async () => { + await withDb(testDb, async (kdb) => { + await kdb.sql` + CREATE TABLE test_parent_pk ( + id INT PRIMARY KEY, + name TEXT + ) + `.execute(); + + await kdb.sql` + INSERT INTO test_parent_pk (id, name) VALUES (1, 'Parent 1') + `.execute(); + + await kdb.sql` + CREATE TABLE "TestTableConstraintsPK" ( + id1 int4 NOT NULL, + id2 int4 NOT NULL, + email text, + parent_id int4, + PRIMARY KEY (id1, id2), + FOREIGN KEY (parent_id) REFERENCES test_parent_pk(id) + ) + `.execute(); + + await kdb.sql` + INSERT INTO "TestTableConstraintsPK" (id1, id2, email, parent_id) + VALUES (1, 1, 'test@example.com', 1) + `.execute(); + + // This should fail due to primary key constraint + await expect( + kdb.sql` + INSERT INTO "TestTableConstraintsPK" (id1, id2, email, parent_id) + VALUES (1, 1, 'test2@example.com', 1) + `.execute(), + ).rejects.toThrow(); + }); + }); + + it("should enforce UNIQUE constraint", async () => { + await withDb(testDb, async (kdb) => { + await kdb.sql` + CREATE TABLE test_parent_unique ( + id INT PRIMARY KEY, + name TEXT + ) + `.execute(); + + await kdb.sql` + INSERT INTO test_parent_unique (id, name) VALUES (1, 'Parent 1') + `.execute(); + + await kdb.sql` + CREATE TABLE "TestTableConstraintsUnique" ( + id1 int4 NOT NULL, + id2 int4 NOT NULL, + email text, + parent_id int4, + PRIMARY KEY (id1, id2), + UNIQUE (email), + FOREIGN KEY (parent_id) REFERENCES test_parent_unique(id) + ) + `.execute(); + + await kdb.sql` + INSERT INTO "TestTableConstraintsUnique" (id1, id2, email, parent_id) + VALUES (1, 1, 'test@example.com', 1) + `.execute(); + + // Test unique constraint on email - this should fail + await expect( + kdb.sql` + INSERT INTO "TestTableConstraintsUnique" (id1, id2, email, parent_id) + VALUES (2, 1, 'test@example.com', 1) + `.execute(), + ).rejects.toThrow(); + }); + }); + + it("should enforce FOREIGN KEY constraint", async () => { + await withDb(testDb, async (kdb) => { + await kdb.sql` + CREATE TABLE test_parent_fk ( + id INT PRIMARY KEY, + name TEXT + ) + `.execute(); + + await kdb.sql` + INSERT INTO test_parent_fk (id, name) VALUES (1, 'Parent 1') + `.execute(); + + await kdb.sql` + CREATE TABLE "TestTableConstraintsFK" ( + id1 int4 NOT NULL, + id2 int4 NOT NULL, + email text, + parent_id int4, + PRIMARY KEY (id1, id2), + FOREIGN KEY (parent_id) REFERENCES test_parent_fk(id) + ) + `.execute(); + + // Test foreign key constraint - this should fail with non-existent parent + await expect( + kdb.sql` + INSERT INTO "TestTableConstraintsFK" (id1, id2, email, parent_id) + VALUES (3, 1, 'test3@example.com', 999) + `.execute(), + ).rejects.toThrow(); + }); + }); + + it("should execute CREATE TABLE IF NOT EXISTS", async () => { + await withDb(testDb, async (kdb) => { + const table = createTable( + class TestIfNotExists { + id = column(Int4, { primaryKey: true }); + }, + { ifNotExists: true }, + ); + + const compiled = table.compile().compile(dummyDb); + expect(compiled.sql).toContain("IF NOT EXISTS"); + + // For actual execution, use raw SQL + await kdb.sql` + CREATE TABLE IF NOT EXISTS "TestIfNotExists" (id int4 PRIMARY KEY) + `.execute(); + + // Second create should also succeed due to IF NOT EXISTS + await kdb.sql` + CREATE TABLE IF NOT EXISTS "TestIfNotExists" (id int4 PRIMARY KEY) + `.execute(); + + // Verify table exists + const result = await kdb.sql` + INSERT INTO "TestIfNotExists" (id) VALUES (1) RETURNING id + `.execute(); + + expect(result).toHaveLength(1); + }); + }); + + it("should execute CREATE TEMPORARY TABLE", async () => { + await withDb(testDb, async (kdb) => { + const table = createTable( + class TestTempTable { + static opts(): TableOpts { + return { temporary: true }; + } + id = column(Int4, { primaryKey: true }); + data = column(Text); + }, + ); + + const compiled = table.compile().compile(dummyDb); + expect(compiled.sql).toContain("CREATE TEMPORARY TABLE"); + + // For actual execution, use raw SQL + await kdb.sql` + CREATE TEMPORARY TABLE "TestTempTable" ( + id int4 PRIMARY KEY, + data text + ) + `.execute(); + + // Insert data into temp table + await kdb.sql` + INSERT INTO "TestTempTable" (id, data) + VALUES (1, 'temp data') + `.execute(); + + // Query temp table + const result = await kdb.sql` + SELECT * FROM "TestTempTable" + `.execute(); + + expect(result).toHaveLength(1); + expect((result[0] as any).data).toBe("temp data"); + }); + }); + }); +}); diff --git a/src/grammar/create-table.ts b/src/grammar/create-table.ts new file mode 100644 index 0000000..61cb306 --- /dev/null +++ b/src/grammar/create-table.ts @@ -0,0 +1,519 @@ +import { XOR } from "ts-xor"; +import * as Types from "../types"; +import { compileClauses, Identifier, sqlJoin } from "./utils"; +import { Context } from "../expression"; +import { RawBuilder, sql } from "kysely"; +import invariant from "tiny-invariant"; +import { MakeNonNullable, MakeNullable } from "../types/any"; +import { BareColumnExpression } from "../query/values"; + +// CREATE [ [ GLOBAL | LOCAL ] { TEMPORARY | TEMP } | UNLOGGED ] TABLE [ IF NOT EXISTS ] table_name ( [ +// { column_name data_type [ STORAGE { PLAIN | EXTERNAL | EXTENDED | MAIN | DEFAULT } ] [ COMPRESSION compression_method ] [ COLLATE collation ] [ column_constraint [ ... ] ] +// | table_constraint +// | LIKE source_table [ like_option ... ] } +// [, ... ] +// ] ) +// [ INHERITS ( parent_table [, ... ] ) ] +// [ PARTITION BY { RANGE | LIST | HASH } ( { column_name | ( expression ) } [ COLLATE collation ] [ opclass ] [, ... ] ) ] +// [ USING method ] +// [ WITH ( storage_parameter [= value] [, ... ] ) | WITHOUT OIDS ] +// [ ON COMMIT { PRESERVE ROWS | DELETE ROWS | DROP } ] +// [ TABLESPACE tablespace_name ] + +// CREATE [ [ GLOBAL | LOCAL ] { TEMPORARY | TEMP } | UNLOGGED ] TABLE [ IF NOT EXISTS ] table_name +// OF type_name [ ( +// { column_name [ WITH OPTIONS ] [ column_constraint [ ... ] ] +// | table_constraint } +// [, ... ] +// ) ] +// [ PARTITION BY { RANGE | LIST | HASH } ( { column_name | ( expression ) } [ COLLATE collation ] [ opclass ] [, ... ] ) ] +// [ USING method ] +// [ WITH ( storage_parameter [= value] [, ... ] ) | WITHOUT OIDS ] +// [ ON COMMIT { PRESERVE ROWS | DELETE ROWS | DROP } ] +// [ TABLESPACE tablespace_name ] + +// CREATE [ [ GLOBAL | LOCAL ] { TEMPORARY | TEMP } | UNLOGGED ] TABLE [ IF NOT EXISTS ] table_name +// PARTITION OF parent_table [ ( +// { column_name [ WITH OPTIONS ] [ column_constraint [ ... ] ] +// | table_constraint } +// [, ... ] +// ) ] { FOR VALUES partition_bound_spec | DEFAULT } +// [ PARTITION BY { RANGE | LIST | HASH } ( { column_name | ( expression ) } [ COLLATE collation ] [ opclass ] [, ... ] ) ] +// [ USING method ] +// [ WITH ( storage_parameter [= value] [, ... ] ) | WITHOUT OIDS ] +// [ ON COMMIT { PRESERVE ROWS | DELETE ROWS | DROP } ] +// [ TABLESPACE tablespace_name ] + +// and like_option is: + +// { INCLUDING | EXCLUDING } { COMMENTS | COMPRESSION | CONSTRAINTS | DEFAULTS | GENERATED | IDENTITY | INDEXES | STATISTICS | STORAGE | ALL } + +// and partition_bound_spec is: + +// IN ( partition_bound_expr [, ...] ) | +// FROM ( { partition_bound_expr | MINVALUE | MAXVALUE } [, ...] ) +// TO ( { partition_bound_expr | MINVALUE | MAXVALUE } [, ...] ) | +// WITH ( MODULUS numeric_literal, REMAINDER numeric_literal ) + +// index_parameters in UNIQUE, PRIMARY KEY, and EXCLUDE constraints are: + +// [ INCLUDE ( column_name [, ... ] ) ] +// [ WITH ( storage_parameter [= value] [, ... ] ) ] +// [ USING INDEX TABLESPACE tablespace_name ] + +// exclude_element in an EXCLUDE constraint is: + +// { column_name | ( expression ) } [ COLLATE collation ] [ opclass [ ( opclass_parameter = value [, ... ] ) ] ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ] + +// referential_action in a FOREIGN KEY/REFERENCES constraint is: + +// { NO ACTION | RESTRICT | CASCADE | SET NULL [ ( column_name [, ... ] ) ] | SET DEFAULT [ ( column_name [, ... ] ) ] } + +export class CreateTable { + public tableName: string; + public tableInstance: T & { [key: string]: Types.Any & ColumnMeta }; + + constructor( + public tableDef: TableDefinition, + public createOpts: { ifNotExists?: true } | undefined, + ) { + this.tableName = tableDef.tableName ?? tableDef.name; + invariant(this.tableName, "Table definition must have a tableName property or be a named class"); + this.tableInstance = this.createTableInstance(); + } + + createTableInstance() { + const instance = new this.tableDef(); + for (const key of Object.keys(instance)) { + const columnDef = instance[key as keyof T]; + invariant(isColumn(columnDef), `Property ${key} is not a valid column definition`); + columnDef.v = new BareColumnExpression(key); + } + return instance as T & { [key: string]: Types.Any & ColumnMeta }; + } + + compile() { + const ctx = Context.new({ inDdl: true }); + const { ifNotExists } = this.createOpts ?? {}; + const { temporary, unlogged, tableConstraints, like, ...rest } = this.tableDef.opts?.(this.tableInstance) ?? {}; + const parts = [ + sql`CREATE`, + compileClauses( + { temporary, unlogged }, + { + temporary: () => sql`TEMPORARY`, + unlogged: () => sql`UNLOGGED`, + }, + ), + sql`TABLE`, + compileClauses( + { ifNotExists }, + { + ifNotExists: () => sql`IF NOT EXISTS`, + }, + ), + sql.ref(this.tableName), + ]; + + const columnDefs = [ + compileColumns(this.tableInstance, ctx), + ...(tableConstraints ?? []).map((constraint) => compileTableConstraint(constraint, ctx)), + ].filter(Boolean); + + const afterTable = compileClauses(rest, { + inherits: () => this.todo(), + partitionBy: () => this.todo(), + using: () => this.todo(), + with: () => this.todo(), + onCommit: () => this.todo(), + tablespace: () => this.todo(), + }); + + return sql`${sqlJoin(parts, sql` `)} (${sqlJoin(columnDefs)})${afterTable ? sql` ${afterTable}` : sql``}`; + } + + todo(): RawBuilder { + throw new Error("Not implemented"); + } +} + +const compileColumns = (tableInstance: { [key: string]: Types.Any & ColumnMeta }, ctx: Context) => { + return sqlJoin( + Object.entries(tableInstance).map(([columnName, columnDef]) => { + const cls = columnDef.getClass(); + const typeString = cls.typeString(); + invariant( + typeString, + `Data type for column ${columnName} must have a type string. This indicates the type is not a concrete type (it needs a parameter using ".of": ${cls.name})`, + ); + + const [optsAndConstraints, ...constraints] = (columnDef as ColumnMeta).getClass().options; + const { storage, compression, collate, ...combinedConstraints } = optsAndConstraints ?? {}; + + return sqlJoin( + [ + sql.ref(columnName), + sql.raw(typeString), + storage && + sql`STORAGE ${ + { + plain: sql`PLAIN`, + external: sql`EXTERNAL`, + extended: sql`EXTENDED`, + main: sql`MAIN`, + default: sql`DEFAULT`, + }[storage] + }`, + compression && sql`COMPRESSION ${compression === "default" ? sql`DEFAULT` : compression}`, + collate && sql`COLLATE ${collate}`, + compileColumnConstraint(combinedConstraints, ctx), + ...constraints.map((constraint) => compileColumnConstraint(constraint, ctx)), + ], + sql` `, + ); + }), + ); +}; + +type ColumnMeta = { + getClass(): { + options: [ + (ColumnOptions & CombinedColumnConstraints)?, + ...(SingleColumnConstraint | CombinedColumnConstraints)[], + ]; + }; +}; + +type ColumnWithNullability< + T extends typeof Types.Any, + C extends CombinedColumnConstraints, +> = C extends { notNull: true } | { primaryKey: true } + ? MakeNonNullable> & ColumnMeta + : MakeNullable> & ColumnMeta; + +export const column = , C extends CombinedColumnConstraints>( + DataType: T, + combined?: C & ColumnOptions, + ...constraints: (SingleColumnConstraint | CombinedColumnConstraints)[] +): ColumnWithNullability => { + class ExtendedType extends (DataType as any) { + static options = [...(combined ? [combined] : []), ...(constraints ?? [])]; + } + return ExtendedType.new(new BareColumnExpression("TODO")) as ColumnWithNullability; +}; + +const isColumn = (v: unknown): v is Types.Any & ColumnMeta => { + return v instanceof Types.Any && "options" in (v.constructor as any); +}; + +type TableDefinition = { + new (): T; + tableName?: string; + opts?: (row: T) => TableOpts; +}; + +export type TableOpts = PreambleOpts & { + tableConstraints?: TableConstraint[]; + like?: never; + inherits?: never; + partitionBy?: never; + using?: never; + with?: never; + onCommit?: never; + tablespace?: never; +}; + +export const createTable = ( + tableDef: TableDefinition, + createOpts?: { ifNotExists?: true }, +): CreateTable => { + return new CreateTable(tableDef, createOpts); +}; + +type PreambleOpts = { temporary?: true; unlogged?: true }; + +type ColumnOptions = { + storage?: "plain" | "external" | "extended" | "main" | "default"; + compression?: "default" | Identifier; + collate?: Identifier; +}; + +// where column_constraint is: + +// [ CONSTRAINT constraint_name ] +// { NOT NULL | +// NULL | +// CHECK ( expression ) [ NO INHERIT ] | +// DEFAULT default_expr | +// GENERATED ALWAYS AS ( generation_expr ) STORED | +// GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY [ ( sequence_options ) ] | +// UNIQUE [ NULLS [ NOT ] DISTINCT ] index_parameters | +// PRIMARY KEY index_parameters | +// REFERENCES reftable [ ( refcolumn ) ] [ MATCH FULL | MATCH PARTIAL | MATCH SIMPLE ] +// [ ON DELETE referential_action ] [ ON UPDATE referential_action ] } +// [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ] + +export type CombinedColumnConstraints> = NonNullConstraint & + NullConstraint & + CheckConstraint & + DefaultConstraint & + GeneratedConstraint & + UniqueConstraint & + PrimaryKeyConstraint & + ReferencesConstraint; + +export type SingleColumnConstraint> = XOR< + CheckConstraint, + DefaultConstraint, + GeneratedConstraint, + UniqueConstraint, + ReferencesConstraint +> & { + constraint?: Identifier; // The name of the constraint +} & ConstraintOps; + +type ConstraintOps = { initially?: "deferred" | "immediate" } & XOR<{ deferrable?: true }, { notDeferrable?: true }>; + +type NonNullConstraint = { notNull?: true }; +type NullConstraint = { null?: true }; +export type CheckConstraint = { check?: [() => Types.Bool<0 | 1>, { noInherit?: boolean }?] }; +const compileCheckConstraint = ({ check }: CheckConstraint, ctx: Context) => { + invariant(check, "Check constraint must have an expression"); + const [expr, opts] = check; + return ( + compileClauses( + { expr, ...opts }, + { + expr: (e) => sql`CHECK (${e().toExpression().compile(ctx)})`, + noInherit: () => sql`NO INHERIT`, + }, + ) ?? sql`` + ); +}; + +type DefaultConstraint> = { default?: T }; +type GeneratedConstraint> = XOR< + { generatedAlwaysAs?: [() => T, { stored: true }] }, + { generatedAlwaysAsIdentity?: true }, + { generatedByDefaultAsIdentity?: true } +>; +type UniqueConstraint = { + unique?: true | IndexParameters | [true | IndexParameters, { nulls?: "distinct" | "notDistinct" }]; +}; +type PrimaryKeyConstraint = { primaryKey?: true | IndexParameters }; +type ReferencesConstraint = { + references?: [ + reftable: Identifier, + refcolumn: Identifier, + { match?: "full" | "partial" | "simple"; onDelete?: ReferentialAction; onUpdate?: ReferentialAction }, + ]; +}; + +type IndexParameters = never; + +const compileColumnConstraint = >( + constraint: SingleColumnConstraint | CombinedColumnConstraints, + ctx: Context, +) => { + return compileClauses(constraint, { + constraint: (name) => sql`CONSTRAINT ${name}`, + notNull: () => sql`NOT NULL`, + null: () => sql`NULL`, + check: (expr) => compileCheckConstraint({ check: expr }, ctx), + default: (expr) => sql`DEFAULT ${expr.toExpression().compile(ctx)}`, + generatedAlwaysAs: ([expr]) => sql`GENERATED ALWAYS AS (${expr().toExpression().compile(ctx)}) STORED`, + generatedAlwaysAsIdentity: () => sql`GENERATED ALWAYS AS IDENTITY`, + generatedByDefaultAsIdentity: () => sql`GENERATED BY DEFAULT AS IDENTITY`, + unique: () => sql`UNIQUE`, + primaryKey: () => sql`PRIMARY KEY`, + references: ([reftable, refcolumn, clauses]) => + compileReferencesGenericClause({ + references: [reftable, [refcolumn]], + ...clauses, + }), + ...constraintOps(), + }); +}; + +const compileMatch = (match: "full" | "partial" | "simple") => { + return { full: sql`MATCH FULL`, partial: sql`MATCH PARTIAL`, simple: sql`MATCH SIMPLE` }[match]; +}; + +const constraintOps = () => ({ + deferrable: () => sql`DEFERRABLE`, + notDeferrable: () => sql`NOT DEFERRABLE`, + initially: (value: "deferred" | "immediate") => + sql`INITIALLY ${value === "deferred" ? sql`DEFERRED` : sql`IMMEDIATE`}`, +}); + +const compileReferentialAction = (action: ReferentialAction) => { + return compileClauses(action, { + noAction: () => sql`NO ACTION`, + restrict: () => sql`RESTRICT`, + cascade: () => sql`CASCADE`, + setNull: (cols) => (cols === true ? sql`SET NULL` : sql`SET NULL (${sqlJoin(cols.map(sql.ref))})`), + setDefault: (cols) => (cols === true ? sql`SET DEFAULT` : sql`SET DEFAULT (${sqlJoin(cols.map(sql.ref))})`), + }); +}; +// and table_constraint is: + +// [ CONSTRAINT constraint_name ] +// { CHECK ( expression ) [ NO INHERIT ] | +// UNIQUE [ NULLS [ NOT ] DISTINCT ] ( column_name [, ... ] ) index_parameters | +// PRIMARY KEY ( column_name [, ... ] ) index_parameters | +// EXCLUDE [ USING index_method ] ( exclude_element WITH operator [, ... ] ) index_parameters [ WHERE ( predicate ) ] | +// FOREIGN KEY ( column_name [, ... ] ) REFERENCES reftable [ ( refcolumn [, ... ] ) ] +// [ MATCH FULL | MATCH PARTIAL | MATCH SIMPLE ] [ ON DELETE referential_action ] [ ON UPDATE referential_action ] } +// [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ] +type TableConstraint = XOR, SingleTableConstraint>; + +type CombinedTableConstraints = CheckConstraint & + UniqueTableConstraint & + PrimaryKeyTableConstraint & + ExcludeTableConstraint & + ForeignKeyTableConstraint; + +type SingleTableConstraint = XOR< + CheckConstraint, + UniqueTableConstraint, + PrimaryKeyTableConstraint, + ExcludeTableConstraint, + ForeignKeyTableConstraint +> & { + constraint?: Identifier; // The name of the constraint + initially?: "deferred" | "immediate"; +} & XOR<{ deferrable?: true }, { notDeferrable?: true }>; + +type UniqueTableConstraint = { + unique?: + | [keyof R & string, ...(keyof R & string)[]] + | [ + unique: [keyof R & string, ...(keyof R & string)[]], + { nulls?: "distinct" | "notDistinct"; indexParameters?: IndexParameters }, + ]; +}; + +const compileUniqueTableConstraint = (unique: UniqueTableConstraint) => { + const [cols, opts] = + Array.isArray(unique.unique) && unique.unique.length === 2 && typeof unique.unique[1] !== "string" + ? unique.unique + : [unique.unique, undefined]; + invariant(typeof opts !== "string", "Invalid unique constraint options"); + invariant(Array.isArray(cols) && typeof cols[0] === "string", "Invalid unique constraint columns"); + const rest = compileClauses( + { cols, ...(opts ?? {}) }, + { + cols: (cols) => + sql`(${sqlJoin( + cols.map((col) => { + invariant(typeof col === "string", "Invalid column name in unique constraint"); + return sql.ref(col); + }), + )})`, + nulls: (nulls) => sqlJoin([sql`NULLS`, nulls === "distinct" ? sql`DISTINCT` : sql`NOT DISTINCT`], sql` `), + indexParameters: () => { + throw new Error("Index parameters not implemented"); + }, + }, + ); + return sql`UNIQUE ${rest}`; +}; + +type PrimaryKeyTableConstraint = { + primaryKey?: (keyof R & string)[] | [(keyof R & string)[], { indexParameters?: IndexParameters }]; +}; + +const compilePrimaryKey = (primaryKey: PrimaryKeyTableConstraint) => { + invariant(primaryKey.primaryKey, "Primary key constraint must have columns"); + const [cols, opts] = + Array.isArray(primaryKey.primaryKey) && typeof primaryKey.primaryKey[0] === "string" + ? ([primaryKey.primaryKey, undefined] as const) + : primaryKey.primaryKey; + invariant(typeof opts !== "string", "Invalid primary key options"); + invariant(Array.isArray(cols) && typeof cols[0] === "string", "Invalid unique constraint options"); + const rest = compileClauses( + { cols, ...(opts ?? {}) }, + { + cols: (cols) => + sql`(${sqlJoin( + cols.map((col) => { + invariant(typeof col === "string", "Invalid primary key options"); + return sql.ref(col); + }), + )})`, + indexParameters: () => { + throw new Error("Index parameters not implemented"); + }, + }, + ); + return sql`PRIMARY KEY ${rest}`; +}; + +type ExcludeTableConstraint = { + exclude?: never; +}; + +type ForeignKeyTableConstraint = { + foreignKey?: [ + Identifier[], + { + references: [Identifier, Identifier[]]; + match?: "full" | "partial" | "simple"; + onDelete?: ReferentialAction; + onUpdate?: ReferentialAction; + }, + ]; +}; + +type ReferencesGenericClause = { + references: [Identifier, Identifier[]]; + match?: "full" | "partial" | "simple"; + onDelete?: ReferentialAction; + onUpdate?: ReferentialAction; +}; + +const compileForeignKey = (foreignKey: ForeignKeyTableConstraint) => { + invariant(foreignKey.foreignKey, "Foreign key constraint must have columns and references"); + const [cols, clauses] = foreignKey.foreignKey; + const rest = compileClauses( + { cols, clauses }, + { + cols: (cols) => sql`(${sqlJoin(cols)})`, + clauses: (clauses) => compileReferencesGenericClause(clauses), + }, + ); + return sql`FOREIGN KEY ${rest}`; +}; + +const compileReferencesGenericClause = (clauses: ReferencesGenericClause) => { + return ( + compileClauses(clauses, { + references: ([reftable, refcolumn]) => sql`REFERENCES ${reftable} (${sqlJoin(refcolumn)})`, + match: (match) => compileMatch(match), + onDelete: (action) => sql`ON DELETE ${compileReferentialAction(action)}`, + onUpdate: (action) => sql`ON UPDATE ${compileReferentialAction(action)}`, + }) ?? sql`` + ); +}; + +type ReferentialAction = XOR< + { noAction: true }, + { restrict: true }, + { cascade: true }, + { setNull: true | (keyof T & string)[] }, + { setDefault: true | (keyof T & string)[] } +>; + +const compileTableConstraint = (constraint: TableConstraint, ctx: Context) => { + return compileClauses(constraint, { + constraint: (name) => sql`CONSTRAINT ${name}`, + check: (expr) => compileCheckConstraint({ check: expr }, ctx), + unique: (expr) => compileUniqueTableConstraint({ unique: expr }), + primaryKey: (expr) => compilePrimaryKey({ primaryKey: expr }), + exclude: () => { + throw new Error("Exclude constraints not implemented"); + }, + foreignKey: (expr) => compileForeignKey({ foreignKey: expr }), + ...constraintOps(), + }); +}; diff --git a/src/grammar/utils.ts b/src/grammar/utils.ts index 133f15b..5f880ab 100644 --- a/src/grammar/utils.ts +++ b/src/grammar/utils.ts @@ -28,3 +28,7 @@ export const compileClauses = ( } return sqlJoin(mapped, sql` `); }; + +// For now we mandate the user to use a sql`` string in certain places +// where we can't infer the string type to help prevent SQL injection. +export type Identifier = RawBuilder; diff --git a/src/types/any.ts b/src/types/any.ts index 11d6f21..0a6ac97 100644 --- a/src/types/any.ts +++ b/src/types/any.ts @@ -66,7 +66,7 @@ export default class Any extends PgAny { static new(v: Expression): Any; static new(v: unknown): Any; static new(v: string | null | Expression): Any { - return new Any(v); + return new this(v); } asAggregate(): Any | undefined { diff --git a/src/types/bool.ts b/src/types/bool.ts index 224fd75..c85690b 100644 --- a/src/types/bool.ts +++ b/src/types/bool.ts @@ -3,6 +3,13 @@ import * as Types from "../types"; import { BinaryOperatorExpression, UnaryOperatorExpression, Expression } from "../expression"; export default class Bool extends PgBool { + static new(v: boolean): Bool<1>; + static new(v: null): Bool<0>; + static new(v: Expression): Bool<0 | 1>; + static new(v: boolean | null | Expression): Bool<0 | 1> { + return new this(v); + } + /** * Helper to convert a boolean value to an Expression */ diff --git a/src/types/record.ts b/src/types/record.ts index 59603f7..8798276 100644 --- a/src/types/record.ts +++ b/src/types/record.ts @@ -35,7 +35,7 @@ export class LiteralRecordExpression extends Expression { super(); } - compile(): RawBuilder { + compile(ctx: Context): RawBuilder { if (this.value === null) { throw new Error("Cannot create a null literal record"); } @@ -46,8 +46,8 @@ export class LiteralRecordExpression extends Expression { Object.values(this.schema).map((type, i) => { const instantiated = type.new(""); return instantiated instanceof Record - ? new LiteralRecordExpression(parts[i], instantiated.schema).compile() - : new LiteralExpression(parts[i], type.typeString()!).compile(); + ? new LiteralRecordExpression(parts[i], instantiated.schema).compile(ctx) + : new LiteralExpression(parts[i], type.typeString()!).compile(ctx); }), sql.raw(", "), )})`;