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/package.json
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sqlite-zod-orm",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.2.0",
|
|
4
|
+
"description": "Type-safe SQLite ORM for Bun — Zod schemas, fluent queries, auto relationships, zero SQL",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./dist/
|
|
7
|
-
"module": "./dist/
|
|
8
|
-
"types": "./src/
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": {
|
|
11
|
-
"import": "./dist/
|
|
12
|
-
"types": "./src/
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./src/index.ts"
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
|
-
"bin": {
|
|
16
|
-
"sqlite-zod-orm": "./dist/satidb.js"
|
|
17
|
-
},
|
|
18
15
|
"scripts": {
|
|
19
16
|
"build": "bun run ./src/build.ts",
|
|
20
17
|
"test": "bun test",
|
|
@@ -32,13 +29,16 @@
|
|
|
32
29
|
"typescript",
|
|
33
30
|
"type-safe",
|
|
34
31
|
"orm",
|
|
35
|
-
"
|
|
32
|
+
"zod",
|
|
33
|
+
"sql",
|
|
34
|
+
"query-builder",
|
|
35
|
+
"relationships"
|
|
36
36
|
],
|
|
37
37
|
"author": "7flash",
|
|
38
38
|
"license": "MIT",
|
|
39
39
|
"repository": {
|
|
40
40
|
"type": "git",
|
|
41
|
-
"url": "git@github.com:7flash/
|
|
41
|
+
"url": "git@github.com:7flash/sqlite-zod-orm.git"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"bun-types": "latest"
|
|
@@ -52,4 +52,4 @@
|
|
|
52
52
|
"engines": {
|
|
53
53
|
"bun": ">=1.0.0"
|
|
54
54
|
}
|
|
55
|
-
}
|
|
55
|
+
}
|
package/src/build.ts
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
import { EOL } from 'os';
|
|
2
2
|
|
|
3
|
-
console.log("
|
|
3
|
+
console.log("Building sqlite-zod-orm...");
|
|
4
4
|
|
|
5
5
|
const result = await Bun.build({
|
|
6
|
-
entrypoints: ['./src/
|
|
6
|
+
entrypoints: ['./src/index.ts'],
|
|
7
7
|
outdir: './dist',
|
|
8
|
-
target: 'bun',
|
|
8
|
+
target: 'bun',
|
|
9
9
|
format: 'esm',
|
|
10
|
-
minify:
|
|
10
|
+
minify: false,
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
if (!result.success) {
|
|
14
|
-
console.error("Build failed");
|
|
15
|
-
for (const
|
|
16
|
-
console.error(
|
|
14
|
+
console.error("Build failed:");
|
|
15
|
+
for (const msg of result.logs) {
|
|
16
|
+
console.error(msg);
|
|
17
17
|
}
|
|
18
18
|
process.exit(1);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
console.log(`Build successful! Executable created at: ${outFile}`);
|
|
21
|
+
console.log(`Build complete → dist/ (${result.outputs.length} file${result.outputs.length > 1 ? 's' : ''})${EOL}`);
|
package/src/database.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* database.ts — Main Database class for sqlite-zod-orm
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates schema-driven table creation, CRUD, relationships,
|
|
5
|
+
* query builders, and event handling.
|
|
6
|
+
*/
|
|
7
|
+
import { Database as SqliteDatabase } from 'bun:sqlite';
|
|
8
|
+
import { EventEmitter } from 'events';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { QueryBuilder } from './query-builder';
|
|
11
|
+
import { executeProxyQuery, type ProxyQueryResult } from './proxy-query';
|
|
12
|
+
import type {
|
|
13
|
+
SchemaMap, DatabaseOptions, Relationship, RelationsConfig,
|
|
14
|
+
EntityAccessor, TypedAccessors, TypedNavAccessors, AugmentedEntity, UpdateBuilder,
|
|
15
|
+
ProxyColumns, InferSchema,
|
|
16
|
+
} from './types';
|
|
17
|
+
import { asZodObject } from './types';
|
|
18
|
+
import {
|
|
19
|
+
parseRelationsConfig,
|
|
20
|
+
getStorableFields,
|
|
21
|
+
zodTypeToSqlType, transformForStorage, transformFromStorage,
|
|
22
|
+
} from './schema';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Database Class
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
class _Database<Schemas extends SchemaMap> extends EventEmitter {
|
|
29
|
+
private db: SqliteDatabase;
|
|
30
|
+
private schemas: Schemas;
|
|
31
|
+
private relationships: Relationship[];
|
|
32
|
+
private subscriptions: Record<'insert' | 'update' | 'delete', Record<string, ((data: any) => void)[]>>;
|
|
33
|
+
private options: DatabaseOptions;
|
|
34
|
+
|
|
35
|
+
constructor(dbFile: string, schemas: Schemas, options: DatabaseOptions = {}) {
|
|
36
|
+
super();
|
|
37
|
+
this.db = new SqliteDatabase(dbFile);
|
|
38
|
+
this.db.run('PRAGMA foreign_keys = ON');
|
|
39
|
+
this.schemas = schemas;
|
|
40
|
+
this.options = options;
|
|
41
|
+
this.subscriptions = { insert: {}, update: {}, delete: {} };
|
|
42
|
+
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
43
|
+
this.initializeTables();
|
|
44
|
+
this.runMigrations();
|
|
45
|
+
if (options.indexes) this.createIndexes(options.indexes);
|
|
46
|
+
if (options.changeTracking) this.setupChangeTracking();
|
|
47
|
+
|
|
48
|
+
// Create typed entity accessors (db.users, db.posts, etc.)
|
|
49
|
+
for (const entityName of Object.keys(schemas)) {
|
|
50
|
+
const key = entityName as keyof Schemas;
|
|
51
|
+
const accessor: EntityAccessor<Schemas[typeof key]> = {
|
|
52
|
+
insert: (data) => this.insert(entityName, data),
|
|
53
|
+
update: (idOrData: any, data?: any) => {
|
|
54
|
+
if (typeof idOrData === 'number') return this.update(entityName, idOrData, data);
|
|
55
|
+
return this._createUpdateBuilder(entityName, idOrData);
|
|
56
|
+
},
|
|
57
|
+
upsert: (conditions, data) => this.upsert(entityName, data, conditions),
|
|
58
|
+
delete: (id) => this.delete(entityName, id),
|
|
59
|
+
subscribe: (event, callback) => this.subscribe(event, entityName, callback),
|
|
60
|
+
unsubscribe: (event, callback) => this.unsubscribe(event, entityName, callback),
|
|
61
|
+
select: (...cols: string[]) => this._createQueryBuilder(entityName, cols),
|
|
62
|
+
_tableName: entityName,
|
|
63
|
+
};
|
|
64
|
+
(this as any)[key] = accessor;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ===========================================================================
|
|
69
|
+
// Table Initialization & Migrations
|
|
70
|
+
// ===========================================================================
|
|
71
|
+
|
|
72
|
+
private initializeTables(): void {
|
|
73
|
+
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
74
|
+
const storableFields = getStorableFields(schema);
|
|
75
|
+
const columnDefs = storableFields.map(f => `${f.name} ${zodTypeToSqlType(f.type)}`);
|
|
76
|
+
const constraints: string[] = [];
|
|
77
|
+
|
|
78
|
+
// Add FOREIGN KEY constraints for FK columns declared in the schema
|
|
79
|
+
const belongsToRels = this.relationships.filter(
|
|
80
|
+
rel => rel.type === 'belongs-to' && rel.from === entityName
|
|
81
|
+
);
|
|
82
|
+
for (const rel of belongsToRels) {
|
|
83
|
+
constraints.push(`FOREIGN KEY (${rel.foreignKey}) REFERENCES ${rel.to}(id) ON DELETE SET NULL`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const allCols = columnDefs.join(', ');
|
|
87
|
+
const allConstraints = constraints.length > 0 ? ', ' + constraints.join(', ') : '';
|
|
88
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS ${entityName} (id INTEGER PRIMARY KEY AUTOINCREMENT, ${allCols}${allConstraints})`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private runMigrations(): void {
|
|
93
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS _schema_meta (
|
|
94
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
|
+
table_name TEXT NOT NULL,
|
|
96
|
+
column_name TEXT NOT NULL,
|
|
97
|
+
added_at TEXT DEFAULT (datetime('now')),
|
|
98
|
+
UNIQUE(table_name, column_name)
|
|
99
|
+
)`);
|
|
100
|
+
|
|
101
|
+
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
102
|
+
const existingCols = new Set(
|
|
103
|
+
(this.db.query(`PRAGMA table_info(${entityName})`).all() as any[]).map(c => c.name)
|
|
104
|
+
);
|
|
105
|
+
const storableFields = getStorableFields(schema);
|
|
106
|
+
|
|
107
|
+
for (const field of storableFields) {
|
|
108
|
+
if (!existingCols.has(field.name)) {
|
|
109
|
+
this.db.run(`ALTER TABLE ${entityName} ADD COLUMN ${field.name} ${zodTypeToSqlType(field.type)}`);
|
|
110
|
+
this.db.query(`INSERT OR IGNORE INTO _schema_meta (table_name, column_name) VALUES (?, ?)`).run(entityName, field.name);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ===========================================================================
|
|
117
|
+
// Indexes
|
|
118
|
+
// ===========================================================================
|
|
119
|
+
|
|
120
|
+
private createIndexes(indexDefs: Record<string, string | (string | string[])[]>): void {
|
|
121
|
+
for (const [tableName, indexes] of Object.entries(indexDefs)) {
|
|
122
|
+
if (!this.schemas[tableName]) throw new Error(`Cannot create index on unknown table '${tableName}'`);
|
|
123
|
+
const indexList = Array.isArray(indexes) ? indexes : [indexes];
|
|
124
|
+
for (const indexDef of indexList) {
|
|
125
|
+
const columns = Array.isArray(indexDef) ? indexDef : [indexDef];
|
|
126
|
+
const indexName = `idx_${tableName}_${columns.join('_')}`;
|
|
127
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS ${indexName} ON ${tableName} (${columns.join(', ')})`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
// Change Tracking
|
|
134
|
+
// ===========================================================================
|
|
135
|
+
|
|
136
|
+
private setupChangeTracking(): void {
|
|
137
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS _changes (
|
|
138
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
+
table_name TEXT NOT NULL,
|
|
140
|
+
row_id INTEGER NOT NULL,
|
|
141
|
+
action TEXT NOT NULL CHECK(action IN ('INSERT', 'UPDATE', 'DELETE')),
|
|
142
|
+
changed_at TEXT DEFAULT (datetime('now'))
|
|
143
|
+
)`);
|
|
144
|
+
this.db.run(`CREATE INDEX IF NOT EXISTS idx_changes_table ON _changes (table_name, id)`);
|
|
145
|
+
|
|
146
|
+
for (const entityName of Object.keys(this.schemas)) {
|
|
147
|
+
for (const action of ['insert', 'update', 'delete'] as const) {
|
|
148
|
+
const ref = action === 'delete' ? 'OLD' : 'NEW';
|
|
149
|
+
this.db.run(`CREATE TRIGGER IF NOT EXISTS _trg_${entityName}_${action}
|
|
150
|
+
AFTER ${action.toUpperCase()} ON ${entityName}
|
|
151
|
+
BEGIN
|
|
152
|
+
INSERT INTO _changes (table_name, row_id, action) VALUES ('${entityName}', ${ref}.id, '${action.toUpperCase()}');
|
|
153
|
+
END`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
public getChangeSeq(tableName?: string): number {
|
|
159
|
+
if (!this.options.changeTracking) return -1;
|
|
160
|
+
const sql = tableName
|
|
161
|
+
? `SELECT MAX(id) as seq FROM _changes WHERE table_name = ?`
|
|
162
|
+
: `SELECT MAX(id) as seq FROM _changes`;
|
|
163
|
+
const row = this.db.query(sql).get(...(tableName ? [tableName] : [])) as any;
|
|
164
|
+
return row?.seq ?? 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public getChangesSince(sinceSeq: number, tableName?: string) {
|
|
168
|
+
const sql = tableName
|
|
169
|
+
? `SELECT * FROM _changes WHERE id > ? AND table_name = ? ORDER BY id ASC`
|
|
170
|
+
: `SELECT * FROM _changes WHERE id > ? ORDER BY id ASC`;
|
|
171
|
+
return this.db.query(sql).all(...(tableName ? [sinceSeq, tableName] : [sinceSeq])) as any[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ===========================================================================
|
|
175
|
+
// CRUD
|
|
176
|
+
// ===========================================================================
|
|
177
|
+
|
|
178
|
+
private insert<T extends Record<string, any>>(entityName: string, data: Omit<T, 'id'>): AugmentedEntity<any> {
|
|
179
|
+
const schema = this.schemas[entityName]!;
|
|
180
|
+
const validatedData = asZodObject(schema).passthrough().parse(data);
|
|
181
|
+
const transformed = transformForStorage(validatedData);
|
|
182
|
+
const columns = Object.keys(transformed);
|
|
183
|
+
|
|
184
|
+
const sql = columns.length === 0
|
|
185
|
+
? `INSERT INTO ${entityName} DEFAULT VALUES`
|
|
186
|
+
: `INSERT INTO ${entityName} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
|
|
187
|
+
|
|
188
|
+
const result = this.db.query(sql).run(...Object.values(transformed));
|
|
189
|
+
const newEntity = this._getById(entityName, result.lastInsertRowid as number);
|
|
190
|
+
if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
|
|
191
|
+
|
|
192
|
+
this.emit('insert', entityName, newEntity);
|
|
193
|
+
this.subscriptions.insert[entityName]?.forEach(cb => cb(newEntity));
|
|
194
|
+
return newEntity;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Internal: get a single entity by ID */
|
|
198
|
+
private _getById(entityName: string, id: number): AugmentedEntity<any> | null {
|
|
199
|
+
const row = this.db.query(`SELECT * FROM ${entityName} WHERE id = ?`).get(id) as any;
|
|
200
|
+
if (!row) return null;
|
|
201
|
+
return this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Internal: get a single entity by conditions */
|
|
205
|
+
private _getOne(entityName: string, conditions: Record<string, any>): AugmentedEntity<any> | null {
|
|
206
|
+
const { clause, values } = this.buildWhereClause(conditions);
|
|
207
|
+
const row = this.db.query(`SELECT * FROM ${entityName} ${clause} LIMIT 1`).get(...values) as any;
|
|
208
|
+
if (!row) return null;
|
|
209
|
+
return this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Internal: find multiple entities by conditions */
|
|
213
|
+
private _findMany(entityName: string, conditions: Record<string, any> = {}): AugmentedEntity<any>[] {
|
|
214
|
+
const { clause, values } = this.buildWhereClause(conditions);
|
|
215
|
+
const rows = this.db.query(`SELECT * FROM ${entityName} ${clause}`).all(...values);
|
|
216
|
+
return rows.map((row: any) =>
|
|
217
|
+
this._attachMethods(entityName, transformFromStorage(row, this.schemas[entityName]!))
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private update<T extends Record<string, any>>(entityName: string, id: number, data: Partial<Omit<T, 'id'>>): AugmentedEntity<any> | null {
|
|
222
|
+
const schema = this.schemas[entityName]!;
|
|
223
|
+
const validatedData = asZodObject(schema).partial().parse(data);
|
|
224
|
+
const transformed = transformForStorage(validatedData);
|
|
225
|
+
if (Object.keys(transformed).length === 0) return this._getById(entityName, id);
|
|
226
|
+
|
|
227
|
+
const setClause = Object.keys(transformed).map(key => `${key} = ?`).join(', ');
|
|
228
|
+
this.db.query(`UPDATE ${entityName} SET ${setClause} WHERE id = ?`).run(...Object.values(transformed), id);
|
|
229
|
+
|
|
230
|
+
const updatedEntity = this._getById(entityName, id);
|
|
231
|
+
if (updatedEntity) {
|
|
232
|
+
this.emit('update', entityName, updatedEntity);
|
|
233
|
+
this.subscriptions.update[entityName]?.forEach(cb => cb(updatedEntity));
|
|
234
|
+
}
|
|
235
|
+
return updatedEntity;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private _updateWhere(entityName: string, data: Record<string, any>, conditions: Record<string, any>): number {
|
|
239
|
+
const schema = this.schemas[entityName]!;
|
|
240
|
+
const validatedData = asZodObject(schema).partial().parse(data);
|
|
241
|
+
const transformed = transformForStorage(validatedData);
|
|
242
|
+
if (Object.keys(transformed).length === 0) return 0;
|
|
243
|
+
|
|
244
|
+
const { clause, values: whereValues } = this.buildWhereClause(conditions);
|
|
245
|
+
if (!clause) throw new Error('update().where() requires at least one condition');
|
|
246
|
+
|
|
247
|
+
const setCols = Object.keys(transformed);
|
|
248
|
+
const setClause = setCols.map(key => `${key} = ?`).join(', ');
|
|
249
|
+
const result = this.db.query(`UPDATE ${entityName} SET ${setClause} ${clause}`).run(
|
|
250
|
+
...setCols.map(key => transformed[key]),
|
|
251
|
+
...whereValues
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const affected = (result as any).changes ?? 0;
|
|
255
|
+
if (affected > 0 && (this.subscriptions.update[entityName]?.length || this.options.changeTracking)) {
|
|
256
|
+
for (const entity of this._findMany(entityName, conditions)) {
|
|
257
|
+
this.emit('update', entityName, entity);
|
|
258
|
+
this.subscriptions.update[entityName]?.forEach(cb => cb(entity));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return affected;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private _createUpdateBuilder(entityName: string, data: Record<string, any>): UpdateBuilder<any> {
|
|
265
|
+
let _conditions: Record<string, any> = {};
|
|
266
|
+
const builder: UpdateBuilder<any> = {
|
|
267
|
+
where: (conditions) => { _conditions = { ..._conditions, ...conditions }; return builder; },
|
|
268
|
+
exec: () => this._updateWhere(entityName, data, _conditions),
|
|
269
|
+
};
|
|
270
|
+
return builder;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private upsert<T extends Record<string, any>>(entityName: string, data: any, conditions: any = {}): AugmentedEntity<any> {
|
|
274
|
+
const hasId = data?.id && typeof data.id === 'number';
|
|
275
|
+
const existing = hasId
|
|
276
|
+
? this._getById(entityName, data.id)
|
|
277
|
+
: Object.keys(conditions ?? {}).length > 0
|
|
278
|
+
? this._getOne(entityName, conditions)
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
if (existing) {
|
|
282
|
+
const updateData = { ...data };
|
|
283
|
+
delete updateData.id;
|
|
284
|
+
return this.update(entityName, existing.id, updateData) as AugmentedEntity<any>;
|
|
285
|
+
}
|
|
286
|
+
const insertData = { ...(conditions ?? {}), ...(data ?? {}) };
|
|
287
|
+
delete insertData.id;
|
|
288
|
+
return this.insert(entityName, insertData);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private delete(entityName: string, id: number): void {
|
|
292
|
+
const entity = this._getById(entityName, id);
|
|
293
|
+
if (entity) {
|
|
294
|
+
this.db.query(`DELETE FROM ${entityName} WHERE id = ?`).run(id);
|
|
295
|
+
this.emit('delete', entityName, entity);
|
|
296
|
+
this.subscriptions.delete[entityName]?.forEach(cb => cb(entity));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ===========================================================================
|
|
301
|
+
// Entity Methods
|
|
302
|
+
// ===========================================================================
|
|
303
|
+
|
|
304
|
+
private _attachMethods<T extends Record<string, any>>(
|
|
305
|
+
entityName: string, entity: T
|
|
306
|
+
): AugmentedEntity<any> {
|
|
307
|
+
const augmented = entity as any;
|
|
308
|
+
augmented.update = (data: any) => this.update(entityName, entity.id, data);
|
|
309
|
+
augmented.delete = () => this.delete(entityName, entity.id);
|
|
310
|
+
|
|
311
|
+
// Attach lazy relationship navigation
|
|
312
|
+
for (const rel of this.relationships) {
|
|
313
|
+
if (rel.from === entityName && rel.type === 'belongs-to') {
|
|
314
|
+
// book.author() → lazy load parent via author_id FK
|
|
315
|
+
augmented[rel.relationshipField] = () => {
|
|
316
|
+
const fkValue = entity[rel.foreignKey];
|
|
317
|
+
return fkValue ? this._getById(rel.to, fkValue) : null;
|
|
318
|
+
};
|
|
319
|
+
} else if (rel.from === entityName && rel.type === 'one-to-many') {
|
|
320
|
+
// author.books() → lazy load children
|
|
321
|
+
const belongsToRel = this.relationships.find(
|
|
322
|
+
r => r.type === 'belongs-to' && r.from === rel.to && r.to === rel.from
|
|
323
|
+
);
|
|
324
|
+
if (belongsToRel) {
|
|
325
|
+
const fk = belongsToRel.foreignKey;
|
|
326
|
+
augmented[rel.relationshipField] = () => {
|
|
327
|
+
return this._findMany(rel.to, { [fk]: entity.id });
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Auto-persist proxy: setting a field auto-updates the DB row
|
|
334
|
+
const storableFieldNames = new Set(getStorableFields(this.schemas[entityName]!).map(f => f.name));
|
|
335
|
+
return new Proxy(augmented, {
|
|
336
|
+
set: (target, prop: string, value) => {
|
|
337
|
+
if (storableFieldNames.has(prop) && target[prop] !== value) {
|
|
338
|
+
this.update(entityName, target.id, { [prop]: value });
|
|
339
|
+
}
|
|
340
|
+
target[prop] = value;
|
|
341
|
+
return true;
|
|
342
|
+
},
|
|
343
|
+
get: (target, prop, receiver) => Reflect.get(target, prop, receiver),
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ===========================================================================
|
|
348
|
+
// SQL Helpers
|
|
349
|
+
// ===========================================================================
|
|
350
|
+
|
|
351
|
+
private buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] } {
|
|
352
|
+
const parts: string[] = [];
|
|
353
|
+
const values: any[] = [];
|
|
354
|
+
|
|
355
|
+
for (const key in conditions) {
|
|
356
|
+
if (key.startsWith('$')) {
|
|
357
|
+
if (key === '$or' && Array.isArray(conditions[key])) {
|
|
358
|
+
const orBranches = conditions[key] as Record<string, any>[];
|
|
359
|
+
const orParts: string[] = [];
|
|
360
|
+
for (const branch of orBranches) {
|
|
361
|
+
const sub = this.buildWhereClause(branch, tablePrefix);
|
|
362
|
+
if (sub.clause) {
|
|
363
|
+
orParts.push(`(${sub.clause.replace(/^WHERE /, '')})`);
|
|
364
|
+
values.push(...sub.values);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (orParts.length > 0) parts.push(`(${orParts.join(' OR ')})`);
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const value = conditions[key];
|
|
372
|
+
const fieldName = tablePrefix ? `${tablePrefix}.${key}` : key;
|
|
373
|
+
|
|
374
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
|
|
375
|
+
const operator = Object.keys(value)[0];
|
|
376
|
+
if (!operator?.startsWith('$')) {
|
|
377
|
+
throw new Error(`Querying on nested object '${key}' not supported. Use operators like $gt.`);
|
|
378
|
+
}
|
|
379
|
+
const operand = value[operator];
|
|
380
|
+
|
|
381
|
+
if (operator === '$in') {
|
|
382
|
+
if (!Array.isArray(operand)) throw new Error(`$in for '${key}' requires an array`);
|
|
383
|
+
if (operand.length === 0) { parts.push('1 = 0'); continue; }
|
|
384
|
+
parts.push(`${fieldName} IN (${operand.map(() => '?').join(', ')})`);
|
|
385
|
+
values.push(...operand.map((v: any) => transformForStorage({ v }).v));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const sqlOp = ({ $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=' } as Record<string, string>)[operator];
|
|
390
|
+
if (!sqlOp) throw new Error(`Unsupported operator '${operator}' on '${key}'`);
|
|
391
|
+
parts.push(`${fieldName} ${sqlOp} ?`);
|
|
392
|
+
values.push(transformForStorage({ operand }).operand);
|
|
393
|
+
} else {
|
|
394
|
+
parts.push(`${fieldName} = ?`);
|
|
395
|
+
values.push(transformForStorage({ value }).value);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { clause: parts.length > 0 ? `WHERE ${parts.join(' AND ')}` : '', values };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ===========================================================================
|
|
403
|
+
// Transactions
|
|
404
|
+
// ===========================================================================
|
|
405
|
+
|
|
406
|
+
public transaction<T>(callback: () => T): T {
|
|
407
|
+
try {
|
|
408
|
+
this.db.run('BEGIN TRANSACTION');
|
|
409
|
+
const result = callback();
|
|
410
|
+
this.db.run('COMMIT');
|
|
411
|
+
return result;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
this.db.run('ROLLBACK');
|
|
414
|
+
throw new Error(`Transaction failed: ${(error as Error).message}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ===========================================================================
|
|
419
|
+
// Events
|
|
420
|
+
// ===========================================================================
|
|
421
|
+
|
|
422
|
+
private subscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
|
|
423
|
+
this.subscriptions[event][entityName] = this.subscriptions[event][entityName] || [];
|
|
424
|
+
this.subscriptions[event][entityName].push(callback);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private unsubscribe(event: 'insert' | 'update' | 'delete', entityName: string, callback: (data: any) => void): void {
|
|
428
|
+
if (this.subscriptions[event][entityName]) {
|
|
429
|
+
this.subscriptions[event][entityName] = this.subscriptions[event][entityName].filter(cb => cb !== callback);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ===========================================================================
|
|
434
|
+
// Query Builders
|
|
435
|
+
// ===========================================================================
|
|
436
|
+
|
|
437
|
+
private _createQueryBuilder(entityName: string, initialCols: string[]): QueryBuilder<any> {
|
|
438
|
+
const schema = this.schemas[entityName]!;
|
|
439
|
+
|
|
440
|
+
const executor = (sql: string, params: any[], raw: boolean): any[] => {
|
|
441
|
+
const rows = this.db.query(sql).all(...params);
|
|
442
|
+
if (raw) return rows;
|
|
443
|
+
return rows.map((row: any) => this._attachMethods(entityName, transformFromStorage(row, schema)));
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const singleExecutor = (sql: string, params: any[], raw: boolean): any | null => {
|
|
447
|
+
const results = executor(sql, params, raw);
|
|
448
|
+
return results.length > 0 ? results[0] : null;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const joinResolver = (fromTable: string, toTable: string): { fk: string; pk: string } | null => {
|
|
452
|
+
const belongsTo = this.relationships.find(
|
|
453
|
+
r => r.type === 'belongs-to' && r.from === fromTable && r.to === toTable
|
|
454
|
+
);
|
|
455
|
+
if (belongsTo) return { fk: belongsTo.foreignKey, pk: 'id' };
|
|
456
|
+
const reverse = this.relationships.find(
|
|
457
|
+
r => r.type === 'belongs-to' && r.from === toTable && r.to === fromTable
|
|
458
|
+
);
|
|
459
|
+
if (reverse) return { fk: 'id', pk: reverse.foreignKey };
|
|
460
|
+
return null;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver);
|
|
464
|
+
if (initialCols.length > 0) builder.select(...initialCols);
|
|
465
|
+
return builder;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Proxy callback query for complex SQL-like JOINs */
|
|
469
|
+
public query<T extends Record<string, any> = Record<string, any>>(
|
|
470
|
+
callback: (ctx: { [K in keyof Schemas]: ProxyColumns<InferSchema<Schemas[K]>> }) => ProxyQueryResult
|
|
471
|
+
): T[] {
|
|
472
|
+
return executeProxyQuery(
|
|
473
|
+
this.schemas,
|
|
474
|
+
callback as any,
|
|
475
|
+
(sql: string, params: any[]) => this.db.query(sql).all(...params) as T[],
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// =============================================================================
|
|
481
|
+
// Public Export
|
|
482
|
+
// =============================================================================
|
|
483
|
+
|
|
484
|
+
const Database = _Database as unknown as new <S extends SchemaMap, const R extends RelationsConfig = {}>(
|
|
485
|
+
dbFile: string, schemas: S, options?: DatabaseOptions<R>
|
|
486
|
+
) => _Database<S> & TypedNavAccessors<S, R>;
|
|
487
|
+
|
|
488
|
+
type Database<S extends SchemaMap, R extends RelationsConfig = {}> = _Database<S> & TypedNavAccessors<S, R>;
|
|
489
|
+
|
|
490
|
+
export { Database };
|
|
491
|
+
export type { Database as DatabaseType };
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sqlite-zod-orm — Type-safe SQLite ORM for Bun with Zod schemas.
|
|
3
|
+
*
|
|
4
|
+
* @module sqlite-zod-orm
|
|
5
|
+
*/
|
|
6
|
+
export { Database } from './database';
|
|
7
|
+
export type { DatabaseType } from './database';
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
SchemaMap, DatabaseOptions, Relationship,
|
|
11
|
+
EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder,
|
|
12
|
+
InferSchema, EntityData, IndexDef,
|
|
13
|
+
ProxyColumns, ColumnRef,
|
|
14
|
+
} from './types';
|
|
15
|
+
|
|
16
|
+
export { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
export { QueryBuilder } from './query-builder';
|
|
19
|
+
export { ColumnNode, type ProxyQueryResult } from './proxy-query';
|
|
20
|
+
export {
|
|
21
|
+
type ASTNode, type WhereCallback, type SetCallback,
|
|
22
|
+
type TypedColumnProxy, type FunctionProxy, type Operators,
|
|
23
|
+
compileAST, wrapNode, createColumnProxy, createFunctionProxy, op,
|
|
24
|
+
} from './ast';
|