oakbun 0.1.1 → 0.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.
@@ -28,6 +28,14 @@ var ValidationError = class extends VelnError {
28
28
  };
29
29
 
30
30
  // src/db/sql.ts
31
+ function buildSubquery(sql, params, col) {
32
+ if (!sql) throw new Error("buildSubquery: sql must not be empty");
33
+ return {
34
+ _sql: `(${sql})`,
35
+ _params: params,
36
+ _phantom: { col, type: void 0 }
37
+ };
38
+ }
31
39
  var ON_CLAUSE_PATTERN = /^([\w]+)\.([\w]+)\s*=\s*([\w]+)\.([\w]+)$/;
32
40
  function validateAndQuoteOnClause(on) {
33
41
  const trimmed = on.trim();
@@ -50,6 +58,9 @@ function filterDefined(data) {
50
58
  function isWhereOp(val) {
51
59
  return typeof val === "object" && val !== null && "op" in val && typeof val["op"] === "string";
52
60
  }
61
+ function isSubqueryResult(val) {
62
+ return typeof val === "object" && val !== null && "_sql" in val && "_params" in val;
63
+ }
53
64
  function buildFieldCondition(key, condition, dialect) {
54
65
  if (!isWhereOp(condition)) {
55
66
  return { sql: `"${key}" = ?`, params: [condition] };
@@ -69,7 +80,11 @@ function buildFieldCondition(key, condition, dialect) {
69
80
  case "<=":
70
81
  return { sql: `"${key}" <= ?`, params: [op.value] };
71
82
  case "IN": {
72
- const vals = op.value;
83
+ const inVal = op.value;
84
+ if (isSubqueryResult(inVal)) {
85
+ return { sql: `"${key}" IN ${inVal._sql}`, params: inVal._params };
86
+ }
87
+ const vals = inVal;
73
88
  if (vals.length === 0) {
74
89
  return { sql: "1 = 0", params: [] };
75
90
  }
@@ -77,7 +92,11 @@ function buildFieldCondition(key, condition, dialect) {
77
92
  return { sql: `"${key}" IN (${placeholders})`, params: vals };
78
93
  }
79
94
  case "NOT IN": {
80
- const vals = op.value;
95
+ const inVal = op.value;
96
+ if (isSubqueryResult(inVal)) {
97
+ return { sql: `"${key}" NOT IN ${inVal._sql}`, params: inVal._params };
98
+ }
99
+ const vals = inVal;
81
100
  if (vals.length === 0) {
82
101
  return { sql: "1 = 1", params: [] };
83
102
  }
@@ -148,6 +167,37 @@ function buildInsert(tableName, data, returning = true) {
148
167
  const sql = `INSERT INTO "${tableName}" (${cols}) VALUES (${placeholders})${returning_clause}`;
149
168
  return { sql, params };
150
169
  }
170
+ function buildInsertMany(tableName, rows, returning = true) {
171
+ if (rows.length === 0) {
172
+ throw new Error("insertMany: rows array must not be empty");
173
+ }
174
+ const columns = Object.keys(rows[0]);
175
+ const quotedCols = columns.map((c) => `"${c}"`).join(", ");
176
+ const params = [];
177
+ const valueClauses = [];
178
+ for (const row of rows) {
179
+ const placeholders = [];
180
+ for (const col of columns) {
181
+ const val = row[col];
182
+ if (val === void 0) {
183
+ throw new Error(`insertMany: column "${col}" has undefined value \u2014 apply defaults before calling buildInsertMany`);
184
+ }
185
+ params.push(val);
186
+ placeholders.push("?");
187
+ }
188
+ valueClauses.push(`(${placeholders.join(", ")})`);
189
+ }
190
+ const returning_clause = returning ? " RETURNING *" : "";
191
+ const sql = `INSERT INTO "${tableName}" (${quotedCols}) VALUES ${valueClauses.join(", ")}${returning_clause}`;
192
+ return { sql, params };
193
+ }
194
+ function buildSoftDeleteUpdate(tableName, col, value, where, dialect = "sqlite") {
195
+ const serialized = value instanceof Date ? value.toISOString() : null;
196
+ const { sql: whereSql, params: whereParams } = buildWhere(where, dialect);
197
+ const setSql = `"${col}" = ?`;
198
+ const sql = whereSql ? `UPDATE "${tableName}" SET ${setSql} WHERE ${whereSql}` : `UPDATE "${tableName}" SET ${setSql}`;
199
+ return { sql, params: [serialized, ...whereParams] };
200
+ }
151
201
  function buildUpdate(tableName, patch, pk, pkValue) {
152
202
  const entries = filterDefined(patch);
153
203
  const sets = entries.map(([key]) => `"${key}" = ?`).join(", ");
@@ -203,11 +253,27 @@ function appendPaginationAndOrder(parts, options) {
203
253
  }
204
254
  }
205
255
  }
256
+ function buildUnion(parts, kind, options = {}) {
257
+ if (parts.length < 2) {
258
+ throw new Error("buildUnion: at least 2 parts required");
259
+ }
260
+ const sqls = parts.map((p) => p.sql);
261
+ const params = parts.flatMap((p) => p.params);
262
+ let sql = sqls.join(` ${kind} `);
263
+ if (options.orderBy) {
264
+ sql += ` ORDER BY "${options.orderBy.col}" ${options.orderBy.dir ?? "ASC"}`;
265
+ }
266
+ if (options.limit !== void 0) {
267
+ sql += ` LIMIT ${Math.trunc(Math.max(0, options.limit))}`;
268
+ }
269
+ return { sql, params };
270
+ }
206
271
  function buildSelect(tableName, conditions, options, dialect = "sqlite") {
207
272
  const selectList = buildSelectListFromOptions(options);
208
273
  const { sql: whereSql, params } = buildWhere(conditions, dialect);
274
+ const selectKeyword = options?.distinct ? "SELECT DISTINCT" : "SELECT";
209
275
  const parts = [
210
- whereSql ? `SELECT ${selectList} FROM "${tableName}" WHERE ${whereSql}` : `SELECT ${selectList} FROM "${tableName}"`
276
+ whereSql ? `${selectKeyword} ${selectList} FROM "${tableName}" WHERE ${whereSql}` : `${selectKeyword} ${selectList} FROM "${tableName}"`
211
277
  ];
212
278
  if (options?.groupBy && options.groupBy.length > 0) {
213
279
  parts.push(`GROUP BY ${options.groupBy.map((c) => `"${c}"`).join(", ")}`);
@@ -260,10 +326,11 @@ var VelnDB = class {
260
326
  }
261
327
  };
262
328
  var BoundVelnDB = class _BoundVelnDB {
263
- constructor(adapter, hooks, ctx, queue, queryLog) {
329
+ constructor(adapter, hooks, ctx, queue, queryLog, dialect = "sqlite") {
264
330
  this.hooks = hooks;
265
331
  this.ctx = ctx;
266
332
  this.queue = queue;
333
+ this.dialect = dialect;
267
334
  if (queryLog) {
268
335
  const log = queryLog;
269
336
  this.adapter = {
@@ -298,9 +365,11 @@ var BoundVelnDB = class _BoundVelnDB {
298
365
  hooks;
299
366
  ctx;
300
367
  queue;
368
+ dialect;
301
369
  /** Per-request query counter — incremented for every query() and execute() call on this instance. */
302
370
  _queryCount = 0;
303
371
  adapter;
372
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
373
  from(table) {
305
374
  return new SelectBuilder(this.adapter, this.hooks, this.ctx, this.queue, table, {});
306
375
  }
@@ -320,20 +389,20 @@ var BoundVelnDB = class _BoundVelnDB {
320
389
  return new JoinBuilder(this.adapter, tableName, [], [], "", []);
321
390
  }
322
391
  into(table) {
323
- return new InsertBuilder(this.adapter, this.hooks, this.ctx, this.queue, table);
324
- }
325
- /**
326
- * DataLoader-pattern relation fetch — single IN-query, no N+1.
327
- * Returns a Map keyed by the foreign-key value; each entry is an array of
328
- * matching child rows (for one-to-many relations).
329
- *
330
- * @example
331
- * const posts = await db.from(postsTable).select()
332
- * const authorMap = await db.loadRelation(posts, 'authorId', usersTable, 'id')
333
- * // → SELECT * FROM "users" WHERE "id" IN (1, 2, 3)
334
- * const withAuthors = posts.map(p => ({ ...p, author: authorMap.get(p.authorId)?.[0] ?? null }))
335
- */
336
- async loadRelation(parents, foreignKey, childTable, primaryKey) {
392
+ return new InsertBuilder(this.adapter, this.hooks, this.ctx, this.queue, table, this.dialect);
393
+ }
394
+ // Implementation
395
+ async loadRelation(parents, keyOrRelationName, tableOrSource, primaryKey) {
396
+ if (primaryKey === void 0) {
397
+ return this._loadRelationByName(
398
+ parents,
399
+ keyOrRelationName,
400
+ tableOrSource,
401
+ "many"
402
+ );
403
+ }
404
+ const foreignKey = keyOrRelationName;
405
+ const childTable = tableOrSource;
337
406
  const result = /* @__PURE__ */ new Map();
338
407
  if (parents.length === 0) return result;
339
408
  const ids = [...new Set(parents.map((p) => p[foreignKey]))];
@@ -349,15 +418,18 @@ var BoundVelnDB = class _BoundVelnDB {
349
418
  }
350
419
  return result;
351
420
  }
352
- /**
353
- * Convenience variant of loadRelation for belongs-to (many-to-one) relations.
354
- * Returns Map<fkValue, TChild> single child per key instead of an array.
355
- *
356
- * @example
357
- * const authorMap = await db.loadRelationOne(posts, 'authorId', usersTable, 'id')
358
- * const author = authorMap.get(post.authorId) ?? null
359
- */
360
- async loadRelationOne(parents, foreignKey, childTable, primaryKey) {
421
+ // Implementation
422
+ async loadRelationOne(parents, keyOrRelationName, tableOrSource, primaryKey) {
423
+ if (primaryKey === void 0) {
424
+ return this._loadRelationByName(
425
+ parents,
426
+ keyOrRelationName,
427
+ tableOrSource,
428
+ "one"
429
+ );
430
+ }
431
+ const foreignKey = keyOrRelationName;
432
+ const childTable = tableOrSource;
361
433
  const result = /* @__PURE__ */ new Map();
362
434
  if (parents.length === 0) return result;
363
435
  const ids = [...new Set(parents.map((p) => p[foreignKey]))];
@@ -367,6 +439,55 @@ var BoundVelnDB = class _BoundVelnDB {
367
439
  }
368
440
  return result;
369
441
  }
442
+ /**
443
+ * Shared implementation for name-based loadRelation / loadRelationOne.
444
+ * Reads RelationMeta from sourceTable.relations, validates the kind,
445
+ * and delegates to the explicit overload.
446
+ */
447
+ async _loadRelationByName(parents, relationName, sourceTable, mode) {
448
+ const rel = sourceTable.relations[relationName];
449
+ if (rel === void 0) {
450
+ const available = Object.keys(sourceTable.relations).join(", ") || "(none)";
451
+ throw new Error(
452
+ `Relation '${relationName}' is not defined on table '${sourceTable.name}'. Available relations: ${available}`
453
+ );
454
+ }
455
+ if (rel.kind === "manyToMany") {
456
+ throw new Error(
457
+ `manyToMany relations are not yet supported in loadRelation. Use a manual JOIN for relation '${relationName}' on table '${sourceTable.name}'.`
458
+ );
459
+ }
460
+ if (mode === "one" && rel.kind === "hasMany") {
461
+ throw new Error(
462
+ `loadRelationOne cannot be used with hasMany relation '${relationName}' on table '${sourceTable.name}'. Use loadRelation to get an array of results.`
463
+ );
464
+ }
465
+ const foreignTable = rel.getTable();
466
+ const pk = foreignTable.primaryKey;
467
+ if (rel.kind === "belongsTo") {
468
+ const ft = foreignTable;
469
+ const fk = rel.foreignKey;
470
+ if (mode === "one") {
471
+ return this.loadRelationOne(parents, fk, ft, pk);
472
+ }
473
+ return this.loadRelation(parents, fk, ft, pk);
474
+ }
475
+ const parentPk = sourceTable.primaryKey;
476
+ const result = /* @__PURE__ */ new Map();
477
+ if (parents.length === 0) return result;
478
+ const ids = [...new Set(parents.map((p) => p[parentPk]))];
479
+ const children = await this.from(foreignTable).where({ [rel.foreignKey]: { op: "IN", value: ids } }).select();
480
+ for (const child of children) {
481
+ const key = child[rel.foreignKey];
482
+ const group = result.get(key);
483
+ if (group) {
484
+ group.push(child);
485
+ } else {
486
+ result.set(key, [child]);
487
+ }
488
+ }
489
+ return result;
490
+ }
370
491
  async transaction(fn) {
371
492
  const txQueue = new RequestEventQueue();
372
493
  const result = await this.adapter.transaction(async (txAdapter) => {
@@ -411,7 +532,7 @@ function mergeWhereAnd(a, b) {
411
532
  return { AND: [a, b] };
412
533
  }
413
534
  var SelectBuilder = class _SelectBuilder {
414
- constructor(adapter, hooks, ctx, queue, table, conditions, _options = {}, _rawWhere = [], _dialect = "sqlite") {
535
+ constructor(adapter, hooks, ctx, queue, table, conditions, _options = {}, _rawWhere = [], _dialect = "sqlite", _withRelations = [], _includeDeleted = false) {
415
536
  this.adapter = adapter;
416
537
  this.hooks = hooks;
417
538
  this.ctx = ctx;
@@ -421,6 +542,8 @@ var SelectBuilder = class _SelectBuilder {
421
542
  this._options = _options;
422
543
  this._rawWhere = _rawWhere;
423
544
  this._dialect = _dialect;
545
+ this._withRelations = _withRelations;
546
+ this._includeDeleted = _includeDeleted;
424
547
  }
425
548
  adapter;
426
549
  hooks;
@@ -431,6 +554,8 @@ var SelectBuilder = class _SelectBuilder {
431
554
  _options;
432
555
  _rawWhere;
433
556
  _dialect;
557
+ _withRelations;
558
+ _includeDeleted;
434
559
  _cloneWith(conditions, rawWhere) {
435
560
  return new _SelectBuilder(
436
561
  this.adapter,
@@ -441,7 +566,9 @@ var SelectBuilder = class _SelectBuilder {
441
566
  conditions,
442
567
  this._options,
443
568
  rawWhere ?? this._rawWhere,
444
- this._dialect
569
+ this._dialect,
570
+ this._withRelations,
571
+ this._includeDeleted
445
572
  );
446
573
  }
447
574
  _clone(patch) {
@@ -454,7 +581,60 @@ var SelectBuilder = class _SelectBuilder {
454
581
  this.conditions,
455
582
  { ...this._options, ...patch },
456
583
  this._rawWhere,
457
- this._dialect
584
+ this._dialect,
585
+ this._withRelations,
586
+ this._includeDeleted
587
+ );
588
+ }
589
+ /**
590
+ * Eager-load relations alongside the main query.
591
+ * One additional IN-query per relation — never N+1 regardless of row count.
592
+ *
593
+ * @example
594
+ * const posts = await db.from(postsTable).with({ author: true }).select()
595
+ * posts[0].author // → User | null (fully typed)
596
+ * posts[0].title // → string (original fields preserved)
597
+ */
598
+ with(relations) {
599
+ const keys = Object.keys(relations);
600
+ return new _SelectBuilder(
601
+ this.adapter,
602
+ this.hooks,
603
+ this.ctx,
604
+ this.queue,
605
+ // table type cast: the schema/relations are unchanged; only T changes in the generic
606
+ this.table,
607
+ this.conditions,
608
+ this._options,
609
+ this._rawWhere,
610
+ this._dialect,
611
+ [...this._withRelations, ...keys],
612
+ this._includeDeleted
613
+ );
614
+ }
615
+ /**
616
+ * Include soft-deleted rows in the query result.
617
+ * By default, tables with `.withSoftDelete()` automatically exclude rows
618
+ * where the soft-delete column is not null.
619
+ *
620
+ * Has no effect on tables without soft delete configured.
621
+ *
622
+ * @example
623
+ * const allUsers = await db.from(usersTable).withDeleted().select()
624
+ */
625
+ withDeleted() {
626
+ return new _SelectBuilder(
627
+ this.adapter,
628
+ this.hooks,
629
+ this.ctx,
630
+ this.queue,
631
+ this.table,
632
+ this.conditions,
633
+ this._options,
634
+ this._rawWhere,
635
+ this._dialect,
636
+ this._withRelations,
637
+ true
458
638
  );
459
639
  }
460
640
  /**
@@ -481,6 +661,17 @@ var SelectBuilder = class _SelectBuilder {
481
661
  whereRaw(sql, params) {
482
662
  return this._cloneWith(this.conditions, [...this._rawWhere, { sql, params }]);
483
663
  }
664
+ /**
665
+ * Apply SELECT DISTINCT — deduplicate rows in the result set.
666
+ * Combine with `.columns()` to deduplicate on specific columns.
667
+ *
668
+ * @example
669
+ * await db.from(usersTable).columns('name').distinct().select()
670
+ * // → SELECT DISTINCT "name" FROM "users"
671
+ */
672
+ distinct() {
673
+ return this._clone({ distinct: true });
674
+ }
484
675
  /** Limit the number of rows returned. Bound as a parameter — never interpolated. */
485
676
  limit(n) {
486
677
  return this._clone({ limit: n });
@@ -502,14 +693,74 @@ var SelectBuilder = class _SelectBuilder {
502
693
  page(page, size) {
503
694
  return this._clone({ limit: size, offset: (page - 1) * size });
504
695
  }
696
+ columns(...cols) {
697
+ const cloned = this._clone({ columns: cols });
698
+ if (cols.length === 1) {
699
+ return new ColumnRestrictedBuilder(cloned, cols[0]);
700
+ }
701
+ return cloned;
702
+ }
505
703
  /**
506
- * Restrict which columns are returned.
507
- * SELECT "id", "name" FROM "table" — instead of SELECT *
508
- *
509
- * Return type is narrowed to Pick<T, K> for full type safety.
704
+ * Build SELECT SQL + params without executing the query.
705
+ * Used internally by ColumnRestrictedBuilder.subquery().
510
706
  */
511
- columns(...cols) {
512
- return this._clone({ columns: cols });
707
+ /** Internal accessor for ColumnRestrictedBuilder / UnionBuilder — returns the adapter. */
708
+ _getAdapter() {
709
+ return this.adapter;
710
+ }
711
+ /** Internal accessor for ColumnRestrictedBuilder / UnionBuilder — returns the SQL dialect. */
712
+ _getDialect() {
713
+ return this._dialect;
714
+ }
715
+ /**
716
+ * Returns the effective WHERE conditions, injecting the soft-delete IS NULL
717
+ * filter when the table has a soft-delete column and .withDeleted() was not called.
718
+ */
719
+ _effectiveConditions() {
720
+ const col = this.table.softDeleteColumn;
721
+ if (col === null || this._includeDeleted) return this.conditions;
722
+ const softFilter = { [col]: { op: "IS NULL" } };
723
+ return mergeWhereAnd(this.conditions, softFilter);
724
+ }
725
+ _buildSelectSQL() {
726
+ const conditions = this._effectiveConditions();
727
+ if (this._rawWhere.length === 0) {
728
+ return buildSelect(
729
+ this.table.name,
730
+ conditions,
731
+ this._options,
732
+ this._dialect
733
+ );
734
+ }
735
+ const { sql: whereSql, params: whereParams } = buildWhere(
736
+ conditions,
737
+ this._dialect
738
+ );
739
+ const allWhereParts = [];
740
+ const allParams = [...whereParams];
741
+ if (whereSql) allWhereParts.push(whereSql);
742
+ for (const raw of this._rawWhere) {
743
+ allWhereParts.push(raw.sql);
744
+ allParams.push(...raw.params);
745
+ }
746
+ const combinedWhere = allWhereParts.join(" AND ");
747
+ const selectList = buildSelectListFromOptions(this._options);
748
+ const selectKeyword = this._options.distinct ? "SELECT DISTINCT" : "SELECT";
749
+ const parts = [
750
+ combinedWhere ? `${selectKeyword} ${selectList} FROM "${this.table.name}" WHERE ${combinedWhere}` : `${selectKeyword} ${selectList} FROM "${this.table.name}"`
751
+ ];
752
+ if (this._options.orderBy && this._options.orderBy.length > 0) {
753
+ const clause = this._options.orderBy.map(({ col, dir }) => `"${col}" ${dir}`).join(", ");
754
+ parts.push(`ORDER BY ${clause}`);
755
+ }
756
+ if (this._options.limit !== void 0 || this._options.offset !== void 0) {
757
+ const limitVal = this._options.limit !== void 0 ? Math.trunc(Math.max(0, this._options.limit)) : -1;
758
+ parts.push(`LIMIT ${limitVal}`);
759
+ if (this._options.offset !== void 0) {
760
+ parts.push(`OFFSET ${Math.trunc(Math.max(0, this._options.offset))}`);
761
+ }
762
+ }
763
+ return { sql: parts.join(" "), params: allParams };
513
764
  }
514
765
  /**
515
766
  * Add a GROUP BY clause. Multiple columns are comma-separated.
@@ -546,7 +797,7 @@ var SelectBuilder = class _SelectBuilder {
546
797
  }));
547
798
  const { sql, params } = buildSelect(
548
799
  this.table.name,
549
- this.conditions,
800
+ this._effectiveConditions(),
550
801
  { ...this._options, aggregates: aggClauses },
551
802
  this._dialect
552
803
  );
@@ -584,7 +835,7 @@ var SelectBuilder = class _SelectBuilder {
584
835
  const alias = "_agg";
585
836
  const colExpr = col ? `"${col}"` : "*";
586
837
  const { sql: whereSql, params } = buildWhere(
587
- this.conditions,
838
+ this._effectiveConditions(),
588
839
  this._dialect
589
840
  );
590
841
  let sqlStr;
@@ -610,10 +861,11 @@ var SelectBuilder = class _SelectBuilder {
610
861
  async select() {
611
862
  let finalSql;
612
863
  let finalParams;
864
+ const effectiveConditions = this._effectiveConditions();
613
865
  if (this._rawWhere.length === 0) {
614
866
  const { sql, params } = buildSelect(
615
867
  this.table.name,
616
- this.conditions,
868
+ effectiveConditions,
617
869
  this._options,
618
870
  this._dialect
619
871
  );
@@ -621,7 +873,7 @@ var SelectBuilder = class _SelectBuilder {
621
873
  finalParams = params;
622
874
  } else {
623
875
  const { sql: whereSql, params: whereParams } = buildWhere(
624
- this.conditions,
876
+ effectiveConditions,
625
877
  this._dialect
626
878
  );
627
879
  const allWhereParts = [];
@@ -660,16 +912,103 @@ var SelectBuilder = class _SelectBuilder {
660
912
  finalSql = parts.join(" ");
661
913
  finalParams = allParams;
662
914
  }
663
- const rows = await this.adapter.query(finalSql, finalParams);
915
+ const rawRows = await this.adapter.query(finalSql, finalParams);
916
+ let rows;
664
917
  if (this._options.columns && this._options.columns.length > 0) {
665
- return rows;
918
+ rows = rawRows;
919
+ } else {
920
+ rows = rawRows.map((row) => deserializeRow(this.table, row));
666
921
  }
667
- return rows.map((row) => deserializeRow(this.table, row));
922
+ if (this._withRelations.length === 0) return rows;
923
+ return this._executeWith(rows);
668
924
  }
669
925
  async first() {
670
926
  const rows = await this.select();
671
927
  return rows[0] ?? null;
672
928
  }
929
+ // ── Eager loading — _executeWith ────────────────────────────────────────
930
+ async _executeWith(rows) {
931
+ if (rows.length === 0) return rows;
932
+ const mutableRows = rows.map((r) => ({ ...r }));
933
+ for (const relationName of this._withRelations) {
934
+ const meta = this.table.relations[relationName];
935
+ if (!meta) continue;
936
+ if (meta.kind === "manyToMany") {
937
+ throw new Error(
938
+ `manyToMany eager loading is not yet supported. Use loadRelation manually for relation '${relationName}'.`
939
+ );
940
+ }
941
+ if (meta.kind === "belongsTo") {
942
+ await this._attachBelongsTo(mutableRows, relationName, meta);
943
+ } else if (meta.kind === "hasMany") {
944
+ await this._attachHasMany(mutableRows, relationName, meta);
945
+ }
946
+ }
947
+ return mutableRows;
948
+ }
949
+ async _attachBelongsTo(rows, relationName, meta) {
950
+ const foreignTable = meta.getTable();
951
+ const fkValues = rows.map((r) => r[meta.foreignKey]).filter((v) => v !== null && v !== void 0);
952
+ if (fkValues.length === 0) {
953
+ for (const r of rows) r[relationName] = null;
954
+ return;
955
+ }
956
+ const uniqueFkValues = [...new Set(fkValues)];
957
+ const pk = foreignTable.primaryKey;
958
+ const baseConditions = { [pk]: { op: "IN", value: uniqueFkValues } };
959
+ if (foreignTable.softDeleteColumn !== null) {
960
+ baseConditions[foreignTable.softDeleteColumn] = { op: "IS NULL" };
961
+ }
962
+ const { sql, params } = buildSelect(
963
+ foreignTable.name,
964
+ baseConditions,
965
+ {},
966
+ this._dialect
967
+ );
968
+ const related = await this.adapter.query(sql, params);
969
+ const relatedMap = /* @__PURE__ */ new Map();
970
+ for (const r of related) {
971
+ relatedMap.set(r[pk], deserializeRow(foreignTable, r));
972
+ }
973
+ for (const r of rows) {
974
+ r[relationName] = relatedMap.get(r[meta.foreignKey]) ?? null;
975
+ }
976
+ }
977
+ async _attachHasMany(rows, relationName, meta) {
978
+ const foreignTable = meta.getTable();
979
+ const pk = this.table.primaryKey;
980
+ const pkValues = rows.map((r) => r[pk]).filter((v) => v !== null && v !== void 0);
981
+ if (pkValues.length === 0) {
982
+ for (const r of rows) r[relationName] = [];
983
+ return;
984
+ }
985
+ const hasManyConditions = { [meta.foreignKey]: { op: "IN", value: pkValues } };
986
+ if (foreignTable.softDeleteColumn !== null) {
987
+ hasManyConditions[foreignTable.softDeleteColumn] = { op: "IS NULL" };
988
+ }
989
+ const { sql, params } = buildSelect(
990
+ foreignTable.name,
991
+ hasManyConditions,
992
+ {},
993
+ this._dialect
994
+ );
995
+ const related = await this.adapter.query(sql, params);
996
+ const grouped = /* @__PURE__ */ new Map();
997
+ for (const pkVal of pkValues) grouped.set(pkVal, []);
998
+ for (const r of related) {
999
+ const fkVal = r[meta.foreignKey];
1000
+ const deserialized = deserializeRow(foreignTable, r);
1001
+ const group = grouped.get(fkVal);
1002
+ if (group) {
1003
+ group.push(deserialized);
1004
+ } else {
1005
+ grouped.set(fkVal, [deserialized]);
1006
+ }
1007
+ }
1008
+ for (const r of rows) {
1009
+ r[relationName] = grouped.get(r[pk]) ?? [];
1010
+ }
1011
+ }
673
1012
  async update(patch) {
674
1013
  const hasConditions = !(Object.keys(this.conditions).length === 0 && this._rawWhere.length === 0);
675
1014
  if (!hasConditions) {
@@ -697,6 +1036,58 @@ var SelectBuilder = class _SelectBuilder {
697
1036
  await this.hooks.runAfterUpdate(this.table, this.ctx, result, current, this.queue);
698
1037
  return result;
699
1038
  }
1039
+ /**
1040
+ * Update multiple rows atomically inside a single transaction.
1041
+ * Each row must include the primary key. beforeUpdate and afterUpdate hooks
1042
+ * run per row. If any row fails, the entire transaction rolls back.
1043
+ *
1044
+ * @example
1045
+ * const updated = await db.from(usersTable).updateMany([
1046
+ * { id: 1, name: 'Alice Updated' },
1047
+ * { id: 2, role: 'admin' },
1048
+ * ])
1049
+ */
1050
+ async updateMany(rows) {
1051
+ if (rows.length === 0) return [];
1052
+ const pk = this.table.primaryKey;
1053
+ const results = await this.adapter.transaction(async (txAdapter) => {
1054
+ const txQueue = new RequestEventQueue();
1055
+ const inner = [];
1056
+ for (const row of rows) {
1057
+ const pkValue = row[pk];
1058
+ if (pkValue === void 0 || pkValue === null) {
1059
+ throw new Error(
1060
+ `updateMany: row is missing primary key "${pk}" \u2014 every row must include the PK`
1061
+ );
1062
+ }
1063
+ const selectSql = `SELECT * FROM "${this.table.name}" WHERE "${pk}" = ?`;
1064
+ const currentRows = await txAdapter.query(selectSql, [pkValue]);
1065
+ if (currentRows.length === 0) {
1066
+ throw new Error(`updateMany: record with ${pk}=${String(pkValue)} not found`);
1067
+ }
1068
+ const current = deserializeRow(this.table, currentRows[0]);
1069
+ const { [pk]: _pk, ...patchWithoutPk } = row;
1070
+ const patch = patchWithoutPk;
1071
+ const finalPatch = await this.hooks.runBeforeUpdate(this.table, this.ctx, current, patch);
1072
+ const { sql, params } = buildUpdate(
1073
+ this.table.name,
1074
+ finalPatch,
1075
+ pk,
1076
+ pkValue
1077
+ );
1078
+ await txAdapter.execute(sql, params);
1079
+ const updatedRow = {
1080
+ ...current,
1081
+ ...finalPatch
1082
+ };
1083
+ const result = deserializeRow(this.table, updatedRow);
1084
+ await this.hooks.runAfterUpdate(this.table, this.ctx, result, current, txQueue);
1085
+ inner.push(result);
1086
+ }
1087
+ return inner;
1088
+ });
1089
+ return results;
1090
+ }
700
1091
  async delete() {
701
1092
  const hasConditions = !(Object.keys(this.conditions).length === 0 && this._rawWhere.length === 0);
702
1093
  if (!hasConditions) {
@@ -714,20 +1105,235 @@ var SelectBuilder = class _SelectBuilder {
714
1105
  await this.hooks.runAfterDelete(this.table, this.ctx, current, this.queue);
715
1106
  return current;
716
1107
  }
1108
+ /**
1109
+ * Soft-delete rows by setting the soft-delete column to the current timestamp.
1110
+ * The table must have `.withSoftDelete()` configured — throws otherwise (at execute() time).
1111
+ *
1112
+ * Does NOT call beforeUpdate/afterUpdate hooks.
1113
+ * Without .where(), all rows in the table are soft-deleted.
1114
+ *
1115
+ * @example
1116
+ * await db.from(usersTable).softDelete().where({ id: 1 }).execute()
1117
+ */
1118
+ softDelete() {
1119
+ return new SoftDeleteBuilder(this.adapter, this.table, /* @__PURE__ */ new Date(), this._dialect);
1120
+ }
1121
+ /**
1122
+ * Restore soft-deleted rows by setting the soft-delete column back to null.
1123
+ * The table must have `.withSoftDelete()` configured — throws otherwise (at execute() time).
1124
+ *
1125
+ * @example
1126
+ * await db.from(usersTable).restore().where({ id: 1 }).execute()
1127
+ */
1128
+ restore() {
1129
+ return new SoftDeleteBuilder(this.adapter, this.table, null, this._dialect);
1130
+ }
1131
+ };
1132
+ var ColumnRestrictedBuilder = class _ColumnRestrictedBuilder {
1133
+ // _builder is typed as SelectBuilder<unknown, ...> to avoid variance issues
1134
+ // when constructing from SelectBuilder<Pick<T,K>, ...> — the runtime shape is identical.
1135
+ constructor(_builder, _col) {
1136
+ this._builder = _builder;
1137
+ this._col = _col;
1138
+ }
1139
+ _builder;
1140
+ _col;
1141
+ where(conditions) {
1142
+ return new _ColumnRestrictedBuilder(
1143
+ this._builder.where(conditions),
1144
+ this._col
1145
+ );
1146
+ }
1147
+ limit(n) {
1148
+ return new _ColumnRestrictedBuilder(
1149
+ this._builder.limit(n),
1150
+ this._col
1151
+ );
1152
+ }
1153
+ orderBy(col, dir = "ASC") {
1154
+ return new _ColumnRestrictedBuilder(
1155
+ // _builder is SelectBuilder<unknown,...> so keyof unknown = never;
1156
+ // col is a valid schema key at runtime — cast is safe.
1157
+ this._builder.orderBy(col, dir),
1158
+ this._col
1159
+ );
1160
+ }
1161
+ /**
1162
+ * Build the SQL for this query as a subquery fragment.
1163
+ * The result can be used directly in WHERE IN / NOT IN conditions.
1164
+ *
1165
+ * @example
1166
+ * const activeIds = db.from(usersTable).columns('id').where({ active: true }).subquery()
1167
+ * // → SubqueryResult<'id', number>
1168
+ *
1169
+ * const posts = await db.from(postsTable)
1170
+ * .where({ authorId: { op: 'IN', value: activeIds } })
1171
+ * .select()
1172
+ */
1173
+ subquery() {
1174
+ const { sql, params } = this._builder._buildSelectSQL();
1175
+ return buildSubquery(sql, params, this._col);
1176
+ }
1177
+ /** Build raw SELECT SQL + params without parentheses (for UNION). */
1178
+ _buildRawSQL() {
1179
+ return this._builder._buildSelectSQL();
1180
+ }
1181
+ /**
1182
+ * Combine this query with another same-type column query via UNION (deduplicates).
1183
+ * Both sides must produce the same column type — enforced at compile time.
1184
+ *
1185
+ * @example
1186
+ * db.from(usersTable).columns('id')
1187
+ * .union(db.from(adminsTable).columns('id'))
1188
+ * .select()
1189
+ */
1190
+ union(other) {
1191
+ return new UnionBuilder(
1192
+ [this._buildRawSQL(), other._buildRawSQL()],
1193
+ "UNION",
1194
+ this._builder._getAdapter(),
1195
+ this._builder._getDialect()
1196
+ );
1197
+ }
1198
+ /**
1199
+ * Combine via UNION ALL — keeps duplicate rows.
1200
+ */
1201
+ unionAll(other) {
1202
+ return new UnionBuilder(
1203
+ [this._buildRawSQL(), other._buildRawSQL()],
1204
+ "UNION ALL",
1205
+ this._builder._getAdapter(),
1206
+ this._builder._getDialect()
1207
+ );
1208
+ }
1209
+ };
1210
+ var SoftDeleteBuilder = class {
1211
+ constructor(adapter, table, _value, _dialect = "sqlite") {
1212
+ this.adapter = adapter;
1213
+ this.table = table;
1214
+ this._value = _value;
1215
+ this._dialect = _dialect;
1216
+ }
1217
+ adapter;
1218
+ table;
1219
+ _value;
1220
+ _dialect;
1221
+ _conditions = {};
1222
+ /**
1223
+ * Add WHERE conditions to scope which rows are soft-deleted / restored.
1224
+ * Multiple calls accumulate with AND.
1225
+ * Without .where(), all rows in the table are affected.
1226
+ */
1227
+ where(conditions) {
1228
+ this._conditions = mergeWhereAnd(this._conditions, conditions);
1229
+ return this;
1230
+ }
1231
+ /**
1232
+ * Execute the soft-delete or restore UPDATE.
1233
+ * Throws if the table has no softDeleteColumn configured.
1234
+ */
1235
+ async execute() {
1236
+ const col = this.table.softDeleteColumn;
1237
+ if (col === null) {
1238
+ throw new Error(
1239
+ `softDelete() called on table '${this.table.name}' which has no soft delete column. Add .withSoftDelete('deletedAt') to the table definition.`
1240
+ );
1241
+ }
1242
+ const { sql, params } = buildSoftDeleteUpdate(
1243
+ this.table.name,
1244
+ col,
1245
+ this._value,
1246
+ this._conditions,
1247
+ this._dialect
1248
+ );
1249
+ await this.adapter.execute(sql, params);
1250
+ }
1251
+ };
1252
+ var UnionBuilder = class _UnionBuilder {
1253
+ constructor(_parts, _kind, _adapter, _dialect) {
1254
+ this._parts = _parts;
1255
+ this._kind = _kind;
1256
+ this._adapter = _adapter;
1257
+ this._dialect = _dialect;
1258
+ }
1259
+ _parts;
1260
+ _kind;
1261
+ _adapter;
1262
+ _dialect;
1263
+ _orderBy;
1264
+ _limit;
1265
+ /** Append another UNION (deduplicating) leg. */
1266
+ union(other) {
1267
+ return new _UnionBuilder(
1268
+ [...this._parts, other._buildRawSQL()],
1269
+ "UNION",
1270
+ this._adapter,
1271
+ this._dialect
1272
+ );
1273
+ }
1274
+ /** Append another UNION ALL (keep duplicates) leg. */
1275
+ unionAll(other) {
1276
+ return new _UnionBuilder(
1277
+ [...this._parts, other._buildRawSQL()],
1278
+ "UNION ALL",
1279
+ this._adapter,
1280
+ this._dialect
1281
+ );
1282
+ }
1283
+ /** Add ORDER BY to the entire UNION result. */
1284
+ orderBy(col, dir = "ASC") {
1285
+ const next = new _UnionBuilder(this._parts, this._kind, this._adapter, this._dialect);
1286
+ next._orderBy = { col, dir };
1287
+ next._limit = this._limit;
1288
+ return next;
1289
+ }
1290
+ /** Add LIMIT to the entire UNION result. */
1291
+ limit(n) {
1292
+ const next = new _UnionBuilder(this._parts, this._kind, this._adapter, this._dialect);
1293
+ next._orderBy = this._orderBy;
1294
+ next._limit = n;
1295
+ return next;
1296
+ }
1297
+ /** Execute the UNION query and return typed rows. */
1298
+ async select() {
1299
+ const { sql, params } = buildUnion(this._parts, this._kind, {
1300
+ orderBy: this._orderBy,
1301
+ limit: this._limit
1302
+ });
1303
+ return this._adapter.query(sql, params);
1304
+ }
1305
+ /**
1306
+ * Build the UNION as a subquery — wrapped in parentheses.
1307
+ * Usable in WHERE IN / NOT IN conditions.
1308
+ *
1309
+ * @example
1310
+ * const adminOrModIds = db.from(usersTable).columns('id').where({ role: 'admin' })
1311
+ * .union(db.from(usersTable).columns('id').where({ role: 'mod' }))
1312
+ * .subquery()
1313
+ */
1314
+ subquery() {
1315
+ const { sql, params } = buildUnion(this._parts, this._kind, {
1316
+ orderBy: this._orderBy,
1317
+ limit: this._limit
1318
+ });
1319
+ return buildSubquery(sql, params, "");
1320
+ }
717
1321
  };
718
1322
  var InsertBuilder = class {
719
- constructor(adapter, hooks, ctx, queue, table) {
1323
+ constructor(adapter, hooks, ctx, queue, table, dialect = "sqlite") {
720
1324
  this.adapter = adapter;
721
1325
  this.hooks = hooks;
722
1326
  this.ctx = ctx;
723
1327
  this.queue = queue;
724
1328
  this.table = table;
1329
+ this.dialect = dialect;
725
1330
  }
726
1331
  adapter;
727
1332
  hooks;
728
1333
  ctx;
729
1334
  queue;
730
1335
  table;
1336
+ dialect;
731
1337
  async insert(data) {
732
1338
  const originalInput = { ...data };
733
1339
  let current = { ...data };
@@ -751,7 +1357,59 @@ var InsertBuilder = class {
751
1357
  await this.hooks.runAfterInsert(this.table, this.ctx, result, originalInput, this.queue);
752
1358
  return result;
753
1359
  }
754
- /** Serialize values for SQLite storage. Date → ISO string. */
1360
+ /**
1361
+ * Insert multiple rows in a single SQL statement.
1362
+ * beforeInsert and afterInsert hooks run per row.
1363
+ * Defaults (defaultFn / defaultValue) are applied per row.
1364
+ *
1365
+ * MySQL is not yet supported (no RETURNING *) — throws an informative error.
1366
+ *
1367
+ * @example
1368
+ * const users = await db.into(usersTable).insertMany([
1369
+ * { name: 'Alice', email: 'alice@example.com' },
1370
+ * { name: 'Bob', email: 'bob@example.com' },
1371
+ * ])
1372
+ */
1373
+ async insertMany(data) {
1374
+ if (data.length === 0) return [];
1375
+ if (this.dialect === "mysql") {
1376
+ throw new Error(
1377
+ "insertMany is not yet supported for MySQL \u2014 MySQL does not support RETURNING *. Use individual insert() calls inside a transaction() instead."
1378
+ );
1379
+ }
1380
+ const serializedRows = [];
1381
+ for (const row of data) {
1382
+ let processed = await this.hooks.runBeforeInsert(
1383
+ this.table,
1384
+ this.ctx,
1385
+ { ...row }
1386
+ );
1387
+ for (const [field, col] of Object.entries(this.table.schema)) {
1388
+ if (col.def.primaryKey && col.def.autoIncrement) continue;
1389
+ if (processed[field] === void 0) {
1390
+ if (col.def.defaultFn !== void 0) {
1391
+ ;
1392
+ processed[field] = col.def.defaultFn();
1393
+ } else if (col.def.defaultValue !== void 0) {
1394
+ ;
1395
+ processed[field] = col.def.defaultValue;
1396
+ }
1397
+ }
1398
+ }
1399
+ const serialized = this._serializeForInsert(processed);
1400
+ serializedRows.push(serialized);
1401
+ }
1402
+ const { sql, params } = buildInsertMany(this.table.name, serializedRows, true);
1403
+ const rawRows = await this.adapter.query(sql, params);
1404
+ const results = [];
1405
+ for (const rawRow of rawRows) {
1406
+ const deserialized = deserializeRow(this.table, rawRow);
1407
+ await this.hooks.runAfterInsert(this.table, this.ctx, deserialized, {}, this.queue);
1408
+ results.push(deserialized);
1409
+ }
1410
+ return results;
1411
+ }
1412
+ /** Serialize values for storage. Date → ISO string. Drops undefined values. */
755
1413
  _serializeForInsert(data) {
756
1414
  const result = {};
757
1415
  for (const [key, val] of Object.entries(data)) {
@@ -882,8 +1540,11 @@ var JoinBuilder = class _JoinBuilder {
882
1540
  };
883
1541
  export {
884
1542
  BoundVelnDB,
1543
+ ColumnRestrictedBuilder,
885
1544
  InsertBuilder,
886
1545
  JoinBuilder,
887
1546
  SelectBuilder,
1547
+ SoftDeleteBuilder,
1548
+ UnionBuilder,
888
1549
  VelnDB
889
1550
  };