sqlite-zod-orm 3.0.0 → 3.2.0
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 +181 -223
- package/dist/index.js +5010 -0
- package/package.json +13 -13
- package/src/build.ts +8 -10
- package/src/database.ts +491 -0
- package/src/index.ts +24 -0
- package/src/proxy-query.ts +55 -51
- package/src/query-builder.ts +145 -6
- package/src/schema.ts +122 -0
- package/src/types.ts +195 -0
- package/src/satidb.ts +0 -1153
package/src/proxy-query.ts
CHANGED
|
@@ -1,49 +1,47 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* proxy-query.ts — Proxy-based SQL query builder
|
|
3
|
+
*
|
|
4
|
+
* Enables db.query(c => { const { users: u, posts: p } = c; ... }) syntax.
|
|
5
|
+
* Table/column access through the proxy creates ColumnNode AST nodes,
|
|
6
|
+
* which are then compiled into parameterized SQL.
|
|
7
|
+
*/
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
function qRef(alias: string, column: string): string {
|
|
12
|
-
return `${q(alias)}.${q(column)}`;
|
|
13
|
-
}
|
|
9
|
+
import { z } from 'zod';
|
|
14
10
|
|
|
15
|
-
// ----------
|
|
11
|
+
// ---------- Column Node ----------
|
|
16
12
|
|
|
17
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Represents a reference to a specific table column with an alias.
|
|
15
|
+
* Used as a building block for SQL query construction.
|
|
16
|
+
*/
|
|
18
17
|
export class ColumnNode {
|
|
19
18
|
readonly _type = 'COL' as const;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
this.table = table;
|
|
26
|
-
this.column = column;
|
|
27
|
-
this.alias = alias;
|
|
28
|
-
}
|
|
19
|
+
constructor(
|
|
20
|
+
readonly table: string,
|
|
21
|
+
readonly column: string,
|
|
22
|
+
readonly alias: string,
|
|
23
|
+
) { }
|
|
29
24
|
|
|
30
|
-
/**
|
|
31
|
-
* When used as object key via `[t.id]`, JS calls toString().
|
|
32
|
-
* This returns the qualified column name for the ORM to parse.
|
|
33
|
-
*/
|
|
25
|
+
/** Quoted alias.column for use as computed property key */
|
|
34
26
|
toString(): string {
|
|
35
|
-
return
|
|
27
|
+
return `"${this.alias}"."${this.column}"`;
|
|
36
28
|
}
|
|
37
29
|
|
|
38
|
-
|
|
39
|
-
valueOf(): string {
|
|
30
|
+
[Symbol.toPrimitive](): string {
|
|
40
31
|
return this.toString();
|
|
41
32
|
}
|
|
33
|
+
}
|
|
42
34
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
35
|
+
// ---------- SQL Quoting Helpers ----------
|
|
36
|
+
|
|
37
|
+
/** Quote an identifier with double quotes */
|
|
38
|
+
function q(name: string): string {
|
|
39
|
+
return `"${name}"`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Quote a fully qualified reference: alias.column */
|
|
43
|
+
function qRef(alias: string, column: string): string {
|
|
44
|
+
return `"${alias}"."${column}"`;
|
|
47
45
|
}
|
|
48
46
|
|
|
49
47
|
// ---------- Table Proxy ----------
|
|
@@ -62,7 +60,6 @@ function createTableProxy(
|
|
|
62
60
|
if (prop === Symbol.toPrimitive as any || prop === 'toString' || prop === 'valueOf') {
|
|
63
61
|
return undefined;
|
|
64
62
|
}
|
|
65
|
-
// Allow any property access — the column may be inferred or wildcard
|
|
66
63
|
return new ColumnNode(tableName, prop, alias);
|
|
67
64
|
},
|
|
68
65
|
ownKeys() {
|
|
@@ -100,9 +97,10 @@ export function createContextProxy(
|
|
|
100
97
|
if (typeof tableName !== 'string') return undefined;
|
|
101
98
|
|
|
102
99
|
const schema = schemas[tableName];
|
|
103
|
-
const
|
|
104
|
-
?
|
|
105
|
-
:
|
|
100
|
+
const shape = schema
|
|
101
|
+
? (schema as unknown as z.ZodObject<any>).shape
|
|
102
|
+
: {};
|
|
103
|
+
const columns = new Set(Object.keys(shape));
|
|
106
104
|
|
|
107
105
|
aliasCounter++;
|
|
108
106
|
const alias = `t${aliasCounter}`;
|
|
@@ -122,14 +120,16 @@ export function createContextProxy(
|
|
|
122
120
|
|
|
123
121
|
// ---------- Query Result Shape ----------
|
|
124
122
|
|
|
123
|
+
type AnyColumn = ColumnNode | (ColumnNode & string);
|
|
124
|
+
|
|
125
125
|
export interface ProxyQueryResult {
|
|
126
|
-
select: Record<string,
|
|
127
|
-
join?: [
|
|
126
|
+
select: Record<string, AnyColumn | undefined>;
|
|
127
|
+
join?: [AnyColumn | undefined, AnyColumn | undefined] | [AnyColumn | undefined, AnyColumn | undefined][];
|
|
128
128
|
where?: Record<string, any>;
|
|
129
129
|
orderBy?: Record<string, 'asc' | 'desc'>;
|
|
130
130
|
limit?: number;
|
|
131
131
|
offset?: number;
|
|
132
|
-
groupBy?:
|
|
132
|
+
groupBy?: (AnyColumn | undefined)[];
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
// ---------- Query Compiler ----------
|
|
@@ -173,7 +173,6 @@ export function compileProxyQuery(
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
// ---------- FROM / JOIN ----------
|
|
176
|
-
// First table in aliases is the primary; rest are joined
|
|
177
176
|
const allAliases = [...tablesUsed.values()];
|
|
178
177
|
if (allAliases.length === 0) throw new Error('No tables referenced in query.');
|
|
179
178
|
|
|
@@ -187,13 +186,11 @@ export function compileProxyQuery(
|
|
|
187
186
|
: [queryResult.join as [ColumnNode, ColumnNode]];
|
|
188
187
|
|
|
189
188
|
for (const [left, right] of joins) {
|
|
190
|
-
// Determine which side is the joined table (not the primary)
|
|
191
189
|
const leftTable = tablesUsed.get(left.alias);
|
|
192
190
|
const rightTable = tablesUsed.get(right.alias);
|
|
193
191
|
|
|
194
192
|
if (!leftTable || !rightTable) throw new Error('Join references unknown table alias.');
|
|
195
193
|
|
|
196
|
-
// The non-primary side needs a JOIN clause
|
|
197
194
|
const joinAlias = leftTable.alias === primaryAlias.alias ? rightTable : leftTable;
|
|
198
195
|
|
|
199
196
|
sql += ` JOIN ${q(joinAlias.tableName)} ${q(joinAlias.alias)} ON ${qRef(left.alias, left.column)} = ${qRef(right.alias, right.column)}`;
|
|
@@ -205,21 +202,17 @@ export function compileProxyQuery(
|
|
|
205
202
|
const whereParts: string[] = [];
|
|
206
203
|
|
|
207
204
|
for (const [key, value] of Object.entries(queryResult.where)) {
|
|
208
|
-
// The key could be '"t1"."column"' (from toString trick) or a plain string
|
|
209
205
|
let fieldRef: string;
|
|
210
206
|
|
|
211
207
|
// Match quoted alias.column pattern: "alias"."column"
|
|
212
208
|
const quotedMatch = key.match(/^"([^"]+)"\."([^"]+)"$/);
|
|
213
209
|
if (quotedMatch && tablesUsed.has(quotedMatch[1]!)) {
|
|
214
|
-
// Already fully quoted
|
|
215
210
|
fieldRef = key;
|
|
216
211
|
} else {
|
|
217
|
-
// Plain field name — use the first table
|
|
218
212
|
fieldRef = qRef(primaryAlias.alias, key);
|
|
219
213
|
}
|
|
220
214
|
|
|
221
215
|
if (isColumnNode(value)) {
|
|
222
|
-
// Column-to-column comparison
|
|
223
216
|
whereParts.push(`${fieldRef} = ${qRef(value.alias, value.column)}`);
|
|
224
217
|
} else if (Array.isArray(value)) {
|
|
225
218
|
if (value.length === 0) {
|
|
@@ -230,8 +223,18 @@ export function compileProxyQuery(
|
|
|
230
223
|
params.push(...value);
|
|
231
224
|
}
|
|
232
225
|
} else if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
|
|
233
|
-
// Operator object like { $gt: 5 }
|
|
234
226
|
for (const [op, operand] of Object.entries(value)) {
|
|
227
|
+
if (op === '$in') {
|
|
228
|
+
const arr = operand as any[];
|
|
229
|
+
if (arr.length === 0) {
|
|
230
|
+
whereParts.push('1 = 0');
|
|
231
|
+
} else {
|
|
232
|
+
const placeholders = arr.map(() => '?').join(', ');
|
|
233
|
+
whereParts.push(`${fieldRef} IN (${placeholders})`);
|
|
234
|
+
params.push(...arr);
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
235
238
|
const opMap: Record<string, string> = {
|
|
236
239
|
$gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=',
|
|
237
240
|
};
|
|
@@ -271,7 +274,7 @@ export function compileProxyQuery(
|
|
|
271
274
|
|
|
272
275
|
// ---------- GROUP BY ----------
|
|
273
276
|
if (queryResult.groupBy && queryResult.groupBy.length > 0) {
|
|
274
|
-
const parts = queryResult.groupBy.map(col => qRef(col
|
|
277
|
+
const parts = queryResult.groupBy.filter(Boolean).map(col => qRef(col!.alias, col!.column));
|
|
275
278
|
sql += ` GROUP BY ${parts.join(', ')}`;
|
|
276
279
|
}
|
|
277
280
|
|
|
@@ -290,7 +293,7 @@ export function compileProxyQuery(
|
|
|
290
293
|
|
|
291
294
|
/**
|
|
292
295
|
* The main `db.query(c => {...})` entry point.
|
|
293
|
-
*
|
|
296
|
+
*
|
|
294
297
|
* @param schemas The schema map for all registered tables.
|
|
295
298
|
* @param callback The user's query callback that receives the context proxy.
|
|
296
299
|
* @param executor A function that runs the compiled SQL and returns rows.
|
|
@@ -306,3 +309,4 @@ export function executeProxyQuery<T>(
|
|
|
306
309
|
const { sql, params } = compileProxyQuery(queryResult, aliasMap);
|
|
307
310
|
return executor(sql, params);
|
|
308
311
|
}
|
|
312
|
+
|
package/src/query-builder.ts
CHANGED
|
@@ -15,10 +15,19 @@ interface WhereCondition {
|
|
|
15
15
|
value: any;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
interface JoinClause {
|
|
19
|
+
table: string;
|
|
20
|
+
fromCol: string;
|
|
21
|
+
toCol: string;
|
|
22
|
+
columns: string[]; // columns to SELECT from the joined table
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
interface IQO {
|
|
19
26
|
selects: string[];
|
|
20
27
|
wheres: WhereCondition[];
|
|
28
|
+
whereOrs: WhereCondition[][]; // Each sub-array is an OR group
|
|
21
29
|
whereAST: ASTNode | null;
|
|
30
|
+
joins: JoinClause[];
|
|
22
31
|
limit: number | null;
|
|
23
32
|
offset: number | null;
|
|
24
33
|
orderBy: { field: string; direction: OrderDirection }[];
|
|
@@ -49,11 +58,27 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
49
58
|
const params: any[] = [];
|
|
50
59
|
|
|
51
60
|
// SELECT clause
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
const selectParts: string[] = [];
|
|
62
|
+
if (iqo.selects.length > 0) {
|
|
63
|
+
selectParts.push(...iqo.selects.map(s => `${tableName}.${s}`));
|
|
64
|
+
} else {
|
|
65
|
+
selectParts.push(`${tableName}.*`);
|
|
66
|
+
}
|
|
67
|
+
// Add columns from joins
|
|
68
|
+
for (const j of iqo.joins) {
|
|
69
|
+
if (j.columns.length > 0) {
|
|
70
|
+
selectParts.push(...j.columns.map(c => `${j.table}.${c} AS ${j.table}_${c}`));
|
|
71
|
+
} else {
|
|
72
|
+
selectParts.push(`${j.table}.*`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
55
75
|
|
|
56
|
-
let sql = `SELECT ${
|
|
76
|
+
let sql = `SELECT ${selectParts.join(', ')} FROM ${tableName}`;
|
|
77
|
+
|
|
78
|
+
// JOIN clauses
|
|
79
|
+
for (const j of iqo.joins) {
|
|
80
|
+
sql += ` JOIN ${j.table} ON ${tableName}.${j.fromCol} = ${j.table}.${j.toCol}`;
|
|
81
|
+
}
|
|
57
82
|
|
|
58
83
|
// WHERE clause — AST-based takes precedence if set
|
|
59
84
|
if (iqo.whereAST) {
|
|
@@ -80,6 +105,31 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
80
105
|
sql += ` WHERE ${whereParts.join(' AND ')}`;
|
|
81
106
|
}
|
|
82
107
|
|
|
108
|
+
// Append OR groups (from $or)
|
|
109
|
+
if (iqo.whereOrs.length > 0) {
|
|
110
|
+
for (const orGroup of iqo.whereOrs) {
|
|
111
|
+
const orParts: string[] = [];
|
|
112
|
+
for (const w of orGroup) {
|
|
113
|
+
if (w.operator === 'IN') {
|
|
114
|
+
const arr = w.value as any[];
|
|
115
|
+
if (arr.length === 0) {
|
|
116
|
+
orParts.push('1 = 0');
|
|
117
|
+
} else {
|
|
118
|
+
orParts.push(`${w.field} IN (${arr.map(() => '?').join(', ')})`);
|
|
119
|
+
params.push(...arr.map(transformValueForStorage));
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
orParts.push(`${w.field} ${w.operator} ?`);
|
|
123
|
+
params.push(transformValueForStorage(w.value));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (orParts.length > 0) {
|
|
127
|
+
const orClause = `(${orParts.join(' OR ')})`;
|
|
128
|
+
sql += sql.includes(' WHERE ') ? ` AND ${orClause}` : ` WHERE ${orClause}`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
83
133
|
// ORDER BY clause
|
|
84
134
|
if (iqo.orderBy.length > 0) {
|
|
85
135
|
const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
|
|
@@ -115,19 +165,27 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
115
165
|
private tableName: string;
|
|
116
166
|
private executor: (sql: string, params: any[], raw: boolean) => any[];
|
|
117
167
|
private singleExecutor: (sql: string, params: any[], raw: boolean) => any | null;
|
|
168
|
+
private joinResolver: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null;
|
|
169
|
+
private conditionResolver: ((conditions: Record<string, any>) => Record<string, any>) | null;
|
|
118
170
|
|
|
119
171
|
constructor(
|
|
120
172
|
tableName: string,
|
|
121
173
|
executor: (sql: string, params: any[], raw: boolean) => any[],
|
|
122
174
|
singleExecutor: (sql: string, params: any[], raw: boolean) => any | null,
|
|
175
|
+
joinResolver?: ((fromTable: string, toTable: string) => { fk: string; pk: string } | null) | null,
|
|
176
|
+
conditionResolver?: ((conditions: Record<string, any>) => Record<string, any>) | null,
|
|
123
177
|
) {
|
|
124
178
|
this.tableName = tableName;
|
|
125
179
|
this.executor = executor;
|
|
126
180
|
this.singleExecutor = singleExecutor;
|
|
181
|
+
this.joinResolver = joinResolver ?? null;
|
|
182
|
+
this.conditionResolver = conditionResolver ?? null;
|
|
127
183
|
this.iqo = {
|
|
128
184
|
selects: [],
|
|
129
185
|
wheres: [],
|
|
186
|
+
whereOrs: [],
|
|
130
187
|
whereAST: null,
|
|
188
|
+
joins: [],
|
|
131
189
|
limit: null,
|
|
132
190
|
offset: null,
|
|
133
191
|
orderBy: [],
|
|
@@ -162,7 +220,7 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
162
220
|
* ))
|
|
163
221
|
* ```
|
|
164
222
|
*/
|
|
165
|
-
where(criteriaOrCallback: Partial<Record<keyof T & string, any>> | WhereCallback<T>): this {
|
|
223
|
+
where(criteriaOrCallback: (Partial<Record<keyof T & string, any>> & { $or?: Partial<Record<keyof T & string, any>>[] }) | WhereCallback<T>): this {
|
|
166
224
|
if (typeof criteriaOrCallback === 'function') {
|
|
167
225
|
// Callback-style: evaluate with proxies to produce AST
|
|
168
226
|
const ast = (criteriaOrCallback as WhereCallback<T>)(
|
|
@@ -177,8 +235,35 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
177
235
|
this.iqo.whereAST = ast;
|
|
178
236
|
}
|
|
179
237
|
} else {
|
|
238
|
+
// Resolve entity references: { author: tolstoy } → { authorId: tolstoy.id }
|
|
239
|
+
const resolved = this.conditionResolver
|
|
240
|
+
? this.conditionResolver(criteriaOrCallback as Record<string, any>)
|
|
241
|
+
: criteriaOrCallback;
|
|
242
|
+
|
|
180
243
|
// Object-style: parse into IQO conditions
|
|
181
|
-
for (const [key, value] of Object.entries(
|
|
244
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
245
|
+
// Handle $or: [{ field1: val1 }, { field2: val2 }]
|
|
246
|
+
if (key === '$or' && Array.isArray(value)) {
|
|
247
|
+
const orConditions: WhereCondition[] = [];
|
|
248
|
+
for (const branch of value as Record<string, any>[]) {
|
|
249
|
+
const resolvedBranch = this.conditionResolver
|
|
250
|
+
? this.conditionResolver(branch)
|
|
251
|
+
: branch;
|
|
252
|
+
for (const [bKey, bValue] of Object.entries(resolvedBranch)) {
|
|
253
|
+
if (typeof bValue === 'object' && bValue !== null && !Array.isArray(bValue) && !(bValue instanceof Date)) {
|
|
254
|
+
for (const [opKey, operand] of Object.entries(bValue)) {
|
|
255
|
+
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
256
|
+
if (sqlOp) orConditions.push({ field: bKey, operator: sqlOp as WhereCondition['operator'], value: operand });
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
orConditions.push({ field: bKey, operator: '=', value: bValue });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (orConditions.length > 0) this.iqo.whereOrs.push(orConditions);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
182
267
|
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
183
268
|
for (const [opKey, operand] of Object.entries(value)) {
|
|
184
269
|
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
@@ -221,6 +306,60 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
221
306
|
return this;
|
|
222
307
|
}
|
|
223
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Join another table. Two calling styles:
|
|
311
|
+
*
|
|
312
|
+
* **Accessor-based** (auto-infers FK from relationships, type-safe columns):
|
|
313
|
+
* ```ts
|
|
314
|
+
* db.trees.select('name').join(db.forests, ['name']).all()
|
|
315
|
+
* // → [{ name: 'Oak', forests_name: 'Sherwood' }]
|
|
316
|
+
* ```
|
|
317
|
+
*
|
|
318
|
+
* **String-based** (manual FK):
|
|
319
|
+
* ```ts
|
|
320
|
+
* db.trees.select('name').join('forests', 'forestId', ['name']).all()
|
|
321
|
+
* ```
|
|
322
|
+
*/
|
|
323
|
+
join(
|
|
324
|
+
accessor: { _tableName: string },
|
|
325
|
+
columns?: string[],
|
|
326
|
+
): this;
|
|
327
|
+
join(
|
|
328
|
+
table: string,
|
|
329
|
+
fk: string,
|
|
330
|
+
columns?: string[],
|
|
331
|
+
pk?: string,
|
|
332
|
+
): this;
|
|
333
|
+
join(tableOrAccessor: string | { _tableName: string }, fkOrCols?: string | string[], colsOrPk?: string[] | string, pk?: string): this {
|
|
334
|
+
let table: string;
|
|
335
|
+
let fromCol: string;
|
|
336
|
+
let toCol: string;
|
|
337
|
+
let columns: string[];
|
|
338
|
+
|
|
339
|
+
if (typeof tableOrAccessor === 'object' && '_tableName' in tableOrAccessor) {
|
|
340
|
+
// Accessor-based: .join(db.forests, ['name', 'address'])
|
|
341
|
+
table = tableOrAccessor._tableName;
|
|
342
|
+
columns = Array.isArray(fkOrCols) ? fkOrCols : [];
|
|
343
|
+
|
|
344
|
+
// Auto-resolve FK from relationships
|
|
345
|
+
if (!this.joinResolver) throw new Error(`Cannot auto-resolve join: no relationship data available`);
|
|
346
|
+
const resolved = this.joinResolver(this.tableName, table);
|
|
347
|
+
if (!resolved) throw new Error(`No relationship found between '${this.tableName}' and '${table}'`);
|
|
348
|
+
fromCol = resolved.fk;
|
|
349
|
+
toCol = resolved.pk;
|
|
350
|
+
} else {
|
|
351
|
+
// String-based: .join('forests', 'forestId', ['name'], 'id')
|
|
352
|
+
table = tableOrAccessor;
|
|
353
|
+
fromCol = fkOrCols as string;
|
|
354
|
+
columns = Array.isArray(colsOrPk) ? colsOrPk : [];
|
|
355
|
+
toCol = (typeof colsOrPk === 'string' ? colsOrPk : pk) ?? 'id';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.iqo.joins.push({ table, fromCol, toCol, columns });
|
|
359
|
+
this.iqo.raw = true;
|
|
360
|
+
return this;
|
|
361
|
+
}
|
|
362
|
+
|
|
224
363
|
/** Skip Zod parsing and return raw SQLite row objects. */
|
|
225
364
|
raw(): this {
|
|
226
365
|
this.iqo.raw = true;
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema.ts — Schema parsing, relationship detection, and DDL helpers
|
|
3
|
+
*/
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { SchemaMap, ZodType, Relationship } from './types';
|
|
6
|
+
import { asZodObject } from './types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse declarative `relations` config into Relationship[] objects.
|
|
10
|
+
*
|
|
11
|
+
* Config format: `{ childTable: { fkColumn: 'parentTable' } }`
|
|
12
|
+
* Example: `{ books: { author_id: 'authors' } }` produces:
|
|
13
|
+
* - books → authors (belongs-to, FK = author_id, nav = author)
|
|
14
|
+
* - authors → books (one-to-many, nav = books)
|
|
15
|
+
*/
|
|
16
|
+
export function parseRelationsConfig(
|
|
17
|
+
relations: Record<string, Record<string, string>>,
|
|
18
|
+
schemas: SchemaMap,
|
|
19
|
+
): Relationship[] {
|
|
20
|
+
const relationships: Relationship[] = [];
|
|
21
|
+
const added = new Set<string>();
|
|
22
|
+
|
|
23
|
+
for (const [fromTable, rels] of Object.entries(relations)) {
|
|
24
|
+
if (!schemas[fromTable]) {
|
|
25
|
+
throw new Error(`relations: unknown table '${fromTable}'`);
|
|
26
|
+
}
|
|
27
|
+
for (const [fkColumn, toTable] of Object.entries(rels)) {
|
|
28
|
+
if (!schemas[toTable]) {
|
|
29
|
+
throw new Error(`relations: unknown target table '${toTable}' in ${fromTable}.${fkColumn}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Derive navigation name: author_id → author
|
|
33
|
+
const navField = fkColumn.replace(/_id$/, '');
|
|
34
|
+
|
|
35
|
+
// belongs-to: books.author_id → authors
|
|
36
|
+
const btKey = `${fromTable}.${fkColumn}:belongs-to`;
|
|
37
|
+
if (!added.has(btKey)) {
|
|
38
|
+
relationships.push({
|
|
39
|
+
type: 'belongs-to',
|
|
40
|
+
from: fromTable,
|
|
41
|
+
to: toTable,
|
|
42
|
+
relationshipField: navField,
|
|
43
|
+
foreignKey: fkColumn,
|
|
44
|
+
});
|
|
45
|
+
added.add(btKey);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// auto-infer one-to-many inverse: authors.books → books[]
|
|
49
|
+
const otmKey = `${toTable}.${fromTable}:one-to-many`;
|
|
50
|
+
if (!added.has(otmKey)) {
|
|
51
|
+
relationships.push({
|
|
52
|
+
type: 'one-to-many',
|
|
53
|
+
from: toTable,
|
|
54
|
+
to: fromTable,
|
|
55
|
+
relationshipField: fromTable, // e.g. 'books'
|
|
56
|
+
foreignKey: '',
|
|
57
|
+
});
|
|
58
|
+
added.add(otmKey);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return relationships;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Get storable (non-id) fields from a schema */
|
|
67
|
+
export function getStorableFields(schema: z.ZodType<any>): { name: string; type: ZodType }[] {
|
|
68
|
+
return Object.entries(asZodObject(schema).shape)
|
|
69
|
+
.filter(([key]) => key !== 'id')
|
|
70
|
+
.map(([name, type]) => ({ name, type: type as ZodType }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Map a Zod type to its SQLite column type */
|
|
74
|
+
export function zodTypeToSqlType(zodType: ZodType): string {
|
|
75
|
+
if (zodType instanceof z.ZodOptional) {
|
|
76
|
+
zodType = zodType._def.innerType;
|
|
77
|
+
}
|
|
78
|
+
if (zodType instanceof z.ZodDefault) {
|
|
79
|
+
zodType = zodType._def.innerType;
|
|
80
|
+
}
|
|
81
|
+
if (zodType instanceof z.ZodString || zodType instanceof z.ZodDate) return 'TEXT';
|
|
82
|
+
if (zodType instanceof z.ZodNumber || zodType instanceof z.ZodBoolean) return 'INTEGER';
|
|
83
|
+
if ((zodType as any)._def.typeName === 'ZodInstanceOf' && (zodType as any)._def.type === Buffer) return 'BLOB';
|
|
84
|
+
return 'TEXT';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Transform JS values to SQLite storage format */
|
|
88
|
+
export function transformForStorage(data: Record<string, any>): Record<string, any> {
|
|
89
|
+
const transformed: Record<string, any> = {};
|
|
90
|
+
for (const [key, value] of Object.entries(data)) {
|
|
91
|
+
if (value instanceof Date) {
|
|
92
|
+
transformed[key] = value.toISOString();
|
|
93
|
+
} else if (typeof value === 'boolean') {
|
|
94
|
+
transformed[key] = value ? 1 : 0;
|
|
95
|
+
} else {
|
|
96
|
+
transformed[key] = value;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return transformed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Transform SQLite row back to JS types based on schema */
|
|
103
|
+
export function transformFromStorage(row: Record<string, any>, schema: z.ZodType<any>): Record<string, any> {
|
|
104
|
+
const transformed: Record<string, any> = {};
|
|
105
|
+
for (const [key, value] of Object.entries(row)) {
|
|
106
|
+
let fieldSchema = asZodObject(schema).shape[key];
|
|
107
|
+
if (fieldSchema instanceof z.ZodOptional) {
|
|
108
|
+
fieldSchema = fieldSchema._def.innerType;
|
|
109
|
+
}
|
|
110
|
+
if (fieldSchema instanceof z.ZodDefault) {
|
|
111
|
+
fieldSchema = fieldSchema._def.innerType;
|
|
112
|
+
}
|
|
113
|
+
if (fieldSchema instanceof z.ZodDate && typeof value === 'string') {
|
|
114
|
+
transformed[key] = new Date(value);
|
|
115
|
+
} else if (fieldSchema instanceof z.ZodBoolean && typeof value === 'number') {
|
|
116
|
+
transformed[key] = value === 1;
|
|
117
|
+
} else {
|
|
118
|
+
transformed[key] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return transformed;
|
|
122
|
+
}
|