@suluk/drizzle 0.1.1 → 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/README.md +35 -0
- package/package.json +19 -8
- package/src/crud.ts +25 -2
- package/src/ddl.ts +50 -0
- package/src/index.ts +9 -0
- package/src/meta.ts +24 -1
- package/src/mutations.ts +41 -0
- package/src/query.ts +95 -0
- package/test/ddl.test.ts +56 -0
- package/test/depth.test.ts +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/MahmoodKhalil57/suluk">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">@suluk/drizzle</h1>
|
|
8
|
+
|
|
9
|
+
<p align="center"><b>Drizzle ORM schema -> v4 'Suluk' contract: table -> Zod (drizzle-zod) -> v4 Schema Objects, DB metadata, and generated CRUD RouteContracts.</b></p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
18
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
19
|
+
> to ratify anything on the SIG's behalf.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun add @suluk/drizzle
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The Suluk cycle
|
|
28
|
+
|
|
29
|
+
`@suluk/drizzle` is one station on the Suluk walk — author one v4 source, then **validate · audit ·
|
|
30
|
+
preview · generate · deploy** the whole stack from it. Explore the full toolchain in the
|
|
31
|
+
[main repository](https://github.com/MahmoodKhalil57/suluk) or drive it from the [VS Code cockpit](https://marketplace.visualstudio.com/items?itemName=MahmoodKhalil.suluk-vscode).
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
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
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/drizzle"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
9
20
|
},
|
|
10
21
|
"dependencies": {
|
|
11
|
-
"@suluk/core": "0.1.
|
|
12
|
-
"@suluk/zod": "0.1.
|
|
13
|
-
"@suluk/hono": "0.1.
|
|
22
|
+
"@suluk/core": "^0.1.11",
|
|
23
|
+
"@suluk/zod": "^0.1.2",
|
|
24
|
+
"@suluk/hono": "^0.1.2"
|
|
14
25
|
},
|
|
15
26
|
"peerDependencies": {
|
|
16
27
|
"drizzle-orm": "^0.4.0 || ^0.30.0 || ^0.40.0 || ^0.45.0",
|
package/src/crud.ts
CHANGED
|
@@ -9,12 +9,25 @@ import { getTableName } from "drizzle-orm";
|
|
|
9
9
|
import type { RouteContract, RouteResponse } from "@suluk/hono";
|
|
10
10
|
import { tableSchemas } from "./schemas";
|
|
11
11
|
import { pascalCase, type AnyTable } from "./meta";
|
|
12
|
+
import { listQuerySchema, type ListQueryOptions } from "./query";
|
|
12
13
|
|
|
13
14
|
export interface CrudOptions {
|
|
14
15
|
/** Base path for the collection. Default "/" + tableName, e.g. "/users". */
|
|
15
16
|
basePath?: string;
|
|
16
17
|
/** Path-param name for the item id. Default "id" ⇒ ".../:id". */
|
|
17
18
|
idParam?: string;
|
|
19
|
+
/** Declare list query params (page/perPage/sort/order/q) on the list route. Default true; pass options to scope. */
|
|
20
|
+
listQuery?: boolean | ListQueryOptions;
|
|
21
|
+
/**
|
|
22
|
+
* SOFT delete: DELETE marks the row (sets a deletedAt column) instead of removing it, so the projected DELETE
|
|
23
|
+
* returns the affected row (200), not 204. The patch is built at runtime by `softDeleteValues`.
|
|
24
|
+
*/
|
|
25
|
+
softDelete?: boolean | { column?: string };
|
|
26
|
+
/**
|
|
27
|
+
* ANONYMIZE on delete (GDPR keep-record): DELETE redacts these columns instead of removing the row. Like
|
|
28
|
+
* softDelete, the projected DELETE returns the affected row (200). The patch comes from `anonymizeValues`.
|
|
29
|
+
*/
|
|
30
|
+
anonymizeDelete?: { columns: string[] };
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
/**
|
|
@@ -41,6 +54,15 @@ export function crudRoutes(table: AnyTable, opts: CrudOptions = {}): RouteContra
|
|
|
41
54
|
const ok = (status: number, schema: z.ZodType, description: string): RouteResponse => ({ status, schema, description });
|
|
42
55
|
const bare = (status: number, description: string): RouteResponse => ({ status, description });
|
|
43
56
|
|
|
57
|
+
// a soft-delete / anonymize-delete keeps the row, so DELETE returns it (200) rather than 204.
|
|
58
|
+
const softening = opts.softDelete || opts.anonymizeDelete;
|
|
59
|
+
const deleteResponses: RouteResponse[] = softening
|
|
60
|
+
? [ok(200, select, `The ${tableName} row, after a soft delete / anonymize.`), bare(404, "Not found.")]
|
|
61
|
+
: [bare(204, "Deleted.")];
|
|
62
|
+
|
|
63
|
+
const listQueryOpts: ListQueryOptions | undefined =
|
|
64
|
+
opts.listQuery === false ? undefined : typeof opts.listQuery === "object" ? opts.listQuery : {};
|
|
65
|
+
|
|
44
66
|
return [
|
|
45
67
|
{
|
|
46
68
|
method: "get",
|
|
@@ -48,6 +70,7 @@ export function crudRoutes(table: AnyTable, opts: CrudOptions = {}): RouteContra
|
|
|
48
70
|
name: `list${Pascal}`,
|
|
49
71
|
summary: `List ${tableName}`,
|
|
50
72
|
tags: [tableName],
|
|
73
|
+
...(listQueryOpts ? { request: { query: listQuerySchema(table, listQueryOpts) } } : {}),
|
|
51
74
|
responses: [ok(200, z.array(select), `A page of ${tableName}.`)],
|
|
52
75
|
},
|
|
53
76
|
{
|
|
@@ -81,10 +104,10 @@ export function crudRoutes(table: AnyTable, opts: CrudOptions = {}): RouteContra
|
|
|
81
104
|
method: "delete",
|
|
82
105
|
path: itemPath,
|
|
83
106
|
name: `delete${Pascal}`,
|
|
84
|
-
summary:
|
|
107
|
+
summary: `${softening ? "Soft-delete" : "Delete"} a ${tableName} row by ${idParam}`,
|
|
85
108
|
tags: [tableName],
|
|
86
109
|
request: { params: idParams },
|
|
87
|
-
responses:
|
|
110
|
+
responses: deleteResponses,
|
|
88
111
|
},
|
|
89
112
|
];
|
|
90
113
|
}
|
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
|
@@ -32,3 +32,12 @@ export {
|
|
|
32
32
|
} from "./schemas";
|
|
33
33
|
|
|
34
34
|
export { crudRoutes, type CrudOptions } from "./crud";
|
|
35
|
+
// list query-param synthesis (Phase 1): declare page/perPage/sort/order/q + the pure parser the handler uses.
|
|
36
|
+
export { listQuerySchema, parseListQuery, type ListQuery, type ListQueryOptions } from "./query";
|
|
37
|
+
// CrudOptions runtime helpers (Phase 1): soft-delete / anonymize-on-delete (GDPR keep-record) / timestamps patches.
|
|
38
|
+
export {
|
|
39
|
+
softDeleteValues, anonymizeValues, touchTimestamps, notSoftDeleted,
|
|
40
|
+
type SoftDeleteOptions, type TimestampOptions,
|
|
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,14 +26,23 @@ 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;
|
|
31
|
+
/** Carries a column-level UNIQUE constraint (drizzle's `.unique()` / `isUnique`). */
|
|
32
|
+
unique: boolean;
|
|
26
33
|
/** SQL CHECK/enum allowed values when the column was declared with `{ enum: [...] }`. */
|
|
27
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;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
export interface TableMeta {
|
|
31
41
|
name: string;
|
|
32
42
|
/** Column names flagged `primary` (ordered as drizzle reports the columns). */
|
|
33
43
|
primaryKey: string[];
|
|
44
|
+
/** Column names carrying a UNIQUE constraint (the natural keys for upsert / by-field lookup). */
|
|
45
|
+
unique: string[];
|
|
34
46
|
columns: ColumnMeta[];
|
|
35
47
|
}
|
|
36
48
|
|
|
@@ -43,6 +55,7 @@ export function tableMetadata(table: AnyTable): TableMeta {
|
|
|
43
55
|
const cols = getTableColumns(table);
|
|
44
56
|
const columns: ColumnMeta[] = [];
|
|
45
57
|
const primaryKey: string[] = [];
|
|
58
|
+
const unique: string[] = [];
|
|
46
59
|
|
|
47
60
|
for (const [name, col] of Object.entries(cols)) {
|
|
48
61
|
// drizzle's descriptor surface — read defensively (any dialect, any version in our peer range).
|
|
@@ -52,23 +65,33 @@ export function tableMetadata(table: AnyTable): TableMeta {
|
|
|
52
65
|
notNull: boolean;
|
|
53
66
|
hasDefault: boolean;
|
|
54
67
|
primary: boolean;
|
|
68
|
+
autoIncrement?: boolean;
|
|
69
|
+
isUnique?: boolean;
|
|
55
70
|
enumValues?: string[];
|
|
71
|
+
default?: unknown;
|
|
72
|
+
name?: string;
|
|
56
73
|
};
|
|
74
|
+
const staticDefault = c.hasDefault && (typeof c.default === "string" || typeof c.default === "number" || typeof c.default === "boolean");
|
|
57
75
|
const meta: ColumnMeta = {
|
|
58
76
|
name,
|
|
77
|
+
sqlName: c.name ?? name, // drizzle's column descriptor carries the SQL name; fall back to the JS key
|
|
59
78
|
dataType: c.dataType,
|
|
60
79
|
columnType: c.columnType,
|
|
61
80
|
notNull: !!c.notNull,
|
|
62
81
|
hasDefault: !!c.hasDefault,
|
|
63
82
|
primaryKey: !!c.primary,
|
|
83
|
+
autoIncrement: !!c.autoIncrement,
|
|
84
|
+
unique: !!c.isUnique,
|
|
64
85
|
// enumValues is often an empty array on non-enum columns — only surface a non-empty one.
|
|
65
86
|
...(Array.isArray(c.enumValues) && c.enumValues.length ? { enumValues: c.enumValues } : {}),
|
|
87
|
+
...(staticDefault ? { defaultValue: c.default as string | number | boolean } : {}),
|
|
66
88
|
};
|
|
67
89
|
columns.push(meta);
|
|
68
90
|
if (meta.primaryKey) primaryKey.push(name);
|
|
91
|
+
if (meta.unique) unique.push(name);
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
return { name: getTableName(table), primaryKey, columns };
|
|
94
|
+
return { name: getTableName(table), primaryKey, unique, columns };
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
/** "user_accounts" / "users" → "UserAccounts" / "Users". The v4 component key (C009 by-name). */
|
package/src/mutations.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CrudOptions runtime helpers (saastarter-parity Phase 1): pure value-builders for soft-delete, anonymize-on-delete,
|
|
3
|
+
* and server-managed timestamps. The package projects CONTRACTS (it runs no SQL), so these produce the PATCH an
|
|
4
|
+
* app's Drizzle handler applies — keeping the policy (which column is `deletedAt`, which columns to redact) in one
|
|
5
|
+
* place. anonymizeValues is the row-level counterpart of @suluk/better-auth's GDPR erasure cascade (the keep-record,
|
|
6
|
+
* FK-safe posture).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SoftDeleteOptions {
|
|
10
|
+
/** the timestamp column set on delete (default "deletedAt"). */
|
|
11
|
+
column?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TimestampOptions {
|
|
15
|
+
createdAt?: string;
|
|
16
|
+
updatedAt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The patch a soft delete applies — sets the deletedAt column to `now` (default current time). */
|
|
20
|
+
export function softDeleteValues(opts: SoftDeleteOptions = {}, now: Date = new Date()): Record<string, string> {
|
|
21
|
+
return { [opts.column ?? "deletedAt"]: now.toISOString() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The patch an anonymize-on-delete applies — redacts each named column to `value` (null by default). Pair with a
|
|
25
|
+
* soft-delete to keep the row (FK-safe right-to-be-forgotten). */
|
|
26
|
+
export function anonymizeValues(columns: string[], value: string | null = null): Record<string, string | null> {
|
|
27
|
+
return Object.fromEntries(columns.map((c) => [c, value]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The patch server-managed timestamps apply on write — `updatedAt` always, `createdAt` only when `creating`. */
|
|
31
|
+
export function touchTimestamps(opts: TimestampOptions = {}, creating = false, now: Date = new Date()): Record<string, string> {
|
|
32
|
+
const iso = now.toISOString();
|
|
33
|
+
const out: Record<string, string> = { [opts.updatedAt ?? "updatedAt"]: iso };
|
|
34
|
+
if (creating) out[opts.createdAt ?? "createdAt"] = iso;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The implicit list filter for a soft-deleting table — exclude rows whose deletedAt is set (unless asked to include). */
|
|
39
|
+
export function notSoftDeleted(column = "deletedAt"): { column: string; isNull: true } {
|
|
40
|
+
return { column, isNull: true };
|
|
41
|
+
}
|
package/src/query.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List query-param synthesis (saastarter-parity Phase 1). The list route today returns the whole collection; real
|
|
3
|
+
* lists are paginated, sortable, filterable, searchable. This DECLARES those query params (so they appear in the v4
|
|
4
|
+
* doc + the SDK + the conformance tests) AND ships a pure `parseListQuery` that normalizes a raw query string into
|
|
5
|
+
* `{ limit, offset, orderBy, filters, q }` the app's Drizzle handler builds its query from — one synthesis, both ends.
|
|
6
|
+
*/
|
|
7
|
+
import * as z from "zod";
|
|
8
|
+
import { tableMetadata, type AnyTable } from "./meta";
|
|
9
|
+
|
|
10
|
+
export interface ListQueryOptions {
|
|
11
|
+
/** sortable + filterable columns (default: all of the table's columns). */
|
|
12
|
+
columns?: string[];
|
|
13
|
+
/** default page size (default 20). */
|
|
14
|
+
defaultPerPage?: number;
|
|
15
|
+
/** max page size — `perPage` is clamped to it (default 100). */
|
|
16
|
+
maxPerPage?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Reserved query keys (everything else that matches a column becomes an equality filter). */
|
|
20
|
+
const RESERVED = new Set(["page", "perPage", "sort", "order", "q"]);
|
|
21
|
+
|
|
22
|
+
/** The Zod query schema for a list route: page/perPage/sort/order/q (coerced from strings). Extra column filters
|
|
23
|
+
* are read by {@link parseListQuery} at runtime (OpenAPI query params are flat, so they aren't enumerated here).
|
|
24
|
+
* `table` is OPTIONAL: with a table (or `opts.columns`) `sort` is a column enum; without either it is a free string —
|
|
25
|
+
* so the contract-projection layer (@suluk/builder), which holds a Zod entity rather than a Drizzle table, can call
|
|
26
|
+
* `listQuerySchema()` and still emit the same five params into the v4 doc + SDK. */
|
|
27
|
+
export function listQuerySchema(table?: AnyTable, opts: ListQueryOptions = {}): z.ZodType {
|
|
28
|
+
const cols = opts.columns ?? (table ? tableMetadata(table).columns.map((c) => c.name) : []);
|
|
29
|
+
const sort = cols.length ? z.enum(cols as [string, ...string[]]).optional() : z.string().optional();
|
|
30
|
+
return z.object({
|
|
31
|
+
page: z.coerce.number().int().min(1).optional(),
|
|
32
|
+
perPage: z.coerce.number().int().min(1).optional(),
|
|
33
|
+
sort,
|
|
34
|
+
order: z.enum(["asc", "desc"]).optional(),
|
|
35
|
+
q: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ListQuery {
|
|
40
|
+
/** rows to return (= perPage). */
|
|
41
|
+
limit: number;
|
|
42
|
+
/** rows to skip (= (page-1)*perPage). */
|
|
43
|
+
offset: number;
|
|
44
|
+
orderBy?: { column: string; dir: "asc" | "desc" };
|
|
45
|
+
/** free-text search term. */
|
|
46
|
+
q?: string;
|
|
47
|
+
/** column → equality value. */
|
|
48
|
+
filters: Record<string, string>;
|
|
49
|
+
page: number;
|
|
50
|
+
perPage: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type RawQuery = Record<string, string | string[] | undefined>;
|
|
54
|
+
const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
|
55
|
+
const intOr = (v: string | undefined, fallback: number) => {
|
|
56
|
+
const n = v == null ? NaN : parseInt(v, 10);
|
|
57
|
+
return Number.isFinite(n) ? n : fallback;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a raw query object into a {@link ListQuery} — pure, validating against the table's real columns:
|
|
62
|
+
* page/perPage are clamped (≥1, ≤maxPerPage); `sort` is honored only for a real column; any other key matching a
|
|
63
|
+
* column becomes an equality filter (unknown keys are ignored — no injection of arbitrary columns).
|
|
64
|
+
*/
|
|
65
|
+
export function parseListQuery(raw: RawQuery, table: AnyTable, opts: ListQueryOptions = {}): ListQuery {
|
|
66
|
+
const colSet = new Set(opts.columns ?? tableMetadata(table).columns.map((c) => c.name));
|
|
67
|
+
const defaultPer = opts.defaultPerPage ?? 20;
|
|
68
|
+
const maxPer = opts.maxPerPage ?? 100;
|
|
69
|
+
|
|
70
|
+
const page = Math.max(1, intOr(first(raw.page), 1));
|
|
71
|
+
const perPage = Math.min(maxPer, Math.max(1, intOr(first(raw.perPage), defaultPer)));
|
|
72
|
+
|
|
73
|
+
const sortCol = first(raw.sort);
|
|
74
|
+
const orderBy = sortCol && colSet.has(sortCol)
|
|
75
|
+
? { column: sortCol, dir: (first(raw.order) === "desc" ? "desc" : "asc") as "asc" | "desc" }
|
|
76
|
+
: undefined;
|
|
77
|
+
|
|
78
|
+
const filters: Record<string, string> = {};
|
|
79
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
80
|
+
if (RESERVED.has(k)) continue;
|
|
81
|
+
const val = first(v);
|
|
82
|
+
if (colSet.has(k) && val != null) filters[k] = val;
|
|
83
|
+
}
|
|
84
|
+
const q = first(raw.q) || undefined;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
limit: perPage,
|
|
88
|
+
offset: (page - 1) * perPage,
|
|
89
|
+
...(orderBy ? { orderBy } : {}),
|
|
90
|
+
...(q ? { q } : {}),
|
|
91
|
+
filters,
|
|
92
|
+
page,
|
|
93
|
+
perPage,
|
|
94
|
+
};
|
|
95
|
+
}
|
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
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { emitV4 } from "@suluk/hono";
|
|
4
|
+
import {
|
|
5
|
+
tableMetadata, crudRoutes,
|
|
6
|
+
listQuerySchema, parseListQuery,
|
|
7
|
+
softDeleteValues, anonymizeValues, touchTimestamps, notSoftDeleted,
|
|
8
|
+
} from "../src/index";
|
|
9
|
+
|
|
10
|
+
const users = sqliteTable("users", {
|
|
11
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
12
|
+
email: text("email").notNull().unique(),
|
|
13
|
+
name: text("name"),
|
|
14
|
+
deletedAt: text("deleted_at"),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("unique-index metadata", () => {
|
|
18
|
+
test("tableMetadata surfaces UNIQUE columns at the column + table level", () => {
|
|
19
|
+
const meta = tableMetadata(users);
|
|
20
|
+
expect(meta.unique).toEqual(["email"]);
|
|
21
|
+
expect(meta.columns.find((c) => c.name === "email")?.unique).toBe(true);
|
|
22
|
+
expect(meta.columns.find((c) => c.name === "name")?.unique).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("list query-param synthesis", () => {
|
|
27
|
+
test("listQuerySchema parses + coerces reserved params and validates sort against columns", () => {
|
|
28
|
+
const schema = listQuerySchema(users);
|
|
29
|
+
expect((schema as { parse: (v: unknown) => unknown }).parse({ page: "2", perPage: "10", sort: "email", order: "desc", q: "ab" }))
|
|
30
|
+
.toEqual({ page: 2, perPage: 10, sort: "email", order: "desc", q: "ab" });
|
|
31
|
+
// an unknown sort column is rejected
|
|
32
|
+
expect(() => (schema as { parse: (v: unknown) => unknown }).parse({ sort: "nope" })).toThrow();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("parseListQuery normalizes to limit/offset/orderBy/filters (clamped, column-validated)", () => {
|
|
36
|
+
const q = parseListQuery({ page: "3", perPage: "10", sort: "name", order: "desc", q: "lina", email: "a@b.co", bogus: "x" }, users);
|
|
37
|
+
expect(q).toMatchObject({ limit: 10, offset: 20, page: 3, perPage: 10, orderBy: { column: "name", dir: "desc" }, q: "lina" });
|
|
38
|
+
expect(q.filters).toEqual({ email: "a@b.co" }); // only real columns become filters; `bogus` ignored
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("parseListQuery clamps perPage to maxPerPage and defaults page/perPage", () => {
|
|
42
|
+
expect(parseListQuery({ perPage: "9999" }, users, { maxPerPage: 50 }).limit).toBe(50);
|
|
43
|
+
expect(parseListQuery({}, users)).toMatchObject({ page: 1, perPage: 20, offset: 0 });
|
|
44
|
+
// a sort on a non-existent column is dropped (no injection)
|
|
45
|
+
expect(parseListQuery({ sort: "evil" }, users).orderBy).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("soft-delete / anonymize / timestamp CrudOptions", () => {
|
|
50
|
+
test("a soft-deleting table's DELETE returns the row (200), not 204; summary says soft-delete", () => {
|
|
51
|
+
const routes = crudRoutes(users, { softDelete: true });
|
|
52
|
+
const del = routes.find((r) => r.name === "deleteUsers")!;
|
|
53
|
+
const statuses = (Array.isArray(del.responses) ? del.responses : []).map((r) => r.status);
|
|
54
|
+
expect(statuses).toContain(200);
|
|
55
|
+
expect(statuses).not.toContain(204);
|
|
56
|
+
expect(del.summary).toContain("Soft-delete");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the list route declares query params when listQuery is on (default)", () => {
|
|
60
|
+
const list = crudRoutes(users).find((r) => r.name === "listUsers")!;
|
|
61
|
+
expect(list.request?.query).toBeDefined();
|
|
62
|
+
expect(crudRoutes(users, { listQuery: false }).find((r) => r.name === "listUsers")!.request?.query).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the extended CRUD still projects to a valid v4 document", () => {
|
|
66
|
+
const { document } = emitV4(crudRoutes(users, { softDelete: true }));
|
|
67
|
+
expect(document.paths["users"].requests.listUsers).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("runtime helpers build the right patches (pure, time-injected)", () => {
|
|
71
|
+
const now = new Date("2026-06-11T00:00:00.000Z");
|
|
72
|
+
expect(softDeleteValues({}, now)).toEqual({ deletedAt: "2026-06-11T00:00:00.000Z" });
|
|
73
|
+
expect(softDeleteValues({ column: "removed_at" }, now)).toEqual({ removed_at: "2026-06-11T00:00:00.000Z" });
|
|
74
|
+
expect(anonymizeValues(["email", "name"])).toEqual({ email: null, name: null });
|
|
75
|
+
expect(anonymizeValues(["email"], "[redacted]")).toEqual({ email: "[redacted]" });
|
|
76
|
+
expect(touchTimestamps({}, true, now)).toEqual({ updatedAt: "2026-06-11T00:00:00.000Z", createdAt: "2026-06-11T00:00:00.000Z" });
|
|
77
|
+
expect(touchTimestamps({}, false, now)).toEqual({ updatedAt: "2026-06-11T00:00:00.000Z" });
|
|
78
|
+
expect(notSoftDeleted()).toEqual({ column: "deletedAt", isNull: true });
|
|
79
|
+
});
|
|
80
|
+
});
|