@suluk/drizzle 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ddl.ts +50 -0
- package/src/index.ts +2 -0
- package/src/meta.ts +15 -0
- package/test/ddl.test.ts +56 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/drizzle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Drizzle ORM schema -> v4 'Suluk' contract: table -> Zod (drizzle-zod) -> v4 Schema Objects, DB metadata, and generated CRUD RouteContracts. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
".": "./src/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@suluk/core": "^0.1.
|
|
22
|
+
"@suluk/core": "^0.1.11",
|
|
23
23
|
"@suluk/zod": "^0.1.2",
|
|
24
24
|
"@suluk/hono": "^0.1.2"
|
|
25
25
|
},
|
package/src/ddl.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Emit SQLite `CREATE TABLE` DDL from a drizzle table's {@link tableMetadata} — the generator that lets a dev
|
|
3
|
+
* in-memory bun:sqlite DB be built FROM the Drizzle schema instead of a hand-mirrored SQL string that silently
|
|
4
|
+
* drifts. Reads only the honest metadata floor (types, notNull, defaults, PK/autoincrement); identifiers are
|
|
5
|
+
* quoted so reserved words (e.g. `order`) are safe. Booleans map to INTEGER (drizzle's storage), enums to plain
|
|
6
|
+
* TEXT (drizzle adds no CHECK). Prod migrations stay the source of truth for prod; this is the dev-schema twin.
|
|
7
|
+
*/
|
|
8
|
+
import { tableMetadata, type AnyTable, type ColumnMeta, type TableMeta } from "./meta";
|
|
9
|
+
|
|
10
|
+
const SQLITE_TYPE: Record<string, string> = {
|
|
11
|
+
SQLiteInteger: "INTEGER", SQLiteBoolean: "INTEGER", SQLiteTimestamp: "INTEGER",
|
|
12
|
+
SQLiteText: "TEXT", SQLiteTextJson: "TEXT", SQLiteReal: "REAL", SQLiteNumeric: "NUMERIC", SQLiteBlob: "BLOB",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Double-quote an identifier (table/column) so reserved words + odd names are always safe. */
|
|
16
|
+
const q = (id: string): string => `"${id.replace(/"/g, '""')}"`;
|
|
17
|
+
/** A SQL literal for a static default: booleans → 0/1, numbers verbatim, strings single-quoted. */
|
|
18
|
+
const lit = (v: string | number | boolean): string =>
|
|
19
|
+
typeof v === "boolean" ? (v ? "1" : "0") : typeof v === "number" ? String(v) : `'${String(v).replace(/'/g, "''")}'`;
|
|
20
|
+
|
|
21
|
+
function columnDDL(c: ColumnMeta): string {
|
|
22
|
+
const parts = [q(c.sqlName), SQLITE_TYPE[c.columnType] ?? "TEXT"];
|
|
23
|
+
if (c.primaryKey) parts.push(c.autoIncrement ? "PRIMARY KEY AUTOINCREMENT" : "PRIMARY KEY");
|
|
24
|
+
else if (c.notNull) parts.push("NOT NULL");
|
|
25
|
+
if (c.defaultValue !== undefined) parts.push("DEFAULT " + lit(c.defaultValue));
|
|
26
|
+
return parts.join(" ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DdlOptions {
|
|
30
|
+
/** prefix with `IF NOT EXISTS` (default true). */
|
|
31
|
+
ifNotExists?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `CREATE TABLE` DDL for one drizzle table (or its already-read metadata). Single-column primary keys only — a
|
|
36
|
+
* table-level composite `primaryKey({columns})` isn't visible on the column-descriptor floor (it needs
|
|
37
|
+
* dialect-specific `getTableConfig`, deferred like FK/relation projection); such a table emits its columns without
|
|
38
|
+
* the composite constraint, so declare those tables' DDL by hand for now.
|
|
39
|
+
*/
|
|
40
|
+
export function tableDDL(table: AnyTable | TableMeta, opts: DdlOptions = {}): string {
|
|
41
|
+
const m: TableMeta = "columns" in table ? table : tableMetadata(table);
|
|
42
|
+
const cols = m.columns.map(columnDDL).join(", ");
|
|
43
|
+
const exists = opts.ifNotExists === false ? "" : "IF NOT EXISTS ";
|
|
44
|
+
return `CREATE TABLE ${exists}${q(m.name)} (${cols});`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `CREATE TABLE` DDL for many tables, newline-joined — the dev-schema twin of the prod migrations. */
|
|
48
|
+
export function schemaDDL(tables: (AnyTable | TableMeta)[], opts: DdlOptions = {}): string {
|
|
49
|
+
return tables.map((t) => tableDDL(t, opts)).join("\n");
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -39,3 +39,5 @@ export {
|
|
|
39
39
|
softDeleteValues, anonymizeValues, touchTimestamps, notSoftDeleted,
|
|
40
40
|
type SoftDeleteOptions, type TimestampOptions,
|
|
41
41
|
} from "./mutations";
|
|
42
|
+
// SQLite CREATE TABLE generator — build a dev in-memory schema FROM the Drizzle tables (no hand-mirrored SQL drift).
|
|
43
|
+
export { tableDDL, schemaDDL, type DdlOptions } from "./ddl";
|
package/src/meta.ts
CHANGED
|
@@ -12,7 +12,10 @@ export type AnyTable = Parameters<typeof getTableColumns>[0];
|
|
|
12
12
|
|
|
13
13
|
/** One column's metadata, lifted from drizzle's column descriptor (verified against drizzle-orm 0.45). */
|
|
14
14
|
export interface ColumnMeta {
|
|
15
|
+
/** the JS property key on the table object (e.g. `reviewId`) — the v4 component property name. */
|
|
15
16
|
name: string;
|
|
17
|
+
/** the SQL column name (e.g. `review_id`) — what DDL + raw SQL must use; differs from `name` under camel/snake. */
|
|
18
|
+
sqlName: string;
|
|
16
19
|
/** drizzle's coarse JS dataType, e.g. "string" | "number" | "boolean" | "date". */
|
|
17
20
|
dataType: string;
|
|
18
21
|
/** drizzle's concrete column type tag, e.g. "SQLiteText" | "SQLiteInteger". */
|
|
@@ -23,10 +26,15 @@ export interface ColumnMeta {
|
|
|
23
26
|
hasDefault: boolean;
|
|
24
27
|
/** Part of the (single-column) primary key. */
|
|
25
28
|
primaryKey: boolean;
|
|
29
|
+
/** An AUTOINCREMENT primary key (SQLite integer PK declared with autoIncrement). */
|
|
30
|
+
autoIncrement: boolean;
|
|
26
31
|
/** Carries a column-level UNIQUE constraint (drizzle's `.unique()` / `isUnique`). */
|
|
27
32
|
unique: boolean;
|
|
28
33
|
/** SQL CHECK/enum allowed values when the column was declared with `{ enum: [...] }`. */
|
|
29
34
|
enumValues?: string[];
|
|
35
|
+
/** The STATIC default value (number/string/boolean) when the column carries one — for DDL emit. Absent for a
|
|
36
|
+
* runtime `$defaultFn` column (hasDefault true, no SQL-literal value) and for autoincrement PKs. */
|
|
37
|
+
defaultValue?: string | number | boolean;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export interface TableMeta {
|
|
@@ -57,19 +65,26 @@ export function tableMetadata(table: AnyTable): TableMeta {
|
|
|
57
65
|
notNull: boolean;
|
|
58
66
|
hasDefault: boolean;
|
|
59
67
|
primary: boolean;
|
|
68
|
+
autoIncrement?: boolean;
|
|
60
69
|
isUnique?: boolean;
|
|
61
70
|
enumValues?: string[];
|
|
71
|
+
default?: unknown;
|
|
72
|
+
name?: string;
|
|
62
73
|
};
|
|
74
|
+
const staticDefault = c.hasDefault && (typeof c.default === "string" || typeof c.default === "number" || typeof c.default === "boolean");
|
|
63
75
|
const meta: ColumnMeta = {
|
|
64
76
|
name,
|
|
77
|
+
sqlName: c.name ?? name, // drizzle's column descriptor carries the SQL name; fall back to the JS key
|
|
65
78
|
dataType: c.dataType,
|
|
66
79
|
columnType: c.columnType,
|
|
67
80
|
notNull: !!c.notNull,
|
|
68
81
|
hasDefault: !!c.hasDefault,
|
|
69
82
|
primaryKey: !!c.primary,
|
|
83
|
+
autoIncrement: !!c.autoIncrement,
|
|
70
84
|
unique: !!c.isUnique,
|
|
71
85
|
// enumValues is often an empty array on non-enum columns — only surface a non-empty one.
|
|
72
86
|
...(Array.isArray(c.enumValues) && c.enumValues.length ? { enumValues: c.enumValues } : {}),
|
|
87
|
+
...(staticDefault ? { defaultValue: c.default as string | number | boolean } : {}),
|
|
73
88
|
};
|
|
74
89
|
columns.push(meta);
|
|
75
90
|
if (meta.primaryKey) primaryKey.push(name);
|
package/test/ddl.test.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tableDDL / schemaDDL — generate SQLite CREATE TABLE from a Drizzle table. Asserts the type/default/PK mapping,
|
|
3
|
+
* reserved-word quoting, AND the SQL-column-name (snake_case) vs JS-key (camelCase) distinction, then round-trips:
|
|
4
|
+
* the generated DDL runs in a real bun:sqlite DB and accepts inserts that honor the defaults — proof the schema is
|
|
5
|
+
* valid + faithful, not just string-shaped.
|
|
6
|
+
*/
|
|
7
|
+
import { test, expect, describe } from "bun:test";
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
|
|
10
|
+
import { tableDDL, schemaDDL } from "../src/index";
|
|
11
|
+
|
|
12
|
+
// "order" is a SQLite reserved word; customer_id (camel JS key) exercises the SQL-name path; boolean+enum+defaults the mapping.
|
|
13
|
+
const order = sqliteTable("order", {
|
|
14
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
15
|
+
customerId: text("customer_id"), // camelCase key → snake_case SQL name
|
|
16
|
+
total: integer("total").notNull().default(0),
|
|
17
|
+
status: text("status", { enum: ["pending", "paid"] }).notNull().default("pending"),
|
|
18
|
+
paid: integer("paid", { mode: "boolean" }).notNull().default(false),
|
|
19
|
+
note: text("note"),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("tableDDL", () => {
|
|
23
|
+
const ddl = tableDDL(order);
|
|
24
|
+
test("quotes reserved table names + maps types + uses the SQL column name", () => {
|
|
25
|
+
expect(ddl).toContain('CREATE TABLE IF NOT EXISTS "order"');
|
|
26
|
+
expect(ddl).toContain('"customer_id" TEXT'); // SQL name, not the JS key "customerId"
|
|
27
|
+
expect(ddl).not.toContain("customerId");
|
|
28
|
+
expect(ddl).toContain('"total" INTEGER NOT NULL DEFAULT 0');
|
|
29
|
+
expect(ddl).toContain('"note" TEXT'); // nullable, no default
|
|
30
|
+
expect(ddl).not.toContain('"note" TEXT NOT NULL');
|
|
31
|
+
});
|
|
32
|
+
test("autoincrement PK, boolean→INTEGER 0/1, string defaults quoted", () => {
|
|
33
|
+
expect(ddl).toContain('"id" INTEGER PRIMARY KEY AUTOINCREMENT');
|
|
34
|
+
expect(ddl).toContain('"paid" INTEGER NOT NULL DEFAULT 0'); // boolean false → 0, type INTEGER
|
|
35
|
+
expect(ddl).toContain(`"status" TEXT NOT NULL DEFAULT 'pending'`);
|
|
36
|
+
});
|
|
37
|
+
test("ifNotExists:false drops the guard", () => {
|
|
38
|
+
expect(tableDDL(order, { ifNotExists: false })).toContain('CREATE TABLE "order"');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("round-trip in a real bun:sqlite DB", () => {
|
|
43
|
+
test("the generated DDL is valid SQL + applies the defaults under the SQL column names", () => {
|
|
44
|
+
const db = new Database(":memory:");
|
|
45
|
+
db.exec(schemaDDL([order]));
|
|
46
|
+
db.exec(`INSERT INTO "order" (customer_id, note) VALUES ('u1', 'hi')`); // rely on total/status/paid defaults
|
|
47
|
+
const row = db.query(`SELECT id, customer_id, total, status, paid, note FROM "order"`).get() as Record<string, unknown>;
|
|
48
|
+
expect(row.id).toBe(1); // autoincrement
|
|
49
|
+
expect(row.customer_id).toBe("u1"); // snake_case column exists
|
|
50
|
+
expect(row.total).toBe(0); // default 0
|
|
51
|
+
expect(row.status).toBe("pending"); // default 'pending'
|
|
52
|
+
expect(row.paid).toBe(0); // boolean default false → 0
|
|
53
|
+
expect(row.note).toBe("hi");
|
|
54
|
+
db.close();
|
|
55
|
+
});
|
|
56
|
+
});
|