sqlite-zod-orm 3.9.0 → 3.10.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/src/iqo.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * iqo.ts — Internal Query Object types and SQL compiler
3
+ *
4
+ * Defines the IQO structure, WHERE operators, and the `compileIQO` function
5
+ * that transforms an IQO into executable SQL + params.
6
+ */
7
+
8
+ import { type ASTNode, compileAST } from './ast';
9
+
10
+ // =============================================================================
11
+ // IQO — Internal Query Object
12
+ // =============================================================================
13
+
14
+ export type OrderDirection = 'asc' | 'desc';
15
+ export type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between';
16
+
17
+ export interface WhereCondition {
18
+ field: string;
19
+ operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN';
20
+ value: any;
21
+ }
22
+
23
+ export interface JoinClause {
24
+ table: string;
25
+ fromCol: string;
26
+ toCol: string;
27
+ columns: string[]; // columns to SELECT from the joined table
28
+ }
29
+
30
+ export interface IQO {
31
+ selects: string[];
32
+ wheres: WhereCondition[];
33
+ whereOrs: WhereCondition[][]; // Each sub-array is an OR group
34
+ whereAST: ASTNode | null;
35
+ joins: JoinClause[];
36
+ groupBy: string[];
37
+ limit: number | null;
38
+ offset: number | null;
39
+ orderBy: { field: string; direction: OrderDirection }[];
40
+ includes: string[];
41
+ raw: boolean;
42
+ }
43
+
44
+ export const OPERATOR_MAP: Record<WhereOperator, string> = {
45
+ $gt: '>',
46
+ $gte: '>=',
47
+ $lt: '<',
48
+ $lte: '<=',
49
+ $ne: '!=',
50
+ $in: 'IN',
51
+ $like: 'LIKE',
52
+ $notIn: 'NOT IN',
53
+ $between: 'BETWEEN',
54
+ };
55
+
56
+ export function transformValueForStorage(value: any): any {
57
+ if (value instanceof Date) return value.toISOString();
58
+ if (typeof value === 'boolean') return value ? 1 : 0;
59
+ return value;
60
+ }
61
+
62
+ /**
63
+ * Compile an Internal Query Object into executable SQL + params.
64
+ * Handles SELECT, JOIN, WHERE (object + AST + $or), ORDER BY, LIMIT, OFFSET.
65
+ */
66
+ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params: any[] } {
67
+ const params: any[] = [];
68
+
69
+ // SELECT clause
70
+ const selectParts: string[] = [];
71
+ if (iqo.selects.length > 0) {
72
+ selectParts.push(...iqo.selects.map(s => `${tableName}.${s}`));
73
+ } else {
74
+ selectParts.push(`${tableName}.*`);
75
+ }
76
+ for (const j of iqo.joins) {
77
+ if (j.columns.length > 0) {
78
+ selectParts.push(...j.columns.map(c => `${j.table}.${c} AS ${j.table}_${c}`));
79
+ } else {
80
+ selectParts.push(`${j.table}.*`);
81
+ }
82
+ }
83
+
84
+ let sql = `SELECT ${selectParts.join(', ')} FROM ${tableName}`;
85
+
86
+ // JOIN clauses
87
+ for (const j of iqo.joins) {
88
+ sql += ` JOIN ${j.table} ON ${tableName}.${j.fromCol} = ${j.table}.${j.toCol}`;
89
+ }
90
+
91
+ // WHERE clause — AST-based takes precedence if set
92
+ if (iqo.whereAST) {
93
+ const compiled = compileAST(iqo.whereAST);
94
+ sql += ` WHERE ${compiled.sql}`;
95
+ params.push(...compiled.params);
96
+ } else if (iqo.wheres.length > 0) {
97
+ const hasJoins = iqo.joins.length > 0;
98
+ const qualify = (field: string) =>
99
+ hasJoins && !field.includes('.') ? `${tableName}.${field}` : field;
100
+
101
+ const whereParts: string[] = [];
102
+ for (const w of iqo.wheres) {
103
+ if (w.operator === 'IN') {
104
+ const arr = w.value as any[];
105
+ if (arr.length === 0) {
106
+ whereParts.push('1 = 0');
107
+ } else {
108
+ const placeholders = arr.map(() => '?').join(', ');
109
+ whereParts.push(`${qualify(w.field)} IN (${placeholders})`);
110
+ params.push(...arr.map(transformValueForStorage));
111
+ }
112
+ } else if (w.operator === 'NOT IN') {
113
+ const arr = w.value as any[];
114
+ if (arr.length === 0) continue; // no-op
115
+ const placeholders = arr.map(() => '?').join(', ');
116
+ whereParts.push(`${qualify(w.field)} NOT IN (${placeholders})`);
117
+ params.push(...arr.map(transformValueForStorage));
118
+ } else if (w.operator === 'BETWEEN') {
119
+ const [min, max] = w.value as [any, any];
120
+ whereParts.push(`${qualify(w.field)} BETWEEN ? AND ?`);
121
+ params.push(transformValueForStorage(min), transformValueForStorage(max));
122
+ } else {
123
+ whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
124
+ params.push(transformValueForStorage(w.value));
125
+ }
126
+ }
127
+ if (whereParts.length > 0) {
128
+ sql += ` WHERE ${whereParts.join(' AND ')}`;
129
+ }
130
+ }
131
+
132
+ // Append OR groups (from $or)
133
+ if (iqo.whereOrs.length > 0) {
134
+ for (const orGroup of iqo.whereOrs) {
135
+ const orParts: string[] = [];
136
+ for (const w of orGroup) {
137
+ if (w.operator === 'IN') {
138
+ const arr = w.value as any[];
139
+ if (arr.length === 0) {
140
+ orParts.push('1 = 0');
141
+ } else {
142
+ orParts.push(`${w.field} IN (${arr.map(() => '?').join(', ')})`);
143
+ params.push(...arr.map(transformValueForStorage));
144
+ }
145
+ } else {
146
+ orParts.push(`${w.field} ${w.operator} ?`);
147
+ params.push(transformValueForStorage(w.value));
148
+ }
149
+ }
150
+ if (orParts.length > 0) {
151
+ const orClause = `(${orParts.join(' OR ')})`;
152
+ sql += sql.includes(' WHERE ') ? ` AND ${orClause}` : ` WHERE ${orClause}`;
153
+ }
154
+ }
155
+ }
156
+
157
+ // GROUP BY
158
+ if (iqo.groupBy.length > 0) {
159
+ sql += ` GROUP BY ${iqo.groupBy.join(', ')}`;
160
+ }
161
+
162
+ // ORDER BY
163
+ if (iqo.orderBy.length > 0) {
164
+ const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
165
+ sql += ` ORDER BY ${parts.join(', ')}`;
166
+ }
167
+
168
+ if (iqo.limit !== null) sql += ` LIMIT ${iqo.limit}`;
169
+ if (iqo.offset !== null) sql += ` OFFSET ${iqo.offset}`;
170
+
171
+ return { sql, params };
172
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * proxy.ts — Proxy Query System
3
+ *
4
+ * Enables `db.query(c => { ... })` for SQL-like multi-table queries
5
+ * using destructured proxied table references.
6
+ */
7
+
8
+ import type { z } from 'zod';
9
+
10
+ // =============================================================================
11
+ // ColumnNode
12
+ // =============================================================================
13
+
14
+ /**
15
+ * Represents a reference to a specific table column with an alias.
16
+ * Used as a building block for SQL query construction.
17
+ */
18
+ export class ColumnNode {
19
+ readonly _type = 'COL' as const;
20
+ constructor(
21
+ readonly table: string,
22
+ readonly column: string,
23
+ readonly alias: string,
24
+ ) { }
25
+
26
+ /** Quoted alias.column for use as computed property key */
27
+ toString(): string {
28
+ return `"${this.alias}"."${this.column}"`;
29
+ }
30
+
31
+ [Symbol.toPrimitive](): string {
32
+ return this.toString();
33
+ }
34
+ }
35
+
36
+ // ---------- SQL Quoting Helpers ----------
37
+
38
+ function q(name: string): string {
39
+ return `"${name}"`;
40
+ }
41
+
42
+ function qRef(alias: string, column: string): string {
43
+ return `"${alias}"."${column}"`;
44
+ }
45
+
46
+ // ---------- Table Proxy ----------
47
+
48
+ function createTableProxy(
49
+ tableName: string,
50
+ alias: string,
51
+ columns: Set<string>,
52
+ ): Record<string, ColumnNode> {
53
+ return new Proxy({} as Record<string, ColumnNode>, {
54
+ get(_target, prop: string): ColumnNode | undefined {
55
+ if (prop === Symbol.toPrimitive as any || prop === 'toString' || prop === 'valueOf') {
56
+ return undefined;
57
+ }
58
+ return new ColumnNode(tableName, prop, alias);
59
+ },
60
+ ownKeys() {
61
+ return [...columns];
62
+ },
63
+ getOwnPropertyDescriptor(_target, prop) {
64
+ if (columns.has(prop as string)) {
65
+ return { configurable: true, enumerable: true, value: new ColumnNode(tableName, prop as string, alias) };
66
+ }
67
+ return undefined;
68
+ },
69
+ });
70
+ }
71
+
72
+ // ---------- Context Proxy ----------
73
+
74
+ interface AliasEntry {
75
+ tableName: string;
76
+ alias: string;
77
+ proxy: Record<string, ColumnNode>;
78
+ }
79
+
80
+ export function createContextProxy(
81
+ schemas: Record<string, z.ZodType<any>>,
82
+ ): { proxy: Record<string, Record<string, ColumnNode>>; aliasMap: Map<string, AliasEntry[]> } {
83
+ const aliases = new Map<string, AliasEntry[]>();
84
+ let aliasCounter = 0;
85
+
86
+ const proxy = new Proxy({} as Record<string, Record<string, ColumnNode>>, {
87
+ get(_target, tableName: string) {
88
+ if (typeof tableName !== 'string') return undefined;
89
+
90
+ const schema = schemas[tableName];
91
+ const shape = schema
92
+ ? (schema as unknown as z.ZodObject<any>).shape
93
+ : {};
94
+ const columns = new Set(Object.keys(shape));
95
+
96
+ aliasCounter++;
97
+ const alias = `t${aliasCounter}`;
98
+ const tableProxy = createTableProxy(tableName, alias, columns);
99
+
100
+ const entries = aliases.get(tableName) || [];
101
+ entries.push({ tableName, alias, proxy: tableProxy });
102
+ aliases.set(tableName, entries);
103
+
104
+ return tableProxy;
105
+ },
106
+ });
107
+
108
+ return { proxy, aliasMap: aliases };
109
+ }
110
+
111
+ // ---------- Proxy Query Result ----------
112
+
113
+ type AnyColumn = ColumnNode | (ColumnNode & string);
114
+
115
+ export interface ProxyQueryResult {
116
+ select: Record<string, AnyColumn | undefined>;
117
+ join?: [AnyColumn | undefined, AnyColumn | undefined] | [AnyColumn | undefined, AnyColumn | undefined][];
118
+ where?: Record<string, any>;
119
+ orderBy?: Record<string, 'asc' | 'desc'>;
120
+ limit?: number;
121
+ offset?: number;
122
+ groupBy?: (AnyColumn | undefined)[];
123
+ }
124
+
125
+ // ---------- Proxy Query Compiler ----------
126
+
127
+ function isColumnNode(val: any): val is ColumnNode {
128
+ return val && typeof val === 'object' && val._type === 'COL';
129
+ }
130
+
131
+ export function compileProxyQuery(
132
+ queryResult: ProxyQueryResult,
133
+ aliasMap: Map<string, AliasEntry[]>,
134
+ ): { sql: string; params: any[] } {
135
+ const params: any[] = [];
136
+
137
+ const tablesUsed = new Map<string, { tableName: string; alias: string }>();
138
+ for (const [tableName, entries] of aliasMap) {
139
+ for (const entry of entries) {
140
+ tablesUsed.set(entry.alias, { tableName, alias: entry.alias });
141
+ }
142
+ }
143
+
144
+ // SELECT
145
+ const selectParts: string[] = [];
146
+ for (const [outputName, colOrValue] of Object.entries(queryResult.select)) {
147
+ if (isColumnNode(colOrValue)) {
148
+ if (outputName === colOrValue.column) {
149
+ selectParts.push(qRef(colOrValue.alias, colOrValue.column));
150
+ } else {
151
+ selectParts.push(`${qRef(colOrValue.alias, colOrValue.column)} AS ${q(outputName)}`);
152
+ }
153
+ } else {
154
+ selectParts.push(`? AS ${q(outputName)}`);
155
+ params.push(colOrValue);
156
+ }
157
+ }
158
+
159
+ // FROM / JOIN
160
+ const allAliases = [...tablesUsed.values()];
161
+ if (allAliases.length === 0) throw new Error('No tables referenced in query.');
162
+
163
+ const primaryAlias = allAliases[0]!;
164
+ let sql = `SELECT ${selectParts.join(', ')} FROM ${q(primaryAlias.tableName)} ${q(primaryAlias.alias)}`;
165
+
166
+ if (queryResult.join) {
167
+ const joins: [ColumnNode, ColumnNode][] = Array.isArray(queryResult.join[0])
168
+ ? queryResult.join as [ColumnNode, ColumnNode][]
169
+ : [queryResult.join as [ColumnNode, ColumnNode]];
170
+
171
+ for (const [left, right] of joins) {
172
+ const leftTable = tablesUsed.get(left.alias);
173
+ const rightTable = tablesUsed.get(right.alias);
174
+ if (!leftTable || !rightTable) throw new Error('Join references unknown table alias.');
175
+ const joinAlias = leftTable.alias === primaryAlias.alias ? rightTable : leftTable;
176
+ sql += ` JOIN ${q(joinAlias.tableName)} ${q(joinAlias.alias)} ON ${qRef(left.alias, left.column)} = ${qRef(right.alias, right.column)}`;
177
+ }
178
+ }
179
+
180
+ // WHERE
181
+ if (queryResult.where && Object.keys(queryResult.where).length > 0) {
182
+ const whereParts: string[] = [];
183
+
184
+ for (const [key, value] of Object.entries(queryResult.where)) {
185
+ let fieldRef: string;
186
+ const quotedMatch = key.match(/^"([^"]+)"\."([^"]+)"$/);
187
+ if (quotedMatch && tablesUsed.has(quotedMatch[1]!)) {
188
+ fieldRef = key;
189
+ } else {
190
+ fieldRef = qRef(primaryAlias.alias, key);
191
+ }
192
+
193
+ if (isColumnNode(value)) {
194
+ whereParts.push(`${fieldRef} = ${qRef(value.alias, value.column)}`);
195
+ } else if (Array.isArray(value)) {
196
+ if (value.length === 0) {
197
+ whereParts.push('1 = 0');
198
+ } else {
199
+ const placeholders = value.map(() => '?').join(', ');
200
+ whereParts.push(`${fieldRef} IN (${placeholders})`);
201
+ params.push(...value);
202
+ }
203
+ } else if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
204
+ for (const [pOp, operand] of Object.entries(value)) {
205
+ if (pOp === '$in') {
206
+ const arr = operand as any[];
207
+ if (arr.length === 0) {
208
+ whereParts.push('1 = 0');
209
+ } else {
210
+ const placeholders = arr.map(() => '?').join(', ');
211
+ whereParts.push(`${fieldRef} IN (${placeholders})`);
212
+ params.push(...arr);
213
+ }
214
+ continue;
215
+ }
216
+ const opMap: Record<string, string> = {
217
+ $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=',
218
+ };
219
+ const sqlOp = opMap[pOp];
220
+ if (!sqlOp) throw new Error(`Unsupported where operator: ${pOp}`);
221
+ whereParts.push(`${fieldRef} ${sqlOp} ?`);
222
+ params.push(operand);
223
+ }
224
+ } else {
225
+ whereParts.push(`${fieldRef} = ?`);
226
+ params.push(value instanceof Date ? value.toISOString() : value);
227
+ }
228
+ }
229
+
230
+ if (whereParts.length > 0) {
231
+ sql += ` WHERE ${whereParts.join(' AND ')}`;
232
+ }
233
+ }
234
+
235
+ // ORDER BY
236
+ if (queryResult.orderBy) {
237
+ const parts: string[] = [];
238
+ for (const [key, dir] of Object.entries(queryResult.orderBy)) {
239
+ let fieldRef: string;
240
+ const quotedMatch = key.match(/^"([^"]+)"\."([^"]+)"$/);
241
+ if (quotedMatch && tablesUsed.has(quotedMatch[1]!)) {
242
+ fieldRef = key;
243
+ } else {
244
+ fieldRef = qRef(primaryAlias.alias, key);
245
+ }
246
+ parts.push(`${fieldRef} ${dir.toUpperCase()}`);
247
+ }
248
+ if (parts.length > 0) {
249
+ sql += ` ORDER BY ${parts.join(', ')}`;
250
+ }
251
+ }
252
+
253
+ // GROUP BY
254
+ if (queryResult.groupBy && queryResult.groupBy.length > 0) {
255
+ const parts = queryResult.groupBy.filter(Boolean).map(col => qRef(col!.alias, col!.column));
256
+ sql += ` GROUP BY ${parts.join(', ')}`;
257
+ }
258
+
259
+ // LIMIT / OFFSET
260
+ if (queryResult.limit !== undefined) sql += ` LIMIT ${queryResult.limit}`;
261
+ if (queryResult.offset !== undefined) sql += ` OFFSET ${queryResult.offset}`;
262
+
263
+ return { sql, params };
264
+ }
265
+
266
+ /** The main `db.query(c => {...})` entry point. */
267
+ export function executeProxyQuery<T>(
268
+ schemas: Record<string, z.ZodType<any>>,
269
+ callback: (ctx: any) => ProxyQueryResult,
270
+ executor: (sql: string, params: any[]) => T[],
271
+ ): T[] {
272
+ const { proxy, aliasMap } = createContextProxy(schemas);
273
+ const queryResult = callback(proxy);
274
+ const { sql, params } = compileProxyQuery(queryResult, aliasMap);
275
+ return executor(sql, params);
276
+ }