diff --git a/src/sumak.ts b/src/sumak.ts index bf5c67b..b11c1e1 100644 --- a/src/sumak.ts +++ b/src/sumak.ts @@ -412,11 +412,16 @@ export class Sumak { if (typeof node.type === "string" && node.type.startsWith("tcl_")) { return new TclPrinter(this._dialect.name).print(node as TclNode) } - // Route DDL nodes directly — same reasoning. Wire in the dialect's printer - // so CREATE TABLE ... AS SELECT / CREATE VIEW AS can render the SELECT body. + // Route DDL nodes directly — DDL itself is not subject to plugins + // (you cannot "soft-delete" a CREATE TABLE). However, when the DDL + // carries an embedded SELECT (CREATE TABLE … AS SELECT, CREATE + // VIEW AS SELECT), the inner SELECT MUST still pass through the + // full pipeline — plugin transforms, hooks, normalize, optimize — + // otherwise a `CREATE VIEW tenant_orders AS SELECT * FROM orders` + // on a multi-tenant table silently captures every tenant's rows + // into the view. Route `asSelect` recursively through compile(). if (isDDLNode(node)) { - const base = this._dialect.createPrinter() - return new DDLPrinter(this._dialect.name, (sel) => base.print(sel)).print(node as DDLNode) + return new DDLPrinter(this._dialect.name, (sel) => this.compile(sel)).print(node as DDLNode) } // 1. Plugin AST transform @@ -512,11 +517,15 @@ export class Sumak { * ``` */ generateDDL(options?: { ifNotExists?: boolean }): CompiledQuery[] { - const base = this._dialect.createPrinter() - const printer = new DDLPrinter(this._dialect.name, (sel) => base.print(sel)) const results: CompiledQuery[] = [] for (const [tableName, columns] of Object.entries(this._tables)) { + // One printer per statement — matches the contract used by + // compileDDL() and avoids any cross-iteration param carryover + // if a column default or future asSelect pushes into the + // printer's params between the inner `renderSelect` and the + // outer `print()`'s snapshot. + const printer = new DDLPrinter(this._dialect.name, (sel) => this.compile(sel)) const builder = new CreateTableBuilder(tableName) let tb = options?.ifNotExists ? builder.ifNotExists() : builder @@ -557,8 +566,9 @@ export class Sumak { /** Compile a DDL node to SQL. */ compileDDL(node: DDLNode): CompiledQuery { - const base = this._dialect.createPrinter() - const printer = new DDLPrinter(this._dialect.name, (sel) => base.print(sel)) + // Same as compile(): embedded SELECTs in AS SELECT / asSelect must + // route through the full pipeline so plugins apply. + const printer = new DDLPrinter(this._dialect.name, (sel) => this.compile(sel)) return printer.print(node) } diff --git a/test/audit23-regressions.test.ts b/test/audit23-regressions.test.ts new file mode 100644 index 0000000..10b07f4 --- /dev/null +++ b/test/audit23-regressions.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest" + +import type { CreateTableNode, CreateViewNode } from "../src/ast/ddl-nodes.ts" +import { CreateTableBuilder } from "../src/builder/ddl/create-table.ts" +import { SelectBuilder } from "../src/builder/select.ts" +import { pgDialect } from "../src/dialect/pg.ts" +import { MultiTenantPlugin } from "../src/plugin/multi-tenant.ts" +import { SoftDeletePlugin } from "../src/plugin/soft-delete.ts" +import { integer, serial, text, timestamptz } from "../src/schema/column.ts" +import { sumak } from "../src/sumak.ts" + +describe("Audit #23 regressions", () => { + describe("DDL AS SELECT is plugin-walked (CREATE TABLE AS SELECT)", () => { + it("MultiTenant plugin filters the inner SELECT on CTAS", () => { + const db = sumak({ + dialect: pgDialect(), + plugins: [new MultiTenantPlugin({ tables: ["orders"], tenantId: 42 })], + tables: { + orders: { id: serial().primaryKey(), tenant_id: integer() }, + }, + }) + const inner = new SelectBuilder().columns("*").from("orders").build() + const ddl = new CreateTableBuilder("orders_cache").asSelect(inner).build() + const r = db.compileDDL(ddl) + expect(r.sql).toContain('WHERE ("tenant_id" = $1)') + expect(r.params).toContain(42) + }) + + it("SoftDelete plugin filters the inner SELECT on CTAS", () => { + const db = sumak({ + dialect: pgDialect(), + plugins: [new SoftDeletePlugin({ tables: ["orders"] })], + tables: { + orders: { + id: serial().primaryKey(), + deleted_at: timestamptz().nullable(), + }, + }, + }) + const inner = new SelectBuilder().columns("*").from("orders").build() + const ddl = new CreateTableBuilder("orders_cache").asSelect(inner).build() + const r = db.compileDDL(ddl) + expect(r.sql).toContain('"deleted_at" IS NULL') + }) + + it("CREATE VIEW AS SELECT also walks through plugins", () => { + const db = sumak({ + dialect: pgDialect(), + plugins: [new MultiTenantPlugin({ tables: ["orders"], tenantId: 7 })], + tables: { + orders: { id: serial().primaryKey(), tenant_id: integer() }, + }, + }) + const inner = new SelectBuilder().columns("*").from("orders").build() + const viewNode: CreateViewNode = { + type: "create_view", + name: "tenant_orders", + asSelect: inner, + } + const r = db.compileDDL(viewNode) + expect(r.sql).toContain('WHERE ("tenant_id" = $1)') + expect(r.params).toContain(7) + }) + + it("CTAS without plugins still works (no-op pipeline)", () => { + const db = sumak({ + dialect: pgDialect(), + tables: { + orders: { id: serial().primaryKey(), name: text() }, + }, + }) + const inner = new SelectBuilder().columns("*").from("orders").build() + const ddl: CreateTableNode = new CreateTableBuilder("orders_cache").asSelect(inner).build() + const r = db.compileDDL(ddl) + expect(r.sql).toContain("CREATE TABLE") + expect(r.sql).toContain("SELECT") + expect(r.sql).toContain("orders") + }) + }) +})