sqlite-zod-orm 3.10.0 → 3.11.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/dist/index.js +154 -8
- package/package.json +1 -1
- package/src/builder.ts +88 -0
- package/src/context.ts +9 -0
- package/src/crud.ts +25 -2
- package/src/database.ts +49 -2
- package/src/helpers.ts +11 -0
- package/src/index.ts +1 -1
- package/src/iqo.ts +29 -3
- package/src/query.ts +7 -0
- package/src/types.ts +18 -1
package/dist/index.js
CHANGED
|
@@ -4149,7 +4149,9 @@ var OPERATOR_MAP = {
|
|
|
4149
4149
|
$in: "IN",
|
|
4150
4150
|
$like: "LIKE",
|
|
4151
4151
|
$notIn: "NOT IN",
|
|
4152
|
-
$between: "BETWEEN"
|
|
4152
|
+
$between: "BETWEEN",
|
|
4153
|
+
$isNull: "IS NULL",
|
|
4154
|
+
$isNotNull: "IS NOT NULL"
|
|
4153
4155
|
};
|
|
4154
4156
|
function transformValueForStorage(value) {
|
|
4155
4157
|
if (value instanceof Date)
|
|
@@ -4173,7 +4175,7 @@ function compileIQO(tableName, iqo) {
|
|
|
4173
4175
|
selectParts.push(`${j.table}.*`);
|
|
4174
4176
|
}
|
|
4175
4177
|
}
|
|
4176
|
-
let sql = `SELECT ${selectParts.join(", ")} FROM ${tableName}`;
|
|
4178
|
+
let sql = `SELECT ${iqo.distinct ? "DISTINCT " : ""}${selectParts.join(", ")} FROM ${tableName}`;
|
|
4177
4179
|
for (const j of iqo.joins) {
|
|
4178
4180
|
sql += ` JOIN ${j.table} ON ${tableName}.${j.fromCol} = ${j.table}.${j.toCol}`;
|
|
4179
4181
|
}
|
|
@@ -4206,6 +4208,10 @@ function compileIQO(tableName, iqo) {
|
|
|
4206
4208
|
const [min, max] = w.value;
|
|
4207
4209
|
whereParts.push(`${qualify(w.field)} BETWEEN ? AND ?`);
|
|
4208
4210
|
params.push(transformValueForStorage(min), transformValueForStorage(max));
|
|
4211
|
+
} else if (w.operator === "IS NULL") {
|
|
4212
|
+
whereParts.push(`${qualify(w.field)} IS NULL`);
|
|
4213
|
+
} else if (w.operator === "IS NOT NULL") {
|
|
4214
|
+
whereParts.push(`${qualify(w.field)} IS NOT NULL`);
|
|
4209
4215
|
} else {
|
|
4210
4216
|
whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
|
|
4211
4217
|
params.push(transformValueForStorage(w.value));
|
|
@@ -4241,6 +4247,22 @@ function compileIQO(tableName, iqo) {
|
|
|
4241
4247
|
if (iqo.groupBy.length > 0) {
|
|
4242
4248
|
sql += ` GROUP BY ${iqo.groupBy.join(", ")}`;
|
|
4243
4249
|
}
|
|
4250
|
+
if (iqo.having && iqo.having.length > 0) {
|
|
4251
|
+
const havingParts = [];
|
|
4252
|
+
for (const h of iqo.having) {
|
|
4253
|
+
if (h.operator === "IS NULL") {
|
|
4254
|
+
havingParts.push(`${h.field} IS NULL`);
|
|
4255
|
+
} else if (h.operator === "IS NOT NULL") {
|
|
4256
|
+
havingParts.push(`${h.field} IS NOT NULL`);
|
|
4257
|
+
} else {
|
|
4258
|
+
havingParts.push(`${h.field} ${h.operator} ?`);
|
|
4259
|
+
params.push(transformValueForStorage(h.value));
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
if (havingParts.length > 0) {
|
|
4263
|
+
sql += ` HAVING ${havingParts.join(" AND ")}`;
|
|
4264
|
+
}
|
|
4265
|
+
}
|
|
4244
4266
|
if (iqo.orderBy.length > 0) {
|
|
4245
4267
|
const parts = iqo.orderBy.map((o) => `${o.field} ${o.direction.toUpperCase()}`);
|
|
4246
4268
|
sql += ` ORDER BY ${parts.join(", ")}`;
|
|
@@ -4274,11 +4296,13 @@ class QueryBuilder {
|
|
|
4274
4296
|
whereAST: null,
|
|
4275
4297
|
joins: [],
|
|
4276
4298
|
groupBy: [],
|
|
4299
|
+
having: [],
|
|
4277
4300
|
limit: null,
|
|
4278
4301
|
offset: null,
|
|
4279
4302
|
orderBy: [],
|
|
4280
4303
|
includes: [],
|
|
4281
|
-
raw: false
|
|
4304
|
+
raw: false,
|
|
4305
|
+
distinct: false
|
|
4282
4306
|
};
|
|
4283
4307
|
}
|
|
4284
4308
|
select(...cols) {
|
|
@@ -4433,6 +4457,61 @@ class QueryBuilder {
|
|
|
4433
4457
|
this.iqo.groupBy.push(...fields);
|
|
4434
4458
|
return this;
|
|
4435
4459
|
}
|
|
4460
|
+
distinct() {
|
|
4461
|
+
this.iqo.distinct = true;
|
|
4462
|
+
return this;
|
|
4463
|
+
}
|
|
4464
|
+
withTrashed() {
|
|
4465
|
+
this.iqo.wheres = this.iqo.wheres.filter((w) => !(w.field === "deletedAt" && w.operator === "IS NULL"));
|
|
4466
|
+
return this;
|
|
4467
|
+
}
|
|
4468
|
+
having(conditions) {
|
|
4469
|
+
for (const [field, value] of Object.entries(conditions)) {
|
|
4470
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
4471
|
+
for (const [opKey, operand] of Object.entries(value)) {
|
|
4472
|
+
const sqlOp = OPERATOR_MAP[opKey];
|
|
4473
|
+
if (!sqlOp)
|
|
4474
|
+
throw new Error(`Unsupported having operator: '${opKey}'`);
|
|
4475
|
+
this.iqo.having.push({ field, operator: sqlOp, value: operand });
|
|
4476
|
+
}
|
|
4477
|
+
} else {
|
|
4478
|
+
this.iqo.having.push({ field, operator: "=", value });
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
return this;
|
|
4482
|
+
}
|
|
4483
|
+
sum(field) {
|
|
4484
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
4485
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT COALESCE(SUM("${field}"), 0) as val FROM`);
|
|
4486
|
+
const results = this.executor(aggSql, params, true);
|
|
4487
|
+
return results[0]?.val ?? 0;
|
|
4488
|
+
}
|
|
4489
|
+
avg(field) {
|
|
4490
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
4491
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT AVG("${field}") as val FROM`);
|
|
4492
|
+
const results = this.executor(aggSql, params, true);
|
|
4493
|
+
return results[0]?.val ?? 0;
|
|
4494
|
+
}
|
|
4495
|
+
min(field) {
|
|
4496
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
4497
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MIN("${field}") as val FROM`);
|
|
4498
|
+
const results = this.executor(aggSql, params, true);
|
|
4499
|
+
return results[0]?.val ?? null;
|
|
4500
|
+
}
|
|
4501
|
+
max(field) {
|
|
4502
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
4503
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MAX("${field}") as val FROM`);
|
|
4504
|
+
const results = this.executor(aggSql, params, true);
|
|
4505
|
+
return results[0]?.val ?? null;
|
|
4506
|
+
}
|
|
4507
|
+
paginate(page = 1, perPage = 20) {
|
|
4508
|
+
const total = this.count();
|
|
4509
|
+
const pages = Math.ceil(total / perPage);
|
|
4510
|
+
this.iqo.limit = perPage;
|
|
4511
|
+
this.iqo.offset = (page - 1) * perPage;
|
|
4512
|
+
const data = this.all();
|
|
4513
|
+
return { data, total, page, perPage, pages };
|
|
4514
|
+
}
|
|
4436
4515
|
then(onfulfilled, onrejected) {
|
|
4437
4516
|
try {
|
|
4438
4517
|
const result = this.all();
|
|
@@ -4638,6 +4717,8 @@ function executeProxyQuery(schemas, callback, executor) {
|
|
|
4638
4717
|
function createQueryBuilder(ctx, entityName, initialCols) {
|
|
4639
4718
|
const schema = ctx.schemas[entityName];
|
|
4640
4719
|
const executor = (sql, params, raw) => {
|
|
4720
|
+
if (ctx.debug)
|
|
4721
|
+
console.log("[satidb]", sql, params);
|
|
4641
4722
|
const rows = ctx.db.query(sql).all(...params);
|
|
4642
4723
|
if (raw)
|
|
4643
4724
|
return rows;
|
|
@@ -4707,6 +4788,9 @@ function createQueryBuilder(ctx, entityName, initialCols) {
|
|
|
4707
4788
|
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, eagerLoader);
|
|
4708
4789
|
if (initialCols.length > 0)
|
|
4709
4790
|
builder.select(...initialCols);
|
|
4791
|
+
if (ctx.softDeletes) {
|
|
4792
|
+
builder.where({ deletedAt: { $isNull: true } });
|
|
4793
|
+
}
|
|
4710
4794
|
return builder;
|
|
4711
4795
|
}
|
|
4712
4796
|
|
|
@@ -4771,6 +4855,14 @@ function buildWhereClause(conditions, tablePrefix) {
|
|
|
4771
4855
|
values.push(transformForStorage({ v: operand[0] }).v, transformForStorage({ v: operand[1] }).v);
|
|
4772
4856
|
continue;
|
|
4773
4857
|
}
|
|
4858
|
+
if (operator === "$isNull") {
|
|
4859
|
+
parts.push(`${fieldName} IS NULL`);
|
|
4860
|
+
continue;
|
|
4861
|
+
}
|
|
4862
|
+
if (operator === "$isNotNull") {
|
|
4863
|
+
parts.push(`${fieldName} IS NOT NULL`);
|
|
4864
|
+
continue;
|
|
4865
|
+
}
|
|
4774
4866
|
const sqlOp = { $gt: ">", $gte: ">=", $lt: "<", $lte: "<=", $ne: "!=" }[operator];
|
|
4775
4867
|
if (!sqlOp)
|
|
4776
4868
|
throw new Error(`Unsupported operator '${operator}' on '${key}'`);
|
|
@@ -4807,9 +4899,16 @@ function insert(ctx, entityName, data) {
|
|
|
4807
4899
|
const schema = ctx.schemas[entityName];
|
|
4808
4900
|
const validatedData = asZodObject(schema).passthrough().parse(data);
|
|
4809
4901
|
const transformed = transformForStorage(validatedData);
|
|
4902
|
+
if (ctx.timestamps) {
|
|
4903
|
+
const now = new Date().toISOString();
|
|
4904
|
+
transformed.createdAt = now;
|
|
4905
|
+
transformed.updatedAt = now;
|
|
4906
|
+
}
|
|
4810
4907
|
const columns = Object.keys(transformed);
|
|
4811
4908
|
const quotedCols = columns.map((c) => `"${c}"`);
|
|
4812
4909
|
const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
|
|
4910
|
+
if (ctx.debug)
|
|
4911
|
+
console.log("[satidb]", sql, Object.values(transformed));
|
|
4813
4912
|
const result = ctx.db.query(sql).run(...Object.values(transformed));
|
|
4814
4913
|
const newEntity = getById(ctx, entityName, result.lastInsertRowid);
|
|
4815
4914
|
if (!newEntity)
|
|
@@ -4820,10 +4919,16 @@ function update(ctx, entityName, id, data) {
|
|
|
4820
4919
|
const schema = ctx.schemas[entityName];
|
|
4821
4920
|
const validatedData = asZodObject(schema).partial().parse(data);
|
|
4822
4921
|
const transformed = transformForStorage(validatedData);
|
|
4823
|
-
if (Object.keys(transformed).length === 0)
|
|
4922
|
+
if (Object.keys(transformed).length === 0 && !ctx.timestamps)
|
|
4824
4923
|
return getById(ctx, entityName, id);
|
|
4924
|
+
if (ctx.timestamps) {
|
|
4925
|
+
transformed.updatedAt = new Date().toISOString();
|
|
4926
|
+
}
|
|
4825
4927
|
const setClause = Object.keys(transformed).map((key) => `"${key}" = ?`).join(", ");
|
|
4826
|
-
|
|
4928
|
+
const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
|
|
4929
|
+
if (ctx.debug)
|
|
4930
|
+
console.log("[satidb]", sql, [...Object.values(transformed), id]);
|
|
4931
|
+
ctx.db.query(sql).run(...Object.values(transformed), id);
|
|
4827
4932
|
return getById(ctx, entityName, id);
|
|
4828
4933
|
}
|
|
4829
4934
|
function updateWhere(ctx, entityName, data, conditions) {
|
|
@@ -4894,6 +4999,11 @@ function insertMany(ctx, entityName, rows) {
|
|
|
4894
4999
|
for (const data of rows) {
|
|
4895
5000
|
const validatedData = zodSchema.parse(data);
|
|
4896
5001
|
const transformed = transformForStorage(validatedData);
|
|
5002
|
+
if (ctx.timestamps) {
|
|
5003
|
+
const now = new Date().toISOString();
|
|
5004
|
+
transformed.createdAt = now;
|
|
5005
|
+
transformed.updatedAt = now;
|
|
5006
|
+
}
|
|
4897
5007
|
const columns = Object.keys(transformed);
|
|
4898
5008
|
const quotedCols = columns.map((c) => `"${c}"`);
|
|
4899
5009
|
const sql = columns.length === 0 ? `INSERT INTO "${entityName}" DEFAULT VALUES` : `INSERT INTO "${entityName}" (${quotedCols.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`;
|
|
@@ -4944,6 +5054,9 @@ function attachMethods(ctx, entityName, entity) {
|
|
|
4944
5054
|
class _Database {
|
|
4945
5055
|
db;
|
|
4946
5056
|
_reactive;
|
|
5057
|
+
_timestamps;
|
|
5058
|
+
_softDeletes;
|
|
5059
|
+
_debug;
|
|
4947
5060
|
schemas;
|
|
4948
5061
|
relationships;
|
|
4949
5062
|
options;
|
|
@@ -4959,6 +5072,9 @@ class _Database {
|
|
|
4959
5072
|
this.schemas = schemas;
|
|
4960
5073
|
this.options = options;
|
|
4961
5074
|
this._reactive = options.reactive !== false;
|
|
5075
|
+
this._timestamps = options.timestamps === true;
|
|
5076
|
+
this._softDeletes = options.softDeletes === true;
|
|
5077
|
+
this._debug = options.debug === true;
|
|
4962
5078
|
this._pollInterval = options.pollInterval ?? 100;
|
|
4963
5079
|
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
4964
5080
|
this._ctx = {
|
|
@@ -4966,7 +5082,10 @@ class _Database {
|
|
|
4966
5082
|
schemas: this.schemas,
|
|
4967
5083
|
relationships: this.relationships,
|
|
4968
5084
|
attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
|
|
4969
|
-
buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix)
|
|
5085
|
+
buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
|
|
5086
|
+
debug: this._debug,
|
|
5087
|
+
timestamps: this._timestamps,
|
|
5088
|
+
softDeletes: this._softDeletes
|
|
4970
5089
|
};
|
|
4971
5090
|
this.initializeTables();
|
|
4972
5091
|
if (this._reactive)
|
|
@@ -4986,8 +5105,14 @@ class _Database {
|
|
|
4986
5105
|
},
|
|
4987
5106
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
4988
5107
|
delete: (id) => {
|
|
4989
|
-
if (typeof id === "number")
|
|
5108
|
+
if (typeof id === "number") {
|
|
5109
|
+
if (this._softDeletes) {
|
|
5110
|
+
const now = new Date().toISOString();
|
|
5111
|
+
this.db.run(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`, now, id);
|
|
5112
|
+
return;
|
|
5113
|
+
}
|
|
4990
5114
|
return deleteEntity(this._ctx, entityName, id);
|
|
5115
|
+
}
|
|
4991
5116
|
return createDeleteBuilder(this._ctx, entityName);
|
|
4992
5117
|
},
|
|
4993
5118
|
select: (...cols) => createQueryBuilder(this._ctx, entityName, cols),
|
|
@@ -5003,6 +5128,13 @@ class _Database {
|
|
|
5003
5128
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
5004
5129
|
const storableFields = getStorableFields(schema);
|
|
5005
5130
|
const columnDefs = storableFields.map((f) => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
|
|
5131
|
+
if (this._timestamps) {
|
|
5132
|
+
columnDefs.push('"createdAt" TEXT');
|
|
5133
|
+
columnDefs.push('"updatedAt" TEXT');
|
|
5134
|
+
}
|
|
5135
|
+
if (this._softDeletes) {
|
|
5136
|
+
columnDefs.push('"deletedAt" TEXT');
|
|
5137
|
+
}
|
|
5006
5138
|
const constraints = [];
|
|
5007
5139
|
const belongsToRels = this.relationships.filter((rel) => rel.type === "belongs-to" && rel.from === entityName);
|
|
5008
5140
|
for (const rel of belongsToRels) {
|
|
@@ -5127,7 +5259,21 @@ class _Database {
|
|
|
5127
5259
|
this.db.close();
|
|
5128
5260
|
}
|
|
5129
5261
|
query(callback) {
|
|
5130
|
-
return executeProxyQuery(this.schemas, callback, (sql, params) =>
|
|
5262
|
+
return executeProxyQuery(this.schemas, callback, (sql, params) => {
|
|
5263
|
+
if (this._debug)
|
|
5264
|
+
console.log("[satidb]", sql, params);
|
|
5265
|
+
return this.db.query(sql).all(...params);
|
|
5266
|
+
});
|
|
5267
|
+
}
|
|
5268
|
+
raw(sql, ...params) {
|
|
5269
|
+
if (this._debug)
|
|
5270
|
+
console.log("[satidb]", sql, params);
|
|
5271
|
+
return this.db.query(sql).all(...params);
|
|
5272
|
+
}
|
|
5273
|
+
exec(sql, ...params) {
|
|
5274
|
+
if (this._debug)
|
|
5275
|
+
console.log("[satidb]", sql, params);
|
|
5276
|
+
this.db.run(sql, ...params);
|
|
5131
5277
|
}
|
|
5132
5278
|
}
|
|
5133
5279
|
var Database = _Database;
|
package/package.json
CHANGED
package/src/builder.ts
CHANGED
|
@@ -57,11 +57,13 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
57
57
|
whereAST: null,
|
|
58
58
|
joins: [],
|
|
59
59
|
groupBy: [],
|
|
60
|
+
having: [],
|
|
60
61
|
limit: null,
|
|
61
62
|
offset: null,
|
|
62
63
|
orderBy: [],
|
|
63
64
|
includes: [],
|
|
64
65
|
raw: false,
|
|
66
|
+
distinct: false,
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -293,6 +295,92 @@ export class QueryBuilder<T extends Record<string, any>> {
|
|
|
293
295
|
return this;
|
|
294
296
|
}
|
|
295
297
|
|
|
298
|
+
/** Return only distinct rows. */
|
|
299
|
+
distinct(): this {
|
|
300
|
+
this.iqo.distinct = true;
|
|
301
|
+
return this;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Include soft-deleted rows in query results.
|
|
306
|
+
* Only relevant when `softDeletes: true` is set in Database options.
|
|
307
|
+
*/
|
|
308
|
+
withTrashed(): this {
|
|
309
|
+
// Remove the auto-injected `deletedAt IS NULL` filter
|
|
310
|
+
this.iqo.wheres = this.iqo.wheres.filter(
|
|
311
|
+
w => !(w.field === 'deletedAt' && w.operator === 'IS NULL')
|
|
312
|
+
);
|
|
313
|
+
return this;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Add HAVING conditions (used after groupBy for aggregate filtering).
|
|
318
|
+
*
|
|
319
|
+
* ```ts
|
|
320
|
+
* db.orders.select('user_id').groupBy('user_id')
|
|
321
|
+
* .having({ 'COUNT(*)': { $gt: 5 } })
|
|
322
|
+
* .raw().all()
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
having(conditions: Record<string, any>): this {
|
|
326
|
+
for (const [field, value] of Object.entries(conditions)) {
|
|
327
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
328
|
+
for (const [opKey, operand] of Object.entries(value)) {
|
|
329
|
+
const sqlOp = OPERATOR_MAP[opKey as WhereOperator];
|
|
330
|
+
if (!sqlOp) throw new Error(`Unsupported having operator: '${opKey}'`);
|
|
331
|
+
this.iqo.having.push({ field, operator: sqlOp as WhereCondition['operator'], value: operand });
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
this.iqo.having.push({ field, operator: '=', value });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------- Aggregate Methods ----------
|
|
341
|
+
|
|
342
|
+
/** Returns the SUM of a numeric column. */
|
|
343
|
+
sum(field: keyof T & string): number {
|
|
344
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
345
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT COALESCE(SUM("${field}"), 0) as val FROM`);
|
|
346
|
+
const results = this.executor(aggSql, params, true);
|
|
347
|
+
return (results[0] as any)?.val ?? 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Returns the AVG of a numeric column. */
|
|
351
|
+
avg(field: keyof T & string): number {
|
|
352
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
353
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT AVG("${field}") as val FROM`);
|
|
354
|
+
const results = this.executor(aggSql, params, true);
|
|
355
|
+
return (results[0] as any)?.val ?? 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Returns the MIN of a column. */
|
|
359
|
+
min(field: keyof T & string): number | string | null {
|
|
360
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
361
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MIN("${field}") as val FROM`);
|
|
362
|
+
const results = this.executor(aggSql, params, true);
|
|
363
|
+
return (results[0] as any)?.val ?? null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Returns the MAX of a column. */
|
|
367
|
+
max(field: keyof T & string): number | string | null {
|
|
368
|
+
const { sql: selectSql, params } = compileIQO(this.tableName, this.iqo);
|
|
369
|
+
const aggSql = selectSql.replace(/^SELECT .+? FROM/, `SELECT MAX("${field}") as val FROM`);
|
|
370
|
+
const results = this.executor(aggSql, params, true);
|
|
371
|
+
return (results[0] as any)?.val ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Paginate results. Returns { data, total, page, perPage, pages }. */
|
|
375
|
+
paginate(page: number = 1, perPage: number = 20): { data: T[]; total: number; page: number; perPage: number; pages: number } {
|
|
376
|
+
const total = this.count();
|
|
377
|
+
const pages = Math.ceil(total / perPage);
|
|
378
|
+
this.iqo.limit = perPage;
|
|
379
|
+
this.iqo.offset = (page - 1) * perPage;
|
|
380
|
+
const data = this.all();
|
|
381
|
+
return { data, total, page, perPage, pages };
|
|
382
|
+
}
|
|
383
|
+
|
|
296
384
|
|
|
297
385
|
|
|
298
386
|
// ---------- Thenable (async/await support) ----------
|
package/src/context.ts
CHANGED
|
@@ -22,4 +22,13 @@ export interface DatabaseContext {
|
|
|
22
22
|
|
|
23
23
|
/** Build a WHERE clause from a conditions object. */
|
|
24
24
|
buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] };
|
|
25
|
+
|
|
26
|
+
/** Whether to log SQL to console. */
|
|
27
|
+
debug: boolean;
|
|
28
|
+
|
|
29
|
+
/** Whether tables have createdAt/updatedAt columns. */
|
|
30
|
+
timestamps: boolean;
|
|
31
|
+
|
|
32
|
+
/** Whether soft deletes are enabled (deletedAt column). */
|
|
33
|
+
softDeletes: boolean;
|
|
25
34
|
}
|
package/src/crud.ts
CHANGED
|
@@ -42,6 +42,14 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
42
42
|
const schema = ctx.schemas[entityName]!;
|
|
43
43
|
const validatedData = asZodObject(schema).passthrough().parse(data);
|
|
44
44
|
const transformed = transformForStorage(validatedData);
|
|
45
|
+
|
|
46
|
+
// Auto-inject timestamps
|
|
47
|
+
if (ctx.timestamps) {
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
transformed.createdAt = now;
|
|
50
|
+
transformed.updatedAt = now;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
const columns = Object.keys(transformed);
|
|
46
54
|
|
|
47
55
|
const quotedCols = columns.map(c => `"${c}"`);
|
|
@@ -49,6 +57,7 @@ export function insert<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
49
57
|
? `INSERT INTO "${entityName}" DEFAULT VALUES`
|
|
50
58
|
: `INSERT INTO "${entityName}" (${quotedCols.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
|
|
51
59
|
|
|
60
|
+
if (ctx.debug) console.log('[satidb]', sql, Object.values(transformed));
|
|
52
61
|
const result = ctx.db.query(sql).run(...Object.values(transformed));
|
|
53
62
|
const newEntity = getById(ctx, entityName, result.lastInsertRowid as number);
|
|
54
63
|
if (!newEntity) throw new Error('Failed to retrieve entity after insertion');
|
|
@@ -60,10 +69,17 @@ export function update<T extends Record<string, any>>(ctx: DatabaseContext, enti
|
|
|
60
69
|
const schema = ctx.schemas[entityName]!;
|
|
61
70
|
const validatedData = asZodObject(schema).partial().parse(data);
|
|
62
71
|
const transformed = transformForStorage(validatedData);
|
|
63
|
-
if (Object.keys(transformed).length === 0) return getById(ctx, entityName, id);
|
|
72
|
+
if (Object.keys(transformed).length === 0 && !ctx.timestamps) return getById(ctx, entityName, id);
|
|
73
|
+
|
|
74
|
+
// Auto-update timestamp
|
|
75
|
+
if (ctx.timestamps) {
|
|
76
|
+
transformed.updatedAt = new Date().toISOString();
|
|
77
|
+
}
|
|
64
78
|
|
|
65
79
|
const setClause = Object.keys(transformed).map(key => `"${key}" = ?`).join(', ');
|
|
66
|
-
|
|
80
|
+
const sql = `UPDATE "${entityName}" SET ${setClause} WHERE id = ?`;
|
|
81
|
+
if (ctx.debug) console.log('[satidb]', sql, [...Object.values(transformed), id]);
|
|
82
|
+
ctx.db.query(sql).run(...Object.values(transformed), id);
|
|
67
83
|
|
|
68
84
|
return getById(ctx, entityName, id);
|
|
69
85
|
}
|
|
@@ -147,6 +163,13 @@ export function insertMany<T extends Record<string, any>>(ctx: DatabaseContext,
|
|
|
147
163
|
for (const data of rows) {
|
|
148
164
|
const validatedData = zodSchema.parse(data);
|
|
149
165
|
const transformed = transformForStorage(validatedData);
|
|
166
|
+
|
|
167
|
+
if (ctx.timestamps) {
|
|
168
|
+
const now = new Date().toISOString();
|
|
169
|
+
transformed.createdAt = now;
|
|
170
|
+
transformed.updatedAt = now;
|
|
171
|
+
}
|
|
172
|
+
|
|
150
173
|
const columns = Object.keys(transformed);
|
|
151
174
|
const quotedCols = columns.map(c => `"${c}"`);
|
|
152
175
|
const sql = columns.length === 0
|
package/src/database.ts
CHANGED
|
@@ -41,6 +41,9 @@ type Listener = {
|
|
|
41
41
|
class _Database<Schemas extends SchemaMap> {
|
|
42
42
|
private db: SqliteDatabase;
|
|
43
43
|
private _reactive: boolean;
|
|
44
|
+
private _timestamps: boolean;
|
|
45
|
+
private _softDeletes: boolean;
|
|
46
|
+
private _debug: boolean;
|
|
44
47
|
private schemas: Schemas;
|
|
45
48
|
private relationships: Relationship[];
|
|
46
49
|
private options: DatabaseOptions;
|
|
@@ -67,6 +70,9 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
67
70
|
this.schemas = schemas;
|
|
68
71
|
this.options = options;
|
|
69
72
|
this._reactive = options.reactive !== false; // default true
|
|
73
|
+
this._timestamps = options.timestamps === true;
|
|
74
|
+
this._softDeletes = options.softDeletes === true;
|
|
75
|
+
this._debug = options.debug === true;
|
|
70
76
|
this._pollInterval = options.pollInterval ?? 100;
|
|
71
77
|
this.relationships = options.relations ? parseRelationsConfig(options.relations, schemas) : [];
|
|
72
78
|
|
|
@@ -77,6 +83,9 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
77
83
|
relationships: this.relationships,
|
|
78
84
|
attachMethods: (name, entity) => attachMethods(this._ctx, name, entity),
|
|
79
85
|
buildWhereClause: (conds, prefix) => buildWhereClause(conds, prefix),
|
|
86
|
+
debug: this._debug,
|
|
87
|
+
timestamps: this._timestamps,
|
|
88
|
+
softDeletes: this._softDeletes,
|
|
80
89
|
};
|
|
81
90
|
|
|
82
91
|
this.initializeTables();
|
|
@@ -96,7 +105,15 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
96
105
|
},
|
|
97
106
|
upsert: (conditions, data) => upsert(this._ctx, entityName, data, conditions),
|
|
98
107
|
delete: ((id?: any) => {
|
|
99
|
-
if (typeof id === 'number')
|
|
108
|
+
if (typeof id === 'number') {
|
|
109
|
+
if (this._softDeletes) {
|
|
110
|
+
// Soft delete: set deletedAt instead of removing
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
this.db.run(`UPDATE "${entityName}" SET "deletedAt" = ? WHERE id = ?`, now, id);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
return deleteEntity(this._ctx, entityName, id);
|
|
116
|
+
}
|
|
100
117
|
return createDeleteBuilder(this._ctx, entityName);
|
|
101
118
|
}) as any,
|
|
102
119
|
select: (...cols: string[]) => createQueryBuilder(this._ctx, entityName, cols),
|
|
@@ -117,6 +134,17 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
117
134
|
for (const [entityName, schema] of Object.entries(this.schemas)) {
|
|
118
135
|
const storableFields = getStorableFields(schema);
|
|
119
136
|
const columnDefs = storableFields.map(f => `"${f.name}" ${zodTypeToSqlType(f.type)}`);
|
|
137
|
+
|
|
138
|
+
// Add timestamp columns
|
|
139
|
+
if (this._timestamps) {
|
|
140
|
+
columnDefs.push('"createdAt" TEXT');
|
|
141
|
+
columnDefs.push('"updatedAt" TEXT');
|
|
142
|
+
}
|
|
143
|
+
// Add soft delete column
|
|
144
|
+
if (this._softDeletes) {
|
|
145
|
+
columnDefs.push('"deletedAt" TEXT');
|
|
146
|
+
}
|
|
147
|
+
|
|
120
148
|
const constraints: string[] = [];
|
|
121
149
|
|
|
122
150
|
const belongsToRels = this.relationships.filter(
|
|
@@ -306,9 +334,28 @@ class _Database<Schemas extends SchemaMap> {
|
|
|
306
334
|
return executeProxyQuery(
|
|
307
335
|
this.schemas,
|
|
308
336
|
callback as any,
|
|
309
|
-
(sql: string, params: any[]) =>
|
|
337
|
+
(sql: string, params: any[]) => {
|
|
338
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
339
|
+
return this.db.query(sql).all(...params) as T[];
|
|
340
|
+
},
|
|
310
341
|
);
|
|
311
342
|
}
|
|
343
|
+
|
|
344
|
+
// =========================================================================
|
|
345
|
+
// Raw SQL
|
|
346
|
+
// =========================================================================
|
|
347
|
+
|
|
348
|
+
/** Execute a raw SQL query and return results. */
|
|
349
|
+
public raw<T = any>(sql: string, ...params: any[]): T[] {
|
|
350
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
351
|
+
return this.db.query(sql).all(...params) as T[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Execute a raw SQL statement (INSERT/UPDATE/DELETE) without returning rows. */
|
|
355
|
+
public exec(sql: string, ...params: any[]): void {
|
|
356
|
+
if (this._debug) console.log('[satidb]', sql, params);
|
|
357
|
+
this.db.run(sql, ...params);
|
|
358
|
+
}
|
|
312
359
|
}
|
|
313
360
|
|
|
314
361
|
// =============================================================================
|
package/src/helpers.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { transformForStorage } from './schema';
|
|
|
13
13
|
* - Operators: `{ age: { $gt: 18 } }`
|
|
14
14
|
* - $in: `{ status: { $in: ['active', 'pending'] } }`
|
|
15
15
|
* - $or: `{ $or: [{ name: 'Alice' }, { name: 'Bob' }] }`
|
|
16
|
+
* - $isNull / $isNotNull: `{ deletedAt: { $isNull: true } }`
|
|
16
17
|
*/
|
|
17
18
|
export function buildWhereClause(conditions: Record<string, any>, tablePrefix?: string): { clause: string; values: any[] } {
|
|
18
19
|
const parts: string[] = [];
|
|
@@ -73,6 +74,16 @@ export function buildWhereClause(conditions: Record<string, any>, tablePrefix?:
|
|
|
73
74
|
continue;
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
if (operator === '$isNull') {
|
|
78
|
+
parts.push(`${fieldName} IS NULL`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (operator === '$isNotNull') {
|
|
83
|
+
parts.push(`${fieldName} IS NOT NULL`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
const sqlOp = ({ $gt: '>', $gte: '>=', $lt: '<', $lte: '<=', $ne: '!=' } as Record<string, string>)[operator];
|
|
77
88
|
if (!sqlOp) throw new Error(`Unsupported operator '${operator}' on '${key}'`);
|
|
78
89
|
parts.push(`${fieldName} ${sqlOp} ?`);
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type { DatabaseType } from './database';
|
|
|
8
8
|
|
|
9
9
|
export type {
|
|
10
10
|
SchemaMap, DatabaseOptions, Relationship,
|
|
11
|
-
EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder,
|
|
11
|
+
EntityAccessor, TypedAccessors, AugmentedEntity, UpdateBuilder, DeleteBuilder,
|
|
12
12
|
InferSchema, EntityData, IndexDef, ChangeEvent,
|
|
13
13
|
ProxyColumns, ColumnRef,
|
|
14
14
|
} from './types';
|
package/src/iqo.ts
CHANGED
|
@@ -12,11 +12,11 @@ import { type ASTNode, compileAST } from './ast';
|
|
|
12
12
|
// =============================================================================
|
|
13
13
|
|
|
14
14
|
export type OrderDirection = 'asc' | 'desc';
|
|
15
|
-
export type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between';
|
|
15
|
+
export type WhereOperator = '$gt' | '$gte' | '$lt' | '$lte' | '$ne' | '$in' | '$like' | '$notIn' | '$between' | '$isNull' | '$isNotNull';
|
|
16
16
|
|
|
17
17
|
export interface WhereCondition {
|
|
18
18
|
field: string;
|
|
19
|
-
operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN';
|
|
19
|
+
operator: '=' | '>' | '>=' | '<' | '<=' | '!=' | 'IN' | 'LIKE' | 'NOT IN' | 'BETWEEN' | 'IS NULL' | 'IS NOT NULL';
|
|
20
20
|
value: any;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -34,11 +34,13 @@ export interface IQO {
|
|
|
34
34
|
whereAST: ASTNode | null;
|
|
35
35
|
joins: JoinClause[];
|
|
36
36
|
groupBy: string[];
|
|
37
|
+
having: WhereCondition[];
|
|
37
38
|
limit: number | null;
|
|
38
39
|
offset: number | null;
|
|
39
40
|
orderBy: { field: string; direction: OrderDirection }[];
|
|
40
41
|
includes: string[];
|
|
41
42
|
raw: boolean;
|
|
43
|
+
distinct: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export const OPERATOR_MAP: Record<WhereOperator, string> = {
|
|
@@ -51,6 +53,8 @@ export const OPERATOR_MAP: Record<WhereOperator, string> = {
|
|
|
51
53
|
$like: 'LIKE',
|
|
52
54
|
$notIn: 'NOT IN',
|
|
53
55
|
$between: 'BETWEEN',
|
|
56
|
+
$isNull: 'IS NULL',
|
|
57
|
+
$isNotNull: 'IS NOT NULL',
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
export function transformValueForStorage(value: any): any {
|
|
@@ -81,7 +85,7 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
81
85
|
}
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
let sql = `SELECT ${selectParts.join(', ')} FROM ${tableName}`;
|
|
88
|
+
let sql = `SELECT ${iqo.distinct ? 'DISTINCT ' : ''}${selectParts.join(', ')} FROM ${tableName}`;
|
|
85
89
|
|
|
86
90
|
// JOIN clauses
|
|
87
91
|
for (const j of iqo.joins) {
|
|
@@ -119,6 +123,10 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
119
123
|
const [min, max] = w.value as [any, any];
|
|
120
124
|
whereParts.push(`${qualify(w.field)} BETWEEN ? AND ?`);
|
|
121
125
|
params.push(transformValueForStorage(min), transformValueForStorage(max));
|
|
126
|
+
} else if (w.operator === 'IS NULL') {
|
|
127
|
+
whereParts.push(`${qualify(w.field)} IS NULL`);
|
|
128
|
+
} else if (w.operator === 'IS NOT NULL') {
|
|
129
|
+
whereParts.push(`${qualify(w.field)} IS NOT NULL`);
|
|
122
130
|
} else {
|
|
123
131
|
whereParts.push(`${qualify(w.field)} ${w.operator} ?`);
|
|
124
132
|
params.push(transformValueForStorage(w.value));
|
|
@@ -159,6 +167,24 @@ export function compileIQO(tableName: string, iqo: IQO): { sql: string; params:
|
|
|
159
167
|
sql += ` GROUP BY ${iqo.groupBy.join(', ')}`;
|
|
160
168
|
}
|
|
161
169
|
|
|
170
|
+
// HAVING
|
|
171
|
+
if (iqo.having && iqo.having.length > 0) {
|
|
172
|
+
const havingParts: string[] = [];
|
|
173
|
+
for (const h of iqo.having) {
|
|
174
|
+
if (h.operator === 'IS NULL') {
|
|
175
|
+
havingParts.push(`${h.field} IS NULL`);
|
|
176
|
+
} else if (h.operator === 'IS NOT NULL') {
|
|
177
|
+
havingParts.push(`${h.field} IS NOT NULL`);
|
|
178
|
+
} else {
|
|
179
|
+
havingParts.push(`${h.field} ${h.operator} ?`);
|
|
180
|
+
params.push(transformValueForStorage(h.value));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (havingParts.length > 0) {
|
|
184
|
+
sql += ` HAVING ${havingParts.join(' AND ')}`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
162
188
|
// ORDER BY
|
|
163
189
|
if (iqo.orderBy.length > 0) {
|
|
164
190
|
const parts = iqo.orderBy.map(o => `${o.field} ${o.direction.toUpperCase()}`);
|
package/src/query.ts
CHANGED
|
@@ -39,6 +39,7 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
|
|
|
39
39
|
const schema = ctx.schemas[entityName]!;
|
|
40
40
|
|
|
41
41
|
const executor = (sql: string, params: any[], raw: boolean): any[] => {
|
|
42
|
+
if (ctx.debug) console.log('[satidb]', sql, params);
|
|
42
43
|
const rows = ctx.db.query(sql).all(...params);
|
|
43
44
|
if (raw) return rows;
|
|
44
45
|
return rows.map((row: any) => ctx.attachMethods(entityName, transformFromStorage(row, schema)));
|
|
@@ -132,5 +133,11 @@ export function createQueryBuilder(ctx: DatabaseContext, entityName: string, ini
|
|
|
132
133
|
|
|
133
134
|
const builder = new QueryBuilder(entityName, executor, singleExecutor, joinResolver, conditionResolver, eagerLoader);
|
|
134
135
|
if (initialCols.length > 0) builder.select(...initialCols);
|
|
136
|
+
|
|
137
|
+
// Auto-filter soft-deleted rows unless withTrashed() is called
|
|
138
|
+
if (ctx.softDeletes) {
|
|
139
|
+
builder.where({ deletedAt: { $isNull: true } });
|
|
140
|
+
}
|
|
141
|
+
|
|
135
142
|
return builder;
|
|
136
143
|
}
|
package/src/types.ts
CHANGED
|
@@ -19,7 +19,6 @@ export const asZodObject = (s: z.ZodType<any>) => s as unknown as z.ZodObject<an
|
|
|
19
19
|
/** Index definition: single column or composite columns */
|
|
20
20
|
export type IndexDef = string | string[];
|
|
21
21
|
|
|
22
|
-
/** Options for the Database constructor */
|
|
23
22
|
export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
24
23
|
indexes?: Record<string, IndexDef[]>;
|
|
25
24
|
/**
|
|
@@ -42,6 +41,24 @@ export type DatabaseOptions<R extends RelationsConfig = RelationsConfig> = {
|
|
|
42
41
|
* `.on()` will throw. Default: `true`.
|
|
43
42
|
*/
|
|
44
43
|
reactive?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Auto-add `createdAt` and `updatedAt` TEXT columns to every table.
|
|
46
|
+
* `createdAt` is set on insert, `updatedAt` on insert + update.
|
|
47
|
+
* Default: `false`.
|
|
48
|
+
*/
|
|
49
|
+
timestamps?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Enable soft deletes. Adds a `deletedAt` TEXT column to every table.
|
|
52
|
+
* `delete()` sets `deletedAt` instead of removing the row.
|
|
53
|
+
* Use `.withTrashed()` on queries to include soft-deleted rows.
|
|
54
|
+
* Default: `false`.
|
|
55
|
+
*/
|
|
56
|
+
softDeletes?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Log every SQL query to the console. Useful for debugging.
|
|
59
|
+
* Default: `false`.
|
|
60
|
+
*/
|
|
61
|
+
debug?: boolean;
|
|
45
62
|
};
|
|
46
63
|
|
|
47
64
|
export type Relationship = {
|