Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/sumak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,11 +412,16 @@ export class Sumak<DB> {
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
Expand Down Expand Up @@ -512,11 +517,15 @@ export class Sumak<DB> {
* ```
*/
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

Expand Down Expand Up @@ -557,8 +566,9 @@ export class Sumak<DB> {

/** 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)
}

Expand Down
80 changes: 80 additions & 0 deletions test/audit23-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})