Skip to content

Commit b5dcb62

Browse files
brainkimclaude
andauthored
fix: remove fake APIs from README, add explain/getColumns to Driver (#11)
- Remove nonexistent APIs from README: Users.ddl(), Posts.ensureColumn(), Posts.ensureIndex(), Users.copyColumn() - Document real APIs: db.ensureTable(), db.ensureView(), db.ensureConstraints(), db.copyColumn(), db.explain() - Make getColumns required on Driver interface (was optional) - Add explain() to Driver interface - each driver owns its EXPLAIN syntax - Remove dialect-switching fallback from database.ts #checkColumnExists 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent d6a4b83 commit b5dcb62

6 files changed

Lines changed: 125 additions & 84 deletions

File tree

README.md

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -595,14 +595,16 @@ IndexedDB-style event-based migrations:
595595
db.addEventListener("upgradeneeded", (e) => {
596596
e.waitUntil((async () => {
597597
if (e.oldVersion < 1) {
598-
await db.exec`${Users.ddl()}`;
599-
await db.exec`${Posts.ddl()}`;
598+
await db.ensureTable(Users);
599+
await db.ensureTable(Posts);
600600
}
601601
if (e.oldVersion < 2) {
602-
await db.exec`${Posts.ensureColumn("views")}`;
602+
// Add a new column - just update the schema and call ensureTable again
603+
await db.ensureTable(Posts); // Adds missing "views" column
603604
}
604605
if (e.oldVersion < 3) {
605-
await db.exec`${Posts.ensureIndex(["title"])}`;
606+
// Add constraints after data cleanup
607+
await db.ensureConstraints(Posts);
606608
}
607609
})());
608610
});
@@ -623,42 +625,51 @@ await db.open(3); // Opens at version 3, fires upgradeneeded if needed
623625
zen provides idempotent helpers that encourage safe, additive-only migrations:
624626

625627
```typescript
626-
// Add a new column (reads from schema)
628+
// Add a new column - update schema and call ensureTable
627629
const Posts = table("posts", {
628630
id: z.string().db.primary(),
629631
title: z.string(),
630632
views: z.number().db.inserted(() => 0), // NEW - add to schema
631633
});
632634

633635
if (e.oldVersion < 2) {
634-
await db.exec`${Posts.ensureColumn("views")}`;
636+
await db.ensureTable(Posts); // Adds missing columns from schema
635637
}
636-
// → ALTER TABLE "posts" ADD COLUMN IF NOT EXISTS "views" REAL DEFAULT 0
638+
// → ALTER TABLE "posts" ADD COLUMN "views" REAL DEFAULT 0
639+
640+
// Add indexes - defined in schema, applied by ensureTable
641+
const Posts = table("posts", {
642+
id: z.string().db.primary(),
643+
title: z.string().db.index(), // NEW - add index
644+
views: z.number().db.inserted(() => 0),
645+
});
637646

638-
// Add an index
639647
if (e.oldVersion < 3) {
640-
await db.exec`${Posts.ensureIndex(["title", "views"])}`;
648+
await db.ensureTable(Posts); // Adds missing indexes
641649
}
642-
// → CREATE INDEX IF NOT EXISTS "idx_posts_title_views" ON "posts"("title", "views")
650+
// → CREATE INDEX IF NOT EXISTS "idx_posts_title" ON "posts"("title")
643651

644652
// Safe column rename (additive, non-destructive)
645653
const Users = table("users", {
646-
emailAddress: z.string().email(), // renamed from "email"
654+
id: z.string().db.primary(),
655+
email: z.string().email(), // Keep old column
656+
emailAddress: z.string().email(), // NEW - add new column
647657
});
648658

649659
if (e.oldVersion < 4) {
650-
await db.exec`${Users.ensureColumn("emailAddress")}`;
651-
await db.exec`${Users.copyColumn("email", "emailAddress")}`;
660+
await db.ensureTable(Users); // Adds emailAddress column
661+
await db.copyColumn(Users, "email", "emailAddress"); // Copy data
652662
// Keep old "email" column for backwards compat
653663
// Drop it in a later migration if needed (manual SQL)
654664
}
655665
// → UPDATE "users" SET "emailAddress" = "email" WHERE "emailAddress" IS NULL
656666
```
657667

658668
**Helper methods:**
659-
- `table.ensureColumn(fieldName, options?)` - Idempotent ALTER TABLE ADD COLUMN
660-
- `table.ensureIndex(fields, options?)` - Idempotent CREATE INDEX
661-
- `table.copyColumn(from, to)` - Copy data between columns (for safe renames)
669+
- `db.ensureTable(table)` - Idempotent CREATE TABLE / ADD COLUMN / CREATE INDEX
670+
- `db.ensureView(view)` - Idempotent DROP + CREATE VIEW
671+
- `db.ensureConstraints(table)` - Add unique/FK constraints (with preflight checks)
672+
- `db.copyColumn(table, from, to)` - Copy data between columns (for safe renames)
662673

663674
All helpers read from your table schema (single source of truth) and are safe to run multiple times (idempotent).
664675

@@ -937,25 +948,9 @@ const query = db.print`SELECT * FROM ${Posts} WHERE ${Posts.cols.published} = ${
937948
console.log(query.sql); // SELECT * FROM "posts" WHERE "posts"."published" = ?
938949
console.log(query.params); // [true]
939950

940-
// Inspect DDL generation
941-
const ddl = db.print`${Posts.ddl()}`;
942-
console.log(ddl.sql); // CREATE TABLE IF NOT EXISTS "posts" (...)
943-
944-
// Analyze query execution plan
945-
const plan = await db.explain`
946-
SELECT * FROM ${Posts}
947-
WHERE ${Posts.cols.authorId} = ${userId}
948-
`;
949-
console.log(plan);
950-
// SQLite: [{ detail: "SEARCH posts USING INDEX idx_posts_authorId (authorId=?)" }]
951-
// PostgreSQL: [{ "QUERY PLAN": "Index Scan using idx_posts_authorId on posts" }]
952-
953951
// Debug fragments
954952
console.log(Posts.set({ title: "Updated" }).toString());
955953
// SQLFragment { sql: "\"title\" = ?", params: ["Updated"] }
956-
957-
console.log(Posts.ddl().toString());
958-
// DDLFragment { type: "create-table", table: "posts" }
959954
```
960955

961956
## Dialect Support
@@ -1085,12 +1080,6 @@ const Posts = table("posts", {
10851080

10861081
const rows = [{id: "u1", email: "alice@example.com", deletedAt: null}];
10871082

1088-
// DDL Generation
1089-
Users.ddl(); // DDLFragment for CREATE TABLE
1090-
Users.ensureColumn("emailAddress"); // DDLFragment for ALTER TABLE ADD COLUMN
1091-
Users.ensureIndex(["email"]); // DDLFragment for CREATE INDEX
1092-
Users.copyColumn("email", "emailAddress"); // SQLFragment for UPDATE (copy data)
1093-
10941083
// Query Fragments
10951084
Users.set({email: "alice@example.com"}); // SQLFragment for SET clause
10961085
Users.values(rows); // SQLFragment for INSERT VALUES
@@ -1157,9 +1146,15 @@ await db.transaction(async (tx) => {
11571146
await tx.exec`SELECT 1`;
11581147
});
11591148

1149+
// Schema Management
1150+
await db.ensureTable(Users); // CREATE TABLE / ADD COLUMN / CREATE INDEX
1151+
await db.ensureView(AdminUsers); // DROP + CREATE VIEW
1152+
await db.ensureConstraints(Users); // Add unique/FK constraints
1153+
await db.copyColumn(Users, "old", "new"); // Copy data between columns
1154+
11601155
// Debugging
1161-
db.print`SELECT 1`;
1162-
await db.explain`SELECT * FROM ${Users}`;
1156+
db.print`SELECT 1`; // Returns { sql, params } without executing
1157+
await db.explain`SELECT * FROM ${Users}`; // Returns query execution plan
11631158
```
11641159

11651160
### Driver Exports

src/bun.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,8 @@ export default class BunDriver implements Driver {
532532
transaction: async () => {
533533
throw new Error("Nested transactions are not supported");
534534
},
535+
getColumns: this.getColumns.bind(this),
536+
explain: this.explain.bind(this),
535537
};
536538

537539
return await fn(txDriver);
@@ -733,6 +735,20 @@ export default class BunDriver implements Driver {
733735
return await this.#getColumns(tableName);
734736
}
735737

738+
async explain(
739+
strings: TemplateStringsArray,
740+
values: unknown[],
741+
): Promise<Record<string, unknown>[]> {
742+
await this.#ensureSqliteInit();
743+
const {sql, params} = buildSQL(strings, values, this.#dialect);
744+
const explainPrefix =
745+
this.#dialect === "sqlite" ? "EXPLAIN QUERY PLAN " : "EXPLAIN ";
746+
return (await this.#sql.unsafe(
747+
explainPrefix + sql,
748+
params as any[],
749+
)) as Record<string, unknown>[];
750+
}
751+
736752
// ==========================================================================
737753
// Introspection Helpers (private)
738754
// ==========================================================================

src/impl/database.ts

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import {
2828
makeTemplate,
2929
} from "./template.js";
3030
import {EnsureError} from "./errors.js";
31-
3231
// ============================================================================
3332
// DB Expressions - Runtime values evaluated by the database
3433
// ============================================================================
@@ -458,10 +457,16 @@ export interface Driver {
458457
toField: string,
459458
): Promise<number>;
460459

461-
/** Optional introspection: list columns for a table (name, type, nullability). */
462-
getColumns?(
460+
/** Introspection: list columns for a table (name, type, nullability). */
461+
getColumns(
463462
tableName: string,
464463
): Promise<{name: string; type?: string; notnull?: boolean}[]>;
464+
465+
/** Get the query execution plan for a SQL query. */
466+
explain(
467+
strings: TemplateStringsArray,
468+
values: unknown[],
469+
): Promise<Record<string, unknown>[]>;
465470
}
466471

467472
// ============================================================================
@@ -3103,6 +3108,30 @@ export class Database extends EventTarget {
31033108
return {sql, params: expandedValues};
31043109
}
31053110

3111+
/**
3112+
* Get the query execution plan without running the query.
3113+
*
3114+
* Returns the database's EXPLAIN output for the given query.
3115+
* The format varies by database:
3116+
* - SQLite: EXPLAIN QUERY PLAN output
3117+
* - PostgreSQL: EXPLAIN output
3118+
* - MySQL: EXPLAIN output
3119+
*
3120+
* @example
3121+
* const plan = await db.explain`SELECT * FROM ${Users} WHERE email = ${"test@example.com"}`;
3122+
* console.log(plan);
3123+
*/
3124+
async explain(
3125+
strings: TemplateStringsArray,
3126+
...values: unknown[]
3127+
): Promise<Record<string, unknown>[]> {
3128+
const {strings: expandedStrings, values: expandedValues} = expandFragments(
3129+
strings,
3130+
values,
3131+
);
3132+
return await this.#driver.explain(expandedStrings, expandedValues);
3133+
}
3134+
31063135
// ==========================================================================
31073136
// Schema Ensure Methods
31083137
// ==========================================================================
@@ -3316,41 +3345,8 @@ export class Database extends EventTarget {
33163345
tableName: string,
33173346
columnName: string,
33183347
): Promise<boolean> {
3319-
// Use driver's getColumns if available
3320-
if (this.#driver.getColumns) {
3321-
const columns = await this.#driver.getColumns(tableName);
3322-
return columns.some((col) => col.name === columnName);
3323-
}
3324-
3325-
// Try PRAGMA table_info first (SQLite)
3326-
try {
3327-
const pragmaStrings = makeTemplate(["PRAGMA table_info(", ")"]);
3328-
const pragmaValues = [ident(tableName)];
3329-
const columns = await this.#driver.all<{name: string}>(
3330-
pragmaStrings,
3331-
pragmaValues,
3332-
);
3333-
if (columns.length > 0) {
3334-
return columns.some((col) => col.name === columnName);
3335-
}
3336-
} catch {
3337-
// Not SQLite, try information_schema
3338-
}
3339-
3340-
// Fallback: information_schema (PostgreSQL/MySQL)
3341-
try {
3342-
const schemaStrings = makeTemplate([
3343-
"SELECT column_name FROM information_schema.columns WHERE table_name = ",
3344-
" AND column_name = ",
3345-
" LIMIT 1",
3346-
]);
3347-
const schemaValues = [tableName, columnName];
3348-
const result = await this.#driver.all(schemaStrings, schemaValues);
3349-
return result.length > 0;
3350-
} catch {
3351-
// Last resort: assume column exists and let the UPDATE fail naturally
3352-
return true;
3353-
}
3348+
const columns = await this.#driver.getColumns(tableName);
3349+
return columns.some((col) => col.name === columnName);
33543350
}
33553351

33563352
// ==========================================================================

src/mysql.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,8 @@ export default class MySQLDriver implements Driver {
379379
transaction: async () => {
380380
throw new Error("Nested transactions are not supported");
381381
},
382+
getColumns: this.getColumns.bind(this),
383+
explain: this.explain.bind(this),
382384
};
383385

384386
const result = await fn(txDriver);
@@ -604,7 +606,7 @@ export default class MySQLDriver implements Driver {
604606
return ((rows as any[])[0]?.count ?? 0) > 0;
605607
}
606608

607-
async #getColumns(
609+
async getColumns(
608610
tableName: string,
609611
): Promise<{name: string; type: string; notnull: boolean}[]> {
610612
const [rows] = await this.#pool.execute<mysql.RowDataPacket[]>(
@@ -619,6 +621,15 @@ export default class MySQLDriver implements Driver {
619621
}));
620622
}
621623

624+
async explain(
625+
strings: TemplateStringsArray,
626+
values: unknown[],
627+
): Promise<Record<string, unknown>[]> {
628+
const {sql, params} = buildSQL(strings, values);
629+
const [rows] = await this.#pool.execute(`EXPLAIN ${sql}`, params);
630+
return rows as Record<string, unknown>[];
631+
}
632+
622633
async #getIndexes(
623634
tableName: string,
624635
): Promise<{name: string; columns: string[]; unique: boolean}[]> {
@@ -698,7 +709,7 @@ export default class MySQLDriver implements Driver {
698709
}
699710

700711
async #ensureMissingColumns(table: Table): Promise<boolean> {
701-
const existingCols = await this.#getColumns(table.name);
712+
const existingCols = await this.getColumns(table.name);
702713
const existingColNames = new Set(existingCols.map((c) => c.name));
703714
const schemaFields = Object.keys(table.schema.shape);
704715

src/postgres.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@ export default class PostgresDriver implements Driver {
331331
transaction: async () => {
332332
throw new Error("Nested transactions are not supported");
333333
},
334+
getColumns: this.getColumns.bind(this),
335+
explain: this.explain.bind(this),
334336
};
335337

336338
return await fn(txDriver);
@@ -541,7 +543,7 @@ export default class PostgresDriver implements Driver {
541543
return result[0]?.exists ?? false;
542544
}
543545

544-
async #getColumns(
546+
async getColumns(
545547
tableName: string,
546548
): Promise<{name: string; type: string; notnull: boolean}[]> {
547549
const result = await this.#sql<
@@ -561,6 +563,17 @@ export default class PostgresDriver implements Driver {
561563
}));
562564
}
563565

566+
async explain(
567+
strings: TemplateStringsArray,
568+
values: unknown[],
569+
): Promise<Record<string, unknown>[]> {
570+
const {sql, params} = buildSQL(strings, values);
571+
return await this.#sql.unsafe<Record<string, unknown>[]>(
572+
`EXPLAIN ${sql}`,
573+
params as any[],
574+
);
575+
}
576+
564577
async #getIndexes(
565578
tableName: string,
566579
): Promise<{name: string; columns: string[]; unique: boolean}[]> {
@@ -651,7 +664,7 @@ export default class PostgresDriver implements Driver {
651664
}
652665

653666
async #ensureMissingColumns(table: Table): Promise<boolean> {
654-
const existingCols = await this.#getColumns(table.name);
667+
const existingCols = await this.getColumns(table.name);
655668
const existingColNames = new Set(existingCols.map((c) => c.name));
656669
const schemaFields = Object.keys(table.schema.shape);
657670

src/sqlite.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -482,7 +482,7 @@ export default class SQLiteDriver implements Driver {
482482
return result.length > 0;
483483
}
484484

485-
async #getColumns(
485+
async getColumns(
486486
tableName: string,
487487
): Promise<{name: string; type: string; notnull: boolean}[]> {
488488
const result = this.#db
@@ -495,6 +495,16 @@ export default class SQLiteDriver implements Driver {
495495
}));
496496
}
497497

498+
async explain(
499+
strings: TemplateStringsArray,
500+
values: unknown[],
501+
): Promise<Record<string, unknown>[]> {
502+
const {sql, params} = buildSQL(strings, values);
503+
return this.#db
504+
.prepare(`EXPLAIN QUERY PLAN ${sql}`)
505+
.all(...params) as Record<string, unknown>[];
506+
}
507+
498508
async #getIndexes(
499509
tableName: string,
500510
): Promise<{name: string; columns: string[]; unique: boolean}[]> {
@@ -579,7 +589,7 @@ export default class SQLiteDriver implements Driver {
579589
async #ensureMissingColumns<T extends Table<any>>(
580590
table: T,
581591
): Promise<boolean> {
582-
const existingCols = await this.#getColumns(table.name);
592+
const existingCols = await this.getColumns(table.name);
583593
const existingColNames = new Set(existingCols.map((c) => c.name));
584594
const schemaFields = Object.keys(table.schema.shape);
585595

0 commit comments

Comments
 (0)