@bunnykit/orm 0.1.20 → 0.1.22

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.
@@ -111,6 +111,9 @@ export class Connection {
111
111
  if (this.driverName !== "postgres") {
112
112
  return await this.transaction(callback);
113
113
  }
114
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(setting)) {
115
+ throw new Error(`Invalid PostgreSQL setting name: ${setting}`);
116
+ }
114
117
  return await this.transaction(async (connection) => {
115
118
  await connection.run(`SET LOCAL ${setting} = ${connection.getGrammar().placeholder(1)}`, [tenantId]);
116
119
  return await callback(connection);
@@ -28,12 +28,12 @@ export class ConnectionManager {
28
28
  }
29
29
  const now = Date.now();
30
30
  const idleTimeout = poolConfig.idleTimeout || 30000;
31
+ // Reuse a healthy idle connection
31
32
  while (pool.length > 0) {
32
33
  const idx = pool.findIndex((c) => !c.inUse && (now - c.lastUsed) < idleTimeout);
33
34
  if (idx === -1)
34
35
  break;
35
36
  const pooled = pool[idx];
36
- pool.splice(idx, 1);
37
37
  try {
38
38
  await pooled.connection.query("SELECT 1");
39
39
  pooled.inUse = true;
@@ -41,6 +41,14 @@ export class ConnectionManager {
41
41
  }
42
42
  catch {
43
43
  await pooled.connection.close().catch(() => null);
44
+ pool.splice(idx, 1);
45
+ }
46
+ }
47
+ // Clean up expired idle connections
48
+ for (let i = pool.length - 1; i >= 0; i--) {
49
+ if (!pool[i].inUse && (now - pool[i].lastUsed) >= idleTimeout) {
50
+ await pool[i].connection.close().catch(() => null);
51
+ pool.splice(i, 1);
44
52
  }
45
53
  }
46
54
  if (pool.length < (poolConfig.maxConnections || 10)) {
@@ -203,6 +211,14 @@ export class ConnectionManager {
203
211
  this.connections.clear();
204
212
  this.pools.clear();
205
213
  this.tenantCache.clear();
214
+ // Reject all pending waiters
215
+ for (const [name, poolWaiters] of this.waiters) {
216
+ for (const waiter of poolWaiters) {
217
+ clearTimeout(waiter.timer);
218
+ waiter.reject(new Error(`Connection pool "${name}" is closing`));
219
+ }
220
+ }
221
+ this.waiters.clear();
206
222
  for (const connection of connections) {
207
223
  await connection.close();
208
224
  }
@@ -23,6 +23,15 @@ export class BelongsToMany {
23
23
  this.relatedPivotKey = relatedPivotKey || `${snakeCase(related.name)}_id`;
24
24
  this.builder = related.on(parent.getConnection());
25
25
  this.addConstraints();
26
+ // Wrap getResults with lazy-loading guard
27
+ const originalGetResults = this.getResults.bind(this);
28
+ this.getResults = async () => {
29
+ if (this.parent.constructor.preventLazyLoading) {
30
+ throw new Error(`Lazy loading is prevented on ${this.parent.constructor.name}. ` +
31
+ `Eager load the relation using with().`);
32
+ }
33
+ return await originalGetResults();
34
+ };
26
35
  }
27
36
  addConstraints() {
28
37
  const relatedTable = this.related.getTable();
@@ -5,4 +5,5 @@ export declare class IdentityMap {
5
5
  static get(table: string, key: string | number): Model | undefined;
6
6
  static set(table: string, key: string | number, model: Model): void;
7
7
  static clear(): void;
8
+ static delete(table: string, key: string | number): void;
8
9
  }
@@ -25,4 +25,10 @@ export class IdentityMap {
25
25
  return;
26
26
  map.clear();
27
27
  }
28
+ static delete(table, key) {
29
+ const map = this.current();
30
+ if (!map)
31
+ return;
32
+ map.delete(`${table}:${String(key)}`);
33
+ }
28
34
  }
@@ -332,6 +332,29 @@ export class Model {
332
332
  }
333
333
  return Reflect.set(target, prop, value, receiver);
334
334
  },
335
+ has(target, prop) {
336
+ if (typeof prop === "string" && prop in target.$attributes)
337
+ return true;
338
+ return Reflect.has(target, prop);
339
+ },
340
+ getOwnPropertyDescriptor(target, prop) {
341
+ if (typeof prop === "string" && prop in target.$attributes) {
342
+ return {
343
+ enumerable: true,
344
+ configurable: true,
345
+ value: target.getAttribute(prop),
346
+ };
347
+ }
348
+ return Reflect.getOwnPropertyDescriptor(target, prop);
349
+ },
350
+ ownKeys(target) {
351
+ const keys = new Set(Reflect.ownKeys(target));
352
+ for (const key of Object.keys(target.$attributes)) {
353
+ if (!key.startsWith("$"))
354
+ keys.add(key);
355
+ }
356
+ return Array.from(keys);
357
+ },
335
358
  });
336
359
  }
337
360
  defineAttributeProperty(key) {
@@ -345,7 +368,17 @@ export class Model {
345
368
  });
346
369
  }
347
370
  syncAttributeProperties() {
348
- for (const key of Object.keys(this.$attributes)) {
371
+ const currentKeys = new Set(Object.keys(this.$attributes));
372
+ // Remove stale attribute properties
373
+ for (const key of Reflect.ownKeys(this)) {
374
+ if (key.startsWith("$") || typeof key !== "string")
375
+ continue;
376
+ const desc = Object.getOwnPropertyDescriptor(this, key);
377
+ if (desc && desc.get && desc.configurable && !currentKeys.has(key)) {
378
+ delete this[key];
379
+ }
380
+ }
381
+ for (const key of currentKeys) {
349
382
  this.defineAttributeProperty(key);
350
383
  }
351
384
  }
@@ -918,6 +951,10 @@ export class Model {
918
951
  .delete();
919
952
  this.$exists = false;
920
953
  }
954
+ const identityMap = IdentityMap.current();
955
+ if (identityMap) {
956
+ IdentityMap.delete(constructor.getTable(), pk);
957
+ }
921
958
  await ObserverRegistry.dispatch("deleted", this);
922
959
  return true;
923
960
  }
@@ -948,6 +985,10 @@ export class Model {
948
985
  .where(constructor.primaryKey, pk)
949
986
  .delete();
950
987
  this.$exists = false;
988
+ const identityMap = IdentityMap.current();
989
+ if (identityMap) {
990
+ IdentityMap.delete(constructor.getTable(), pk);
991
+ }
951
992
  return true;
952
993
  }
953
994
  async refresh() {
@@ -955,11 +996,20 @@ export class Model {
955
996
  const pk = this.getAttribute(constructor.primaryKey);
956
997
  if (!pk)
957
998
  return this;
999
+ // Bypass identity map to fetch fresh data
1000
+ const identityMap = IdentityMap.current();
1001
+ if (identityMap) {
1002
+ IdentityMap.delete(constructor.getTable(), pk);
1003
+ }
958
1004
  const result = await constructor.find(pk);
959
1005
  if (result) {
960
1006
  this.$attributes = result.$attributes;
961
1007
  this.$original = { ...result.$attributes };
962
1008
  this.syncAttributeProperties();
1009
+ // Ensure this instance is the canonical one in the identity map
1010
+ if (identityMap) {
1011
+ IdentityMap.set(constructor.getTable(), pk, this);
1012
+ }
963
1013
  }
964
1014
  return this;
965
1015
  }
@@ -13,6 +13,15 @@ export class MorphTo {
13
13
  this.typeColumn = `${name}_type`;
14
14
  this.idColumn = `${name}_id`;
15
15
  this.typeMap = typeMap;
16
+ // Wrap getResults with lazy-loading guard
17
+ const originalGetResults = this.getResults.bind(this);
18
+ this.getResults = async () => {
19
+ if (this.parent.constructor.preventLazyLoading) {
20
+ throw new Error(`Lazy loading is prevented on ${this.parent.constructor.name}. ` +
21
+ `Eager load the relation using with().`);
22
+ }
23
+ return await originalGetResults();
24
+ };
16
25
  }
17
26
  async getResults() {
18
27
  const type = this.parent.getAttribute(this.typeColumn);
@@ -99,6 +108,15 @@ export class MorphOne {
99
108
  this.builder = related.on(parent.getConnection());
100
109
  this.builder.where(this.typeColumn, this.getMorphType());
101
110
  this.builder.where(this.idColumn, this.parent.getAttribute(this.localKey));
111
+ // Wrap getResults with lazy-loading guard
112
+ const originalGetResults = this.getResults.bind(this);
113
+ this.getResults = async () => {
114
+ if (this.parent.constructor.preventLazyLoading) {
115
+ throw new Error(`Lazy loading is prevented on ${this.parent.constructor.name}. ` +
116
+ `Eager load the relation using with().`);
117
+ }
118
+ return await originalGetResults();
119
+ };
102
120
  }
103
121
  getMorphType() {
104
122
  return this.parent.constructor.morphName || this.parent.constructor.name;
@@ -168,6 +186,15 @@ export class MorphMany {
168
186
  this.builder = related.on(parent.getConnection());
169
187
  this.builder.where(this.typeColumn, this.getMorphType());
170
188
  this.builder.where(this.idColumn, this.parent.getAttribute(this.localKey));
189
+ // Wrap getResults with lazy-loading guard
190
+ const originalGetResults = this.getResults.bind(this);
191
+ this.getResults = async () => {
192
+ if (this.parent.constructor.preventLazyLoading) {
193
+ throw new Error(`Lazy loading is prevented on ${this.parent.constructor.name}. ` +
194
+ `Eager load the relation using with().`);
195
+ }
196
+ return await originalGetResults();
197
+ };
171
198
  }
172
199
  getMorphType() {
173
200
  return this.parent.constructor.morphName || this.parent.constructor.name;
@@ -244,6 +271,15 @@ export class MorphToMany {
244
271
  this.relatedPivotKey = relatedPivotKey || `${snakeCase(related.name)}_id`;
245
272
  this.builder = related.on(parent.getConnection());
246
273
  this.addConstraints();
274
+ // Wrap getResults with lazy-loading guard
275
+ const originalGetResults = this.getResults.bind(this);
276
+ this.getResults = async () => {
277
+ if (this.parent.constructor.preventLazyLoading) {
278
+ throw new Error(`Lazy loading is prevented on ${this.parent.constructor.name}. ` +
279
+ `Eager load the relation using with().`);
280
+ }
281
+ return await originalGetResults();
282
+ };
247
283
  }
248
284
  addConstraints() {
249
285
  const relatedTable = this.related.getTable();
@@ -134,7 +134,7 @@ export declare class Builder<T = Record<string, any>> {
134
134
  private addBinding;
135
135
  private compileWhereClause;
136
136
  private compileWheres;
137
- private compileNestedWheres;
137
+ private compileWhereClauses;
138
138
  private compileOrders;
139
139
  private compileGroups;
140
140
  private compileHavings;
@@ -161,6 +161,8 @@ export declare class Builder<T = Record<string, any>> {
161
161
  chunk(count: number, callback: (items: T[]) => void | Promise<void>): Promise<void>;
162
162
  each(count: number, callback: (item: T) => void | Promise<void>): Promise<void>;
163
163
  cursor(chunkSize?: number): AsyncGenerator<T>;
164
+ private getResultAccessColumn;
165
+ private compileCursorWheres;
164
166
  lazy(count?: number): AsyncGenerator<T>;
165
167
  insert(data: ModelAttributeInput<T> | ModelAttributeInput<T>[]): Promise<any>;
166
168
  insertGetId(data: ModelAttributeInput<T>, idColumn?: ModelColumn<T>): Promise<any>;
@@ -65,8 +65,7 @@ export class Builder {
65
65
  const nested = new Builder(this.connection, this.tableName);
66
66
  callback(nested);
67
67
  if (nested.wheres.length > 0) {
68
- const sql = this.compileNestedWheres(nested);
69
- this.wheres.push({ type: "raw", column: `(${sql})`, boolean, scope: undefined });
68
+ this.wheres.push({ type: "nested", column: "", query: nested.wheres, boolean, scope: undefined });
70
69
  }
71
70
  return this;
72
71
  }
@@ -182,10 +181,7 @@ export class Builder {
182
181
  return this.whereRaw(sql, "or", scope);
183
182
  }
184
183
  whereJsonContains(column, value, boolean = "and", not = false) {
185
- let sql = this.grammar.compileJsonContains(this.grammar.wrap(column), value);
186
- if (not)
187
- sql = `NOT (${sql})`;
188
- this.wheres.push({ type: "raw", column: sql, boolean, scope: undefined });
184
+ this.wheres.push({ type: "json_contains", column, value, boolean, scope: undefined, not });
189
185
  return this;
190
186
  }
191
187
  whereJsonLength(column, operator = "=", value, boolean = "and", not = false) {
@@ -193,41 +189,31 @@ export class Builder {
193
189
  value = operator;
194
190
  operator = "=";
195
191
  }
196
- let sql = this.grammar.compileJsonLength(this.grammar.wrap(column), String(operator), value);
197
- if (not)
198
- sql = `NOT (${sql})`;
199
- this.wheres.push({ type: "raw", column: sql, boolean, scope: undefined });
192
+ this.wheres.push({ type: "json_length", column, operator: String(operator), value, boolean, scope: undefined, not });
200
193
  return this;
201
194
  }
202
195
  whereLike(column, value, boolean = "and", not = false) {
203
- const sql = this.grammar.compileLike(this.grammar.wrap(column), value, not);
204
- this.wheres.push({ type: "raw", column: sql, boolean, scope: undefined });
196
+ this.wheres.push({ type: "like", column, value, boolean, scope: undefined, not });
205
197
  return this;
206
198
  }
207
199
  whereNotLike(column, value) {
208
200
  return this.whereLike(column, value, "and", true);
209
201
  }
210
202
  whereRegexp(column, value, boolean = "and", not = false) {
211
- const sql = this.grammar.compileRegexp(this.grammar.wrap(column), value, not);
212
- this.wheres.push({ type: "raw", column: sql, boolean, scope: undefined });
203
+ this.wheres.push({ type: "regexp", column, value, boolean, scope: undefined, not });
213
204
  return this;
214
205
  }
215
206
  whereFullText(columns, value, boolean = "and", not = false) {
216
207
  const cols = Array.isArray(columns) ? columns : [columns];
217
- let sql = this.grammar.compileFullText(cols.map((c) => this.grammar.wrap(c)), value);
218
- if (not)
219
- sql = `NOT (${sql})`;
220
- this.wheres.push({ type: "raw", column: sql, boolean, scope: undefined });
208
+ this.wheres.push({ type: "fulltext", column: "", columns: cols, value, boolean, scope: undefined, not });
221
209
  return this;
222
210
  }
223
211
  whereAll(columns, operator, value, boolean = "and") {
224
- const sql = columns.map((c) => `${this.grammar.wrap(c)} ${operator} ${this.grammar.escape(value)}`).join(" AND ");
225
- this.wheres.push({ type: "raw", column: `(${sql})`, boolean, scope: undefined });
212
+ this.wheres.push({ type: "all", column: "", columns: columns, operator, value, boolean, scope: undefined });
226
213
  return this;
227
214
  }
228
215
  whereAny(columns, operator, value, boolean = "and") {
229
- const sql = columns.map((c) => `${this.grammar.wrap(c)} ${operator} ${this.grammar.escape(value)}`).join(" OR ");
230
- this.wheres.push({ type: "raw", column: `(${sql})`, boolean, scope: undefined });
216
+ this.wheres.push({ type: "any", column: "", columns: columns, operator, value, boolean, scope: undefined });
231
217
  return this;
232
218
  }
233
219
  orderBy(column, direction = "asc") {
@@ -459,6 +445,7 @@ export class Builder {
459
445
  cloned.fromRaw = this.fromRaw;
460
446
  cloned.updateJoins = [...this.updateJoins];
461
447
  cloned.bindings = [...this.bindings];
448
+ cloned.parameterize = this.parameterize;
462
449
  return cloned;
463
450
  }
464
451
  wrapColumn(value) {
@@ -496,6 +483,57 @@ export class Builder {
496
483
  else if (where.type === "raw") {
497
484
  return `${prefix} ${where.column}`;
498
485
  }
486
+ else if (where.type === "nested") {
487
+ const sql = this.compileWhereClauses(where.query || [], "");
488
+ return `${prefix} (${sql})`;
489
+ }
490
+ else if (where.type === "like") {
491
+ const sql = this.grammar.compileLike(this.grammar.wrap(where.column), where.value, !!where.not, this.parameterize ? (v) => this.addBinding(v) : undefined);
492
+ return `${prefix} ${sql}`;
493
+ }
494
+ else if (where.type === "regexp") {
495
+ const sql = this.grammar.compileRegexp(this.grammar.wrap(where.column), where.value, !!where.not, this.parameterize ? (v) => this.addBinding(v) : undefined);
496
+ return `${prefix} ${sql}`;
497
+ }
498
+ else if (where.type === "fulltext") {
499
+ const cols = (where.columns || []).map((c) => this.grammar.wrap(c));
500
+ let sql = this.grammar.compileFullText(cols, where.value, this.parameterize ? (v) => this.addBinding(v) : undefined);
501
+ if (where.not)
502
+ sql = `NOT (${sql})`;
503
+ return `${prefix} ${sql}`;
504
+ }
505
+ else if (where.type === "json_contains") {
506
+ let sql = this.grammar.compileJsonContains(this.grammar.wrap(where.column), where.value, this.parameterize ? (v) => this.addBinding(v) : undefined);
507
+ if (where.not)
508
+ sql = `NOT (${sql})`;
509
+ return `${prefix} ${sql}`;
510
+ }
511
+ else if (where.type === "json_length") {
512
+ let sql = this.grammar.compileJsonLength(this.grammar.wrap(where.column), where.operator || "=", where.value, this.parameterize ? (v) => this.addBinding(v) : undefined);
513
+ if (where.not)
514
+ sql = `NOT (${sql})`;
515
+ return `${prefix} ${sql}`;
516
+ }
517
+ else if (where.type === "date") {
518
+ const sql = this.grammar.compileDateWhere(where.dateType || "date", this.grammar.wrap(where.column), where.operator || "=", where.value, this.parameterize ? (v) => this.addBinding(v) : undefined);
519
+ return `${prefix} ${sql}`;
520
+ }
521
+ else if (where.type === "all") {
522
+ const cols = (where.columns || []).map((c) => this.grammar.wrap(c));
523
+ const inner = cols.map((c) => {
524
+ const val = this.parameterize ? this.addBinding(where.value) : this.grammar.escape(where.value);
525
+ return `${c} ${where.operator} ${val}`;
526
+ }).join(" AND ");
527
+ return `${prefix} (${inner})`;
528
+ }
529
+ else if (where.type === "any") {
530
+ const cols = (where.columns || []).map((c) => this.grammar.wrap(c));
531
+ const inner = cols.map((c) => {
532
+ const val = this.parameterize ? this.addBinding(where.value) : this.grammar.escape(where.value);
533
+ return `${c} ${where.operator} ${val}`;
534
+ }).join(" OR ");
535
+ return `${prefix} (${inner})`;
536
+ }
499
537
  else if (where.type === "column") {
500
538
  return `${prefix} ${this.grammar.wrap(where.column)} ${where.operator} ${this.grammar.wrap(where.value)}`;
501
539
  }
@@ -505,20 +543,15 @@ export class Builder {
505
543
  return "";
506
544
  }
507
545
  compileWheres() {
508
- if (this.wheres.length === 0)
509
- return "";
510
- const clauses = this.wheres.map((where, index) => {
511
- const prefix = index === 0 ? "WHERE" : where.boolean.toUpperCase();
512
- return this.compileWhereClause(where, prefix);
513
- });
514
- return clauses.join(" ");
546
+ return this.compileWhereClauses(this.wheres, "WHERE");
515
547
  }
516
- compileNestedWheres(builder) {
517
- if (builder.wheres.length === 0)
548
+ compileWhereClauses(wheres, firstPrefix) {
549
+ if (wheres.length === 0)
518
550
  return "";
519
- const clauses = builder.wheres.map((where, index) => {
520
- const prefix = index === 0 ? "" : where.boolean.toUpperCase();
521
- return this.compileWhereClause(where, prefix);
551
+ const clauses = wheres.map((where, index) => {
552
+ const prefix = index === 0 ? "WHERE" : where.boolean.toUpperCase();
553
+ const adjustedPrefix = index === 0 ? firstPrefix : prefix;
554
+ return this.compileWhereClause(where, adjustedPrefix);
522
555
  });
523
556
  return clauses.join(" ").trim();
524
557
  }
@@ -740,24 +773,32 @@ export class Builder {
740
773
  async *cursor(chunkSize = 1000) {
741
774
  const model = this.model;
742
775
  const primaryKey = model ? model.primaryKey || "id" : "id";
776
+ // Cursor pagination is incompatible with random ordering
777
+ if (this.randomOrderFlag) {
778
+ throw new Error("cursor() does not support inRandomOrder(). Use lazy() instead.");
779
+ }
743
780
  const orderColumn = this.orders[0]?.column || primaryKey;
744
781
  const orderDirection = this.orders[0]?.direction || "asc";
745
- let lastValue = undefined;
782
+ let lastValues = undefined;
746
783
  while (true) {
747
784
  const builder = this.clone();
748
- builder.orders = [{ column: orderColumn, direction: orderDirection }];
785
+ // Preserve multi-column ORDER BY, appending PK tie-breaker if not present
786
+ builder.orders = this.orders.length > 0 ? [...this.orders] : [{ column: orderColumn, direction: orderDirection }];
787
+ const hasPkOrder = builder.orders.some((o) => o.column === primaryKey);
788
+ if (!hasPkOrder) {
789
+ builder.orders.push({ column: primaryKey, direction: orderDirection });
790
+ }
749
791
  builder.offsetValue = undefined;
750
792
  builder.limitValue = chunkSize;
751
- if (lastValue !== undefined) {
752
- const op = orderDirection === "asc" ? ">" : "<";
753
- builder.wheres.push({
754
- type: "basic",
755
- column: orderColumn,
756
- operator: op,
757
- value: lastValue,
758
- boolean: "and",
759
- scope: undefined,
760
- });
793
+ if (lastValues !== undefined) {
794
+ // Parenthesize existing wheres when appending cursor condition to preserve OR precedence
795
+ if (builder.wheres.length > 0) {
796
+ const hasOr = builder.wheres.some((w) => w.boolean === "or");
797
+ if (hasOr) {
798
+ builder.wheres = [{ type: "nested", column: "", query: builder.wheres, boolean: "and", scope: undefined }];
799
+ }
800
+ }
801
+ builder.wheres.push({ type: "nested", column: "", query: this.compileCursorWheres(builder.orders, lastValues), boolean: "and", scope: undefined });
761
802
  }
762
803
  const items = await builder.get();
763
804
  if (items.length === 0)
@@ -768,11 +809,46 @@ export class Builder {
768
809
  if (items.length < chunkSize)
769
810
  break;
770
811
  const lastItem = items[items.length - 1];
771
- lastValue = lastItem && typeof lastItem === "object"
772
- ? lastItem[orderColumn]
812
+ lastValues = lastItem && typeof lastItem === "object"
813
+ ? builder.orders.map((order) => lastItem[this.getResultAccessColumn(order.column)])
773
814
  : undefined;
774
815
  }
775
816
  }
817
+ getResultAccessColumn(column) {
818
+ return column.includes(".") ? column.split(".").at(-1) : column;
819
+ }
820
+ compileCursorWheres(orders, values, index = 0) {
821
+ const order = orders[index];
822
+ const op = order.direction === "asc" ? ">" : "<";
823
+ const clauses = [{
824
+ type: "basic",
825
+ column: order.column,
826
+ operator: op,
827
+ value: values[index],
828
+ boolean: "and",
829
+ scope: undefined,
830
+ }];
831
+ if (index < orders.length - 1) {
832
+ clauses.push({
833
+ type: "nested",
834
+ column: "",
835
+ query: [
836
+ {
837
+ type: "basic",
838
+ column: order.column,
839
+ operator: "=",
840
+ value: values[index],
841
+ boolean: "and",
842
+ scope: undefined,
843
+ },
844
+ ...this.compileCursorWheres(orders, values, index + 1),
845
+ ],
846
+ boolean: "or",
847
+ scope: undefined,
848
+ });
849
+ }
850
+ return clauses;
851
+ }
776
852
  async *lazy(count = 1000) {
777
853
  let page = 1;
778
854
  while (true) {
@@ -958,8 +1034,7 @@ export class Builder {
958
1034
  value = operator;
959
1035
  operator = "=";
960
1036
  }
961
- const sql = this.grammar.compileDateWhere(type, this.grammar.wrap(column), operator, value);
962
- this.wheres.push({ type: "raw", column: sql, boolean, scope: undefined });
1037
+ this.wheres.push({ type: "date", column: column, operator, value, boolean, scope: undefined, dateType: type });
963
1038
  return this;
964
1039
  }
965
1040
  getModelRelation(relationName) {
@@ -6,15 +6,15 @@ export declare abstract class Grammar {
6
6
  abstract compileRandomOrder(): string;
7
7
  compileOffset(offset: number, _limit?: number): string;
8
8
  compileLock(lockMode?: string): string;
9
- abstract compileDateWhere(type: string, column: string, operator: string, value: any): string;
9
+ abstract compileDateWhere(type: string, column: string, operator: string, value: any, binding?: (value: any) => string): string;
10
10
  abstract compileInsertOrIgnore(table: string, columns: string[], values: string[]): string;
11
11
  abstract compileUpsert(table: string, columns: string[], values: string[], uniqueBy: string[], updateColumns: string[]): string;
12
12
  compileUpdate(table: string, sets: string[], wheres: string, joins?: string[]): string;
13
13
  compileDelete(table: string, wheres: string, joins?: string[], limit?: number): string;
14
- abstract compileJsonContains(column: string, value: any): string;
15
- abstract compileJsonLength(column: string, operator: string, value: any): string;
16
- compileLike(column: string, value: string, not: boolean): string;
17
- abstract compileRegexp(column: string, value: string, not: boolean): string;
18
- abstract compileFullText(columns: string[], value: string): string;
14
+ abstract compileJsonContains(column: string, value: any, binding?: (value: any) => string): string;
15
+ abstract compileJsonLength(column: string, operator: string, value: any, binding?: (value: any) => string): string;
16
+ compileLike(column: string, value: string, not: boolean, binding?: (value: any) => string): string;
17
+ abstract compileRegexp(column: string, value: string, not: boolean, binding?: (value: any) => string): string;
18
+ abstract compileFullText(columns: string[], value: string, binding?: (value: any) => string): string;
19
19
  abstract compileExplain(sql: string): string;
20
20
  }
@@ -40,8 +40,8 @@ export class Grammar {
40
40
  sql += ` LIMIT ${limit}`;
41
41
  return sql.trim();
42
42
  }
43
- compileLike(column, value, not) {
43
+ compileLike(column, value, not, binding) {
44
44
  const op = not ? "NOT LIKE" : "LIKE";
45
- return `${column} ${op} ${this.escape(value)}`;
45
+ return `${column} ${op} ${binding ? binding(value) : this.escape(value)}`;
46
46
  }
47
47
  }
@@ -3,12 +3,12 @@ export declare class MySqlGrammar extends Grammar {
3
3
  wrap(value: string): string;
4
4
  placeholder(_index: number): string;
5
5
  compileRandomOrder(): string;
6
- compileDateWhere(type: string, column: string, operator: string, value: any): string;
6
+ compileDateWhere(type: string, column: string, operator: string, value: any, binding?: (value: any) => string): string;
7
7
  compileInsertOrIgnore(table: string, columns: string[], values: string[]): string;
8
8
  compileUpsert(table: string, columns: string[], values: string[], _uniqueBy: string[], updateColumns: string[]): string;
9
- compileJsonContains(column: string, value: any): string;
10
- compileJsonLength(column: string, operator: string, value: any): string;
11
- compileRegexp(column: string, value: string, not: boolean): string;
12
- compileFullText(columns: string[], value: string): string;
9
+ compileJsonContains(column: string, value: any, binding?: (value: any) => string): string;
10
+ compileJsonLength(column: string, operator: string, value: any, binding?: (value: any) => string): string;
11
+ compileRegexp(column: string, value: string, not: boolean, binding?: (value: any) => string): string;
12
+ compileFullText(columns: string[], value: string, binding?: (value: any) => string): string;
13
13
  compileExplain(sql: string): string;
14
14
  }
@@ -18,20 +18,21 @@ export class MySqlGrammar extends Grammar {
18
18
  compileRandomOrder() {
19
19
  return "ORDER BY RAND()";
20
20
  }
21
- compileDateWhere(type, column, operator, value) {
21
+ compileDateWhere(type, column, operator, value, binding) {
22
+ const val = binding ? binding(value) : this.escape(value);
22
23
  switch (type) {
23
24
  case "date":
24
- return `DATE(${column}) ${operator} ${this.escape(value)}`;
25
+ return `DATE(${column}) ${operator} ${val}`;
25
26
  case "day":
26
- return `DAY(${column}) ${operator} ${this.escape(value)}`;
27
+ return `DAY(${column}) ${operator} ${val}`;
27
28
  case "month":
28
- return `MONTH(${column}) ${operator} ${this.escape(value)}`;
29
+ return `MONTH(${column}) ${operator} ${val}`;
29
30
  case "year":
30
- return `YEAR(${column}) ${operator} ${this.escape(value)}`;
31
+ return `YEAR(${column}) ${operator} ${val}`;
31
32
  case "time":
32
- return `TIME(${column}) ${operator} ${this.escape(value)}`;
33
+ return `TIME(${column}) ${operator} ${val}`;
33
34
  default:
34
- return `${column} ${operator} ${this.escape(value)}`;
35
+ return `${column} ${operator} ${val}`;
35
36
  }
36
37
  }
37
38
  compileInsertOrIgnore(table, columns, values) {
@@ -43,18 +44,18 @@ export class MySqlGrammar extends Grammar {
43
44
  .join(", ");
44
45
  return `INSERT INTO ${table} (${columns.map((c) => this.wrap(c)).join(", ")}) VALUES ${values.join(", ")} ON DUPLICATE KEY UPDATE ${updateCols}`;
45
46
  }
46
- compileJsonContains(column, value) {
47
- return `JSON_CONTAINS(${column}, ${this.escape(JSON.stringify(value))})`;
47
+ compileJsonContains(column, value, binding) {
48
+ return `JSON_CONTAINS(${column}, ${binding ? binding(JSON.stringify(value)) : this.escape(JSON.stringify(value))})`;
48
49
  }
49
- compileJsonLength(column, operator, value) {
50
- return `JSON_LENGTH(${column}) ${operator} ${this.escape(value)}`;
50
+ compileJsonLength(column, operator, value, binding) {
51
+ return `JSON_LENGTH(${column}) ${operator} ${binding ? binding(value) : this.escape(value)}`;
51
52
  }
52
- compileRegexp(column, value, not) {
53
+ compileRegexp(column, value, not, binding) {
53
54
  const op = not ? "NOT REGEXP" : "REGEXP";
54
- return `${column} ${op} ${this.escape(value)}`;
55
+ return `${column} ${op} ${binding ? binding(value) : this.escape(value)}`;
55
56
  }
56
- compileFullText(columns, value) {
57
- return `MATCH (${columns.join(", ")}) AGAINST (${this.escape(value)})`;
57
+ compileFullText(columns, value, binding) {
58
+ return `MATCH (${columns.join(", ")}) AGAINST (${binding ? binding(value) : this.escape(value)})`;
58
59
  }
59
60
  compileExplain(sql) {
60
61
  return `EXPLAIN ${sql}`;
@@ -3,12 +3,12 @@ export declare class PostgresGrammar extends Grammar {
3
3
  wrap(value: string): string;
4
4
  placeholder(index: number): string;
5
5
  compileRandomOrder(): string;
6
- compileDateWhere(type: string, column: string, operator: string, value: any): string;
6
+ compileDateWhere(type: string, column: string, operator: string, value: any, binding?: (value: any) => string): string;
7
7
  compileInsertOrIgnore(table: string, columns: string[], values: string[]): string;
8
8
  compileUpsert(table: string, columns: string[], values: string[], uniqueBy: string[], updateColumns: string[]): string;
9
- compileJsonContains(column: string, value: any): string;
10
- compileJsonLength(column: string, operator: string, value: any): string;
11
- compileRegexp(column: string, value: string, not: boolean): string;
12
- compileFullText(columns: string[], value: string): string;
9
+ compileJsonContains(column: string, value: any, binding?: (value: any) => string): string;
10
+ compileJsonLength(column: string, operator: string, value: any, binding?: (value: any) => string): string;
11
+ compileRegexp(column: string, value: string, not: boolean, binding?: (value: any) => string): string;
12
+ compileFullText(columns: string[], value: string, binding?: (value: any) => string): string;
13
13
  compileExplain(sql: string): string;
14
14
  }
@@ -18,20 +18,21 @@ export class PostgresGrammar extends Grammar {
18
18
  compileRandomOrder() {
19
19
  return "ORDER BY RANDOM()";
20
20
  }
21
- compileDateWhere(type, column, operator, value) {
21
+ compileDateWhere(type, column, operator, value, binding) {
22
+ const val = binding ? binding(value) : this.escape(value);
22
23
  switch (type) {
23
24
  case "date":
24
- return `(${column})::date ${operator} ${this.escape(value)}`;
25
+ return `(${column})::date ${operator} ${val}`;
25
26
  case "day":
26
- return `EXTRACT(DAY FROM ${column}) ${operator} ${this.escape(value)}`;
27
+ return `EXTRACT(DAY FROM ${column}) ${operator} ${val}`;
27
28
  case "month":
28
- return `EXTRACT(MONTH FROM ${column}) ${operator} ${this.escape(value)}`;
29
+ return `EXTRACT(MONTH FROM ${column}) ${operator} ${val}`;
29
30
  case "year":
30
- return `EXTRACT(YEAR FROM ${column}) ${operator} ${this.escape(value)}`;
31
+ return `EXTRACT(YEAR FROM ${column}) ${operator} ${val}`;
31
32
  case "time":
32
- return `(${column})::time ${operator} ${this.escape(value)}`;
33
+ return `(${column})::time ${operator} ${val}`;
33
34
  default:
34
- return `${column} ${operator} ${this.escape(value)}`;
35
+ return `${column} ${operator} ${val}`;
35
36
  }
36
37
  }
37
38
  compileInsertOrIgnore(table, columns, values) {
@@ -43,21 +44,21 @@ export class PostgresGrammar extends Grammar {
43
44
  .join(", ");
44
45
  return `INSERT INTO ${table} (${columns.map((c) => this.wrap(c)).join(", ")}) VALUES ${values.join(", ")} ON CONFLICT (${uniqueBy.map((c) => this.wrap(c)).join(", ")}) DO UPDATE SET ${updateCols}`;
45
46
  }
46
- compileJsonContains(column, value) {
47
- return `${column} @> ${this.escape(JSON.stringify([value]))}`;
47
+ compileJsonContains(column, value, binding) {
48
+ return `${column} @> ${binding ? binding(JSON.stringify([value])) : this.escape(JSON.stringify([value]))}`;
48
49
  }
49
- compileJsonLength(column, operator, value) {
50
- return `jsonb_array_length(${column}) ${operator} ${this.escape(value)}`;
50
+ compileJsonLength(column, operator, value, binding) {
51
+ return `jsonb_array_length(${column}) ${operator} ${binding ? binding(value) : this.escape(value)}`;
51
52
  }
52
- compileRegexp(column, value, not) {
53
+ compileRegexp(column, value, not, binding) {
53
54
  const op = not ? "!~" : "~";
54
- return `${column} ${op} ${this.escape(value)}`;
55
+ return `${column} ${op} ${binding ? binding(value) : this.escape(value)}`;
55
56
  }
56
- compileFullText(columns, value) {
57
+ compileFullText(columns, value, binding) {
57
58
  const cols = columns.length > 1
58
59
  ? `concat_ws(' ', ${columns.join(", ")})`
59
60
  : columns[0];
60
- return `to_tsvector('english', ${cols}) @@ plainto_tsquery('english', ${this.escape(value)})`;
61
+ return `to_tsvector('english', ${cols}) @@ plainto_tsquery('english', ${binding ? binding(value) : this.escape(value)})`;
61
62
  }
62
63
  compileExplain(sql) {
63
64
  return `EXPLAIN (FORMAT JSON) ${sql}`;
@@ -4,12 +4,12 @@ export declare class SQLiteGrammar extends Grammar {
4
4
  placeholder(_index: number): string;
5
5
  compileRandomOrder(): string;
6
6
  compileOffset(offset: number, limit?: number): string;
7
- compileDateWhere(type: string, column: string, operator: string, value: any): string;
7
+ compileDateWhere(type: string, column: string, operator: string, value: any, binding?: (value: any) => string): string;
8
8
  compileInsertOrIgnore(table: string, columns: string[], values: string[]): string;
9
9
  compileUpsert(table: string, columns: string[], values: string[], uniqueBy: string[], updateColumns: string[]): string;
10
- compileJsonContains(column: string, value: any): string;
11
- compileJsonLength(column: string, operator: string, value: any): string;
12
- compileRegexp(column: string, value: string, not: boolean): string;
13
- compileFullText(columns: string[], value: string): string;
10
+ compileJsonContains(column: string, value: any, binding?: (value: any) => string): string;
11
+ compileJsonLength(column: string, operator: string, value: any, binding?: (value: any) => string): string;
12
+ compileRegexp(column: string, value: string, not: boolean, binding?: (value: any) => string): string;
13
+ compileFullText(columns: string[], value: string, binding?: (value: any) => string): string;
14
14
  compileExplain(sql: string): string;
15
15
  }
@@ -22,20 +22,21 @@ export class SQLiteGrammar extends Grammar {
22
22
  const limitSql = limit === undefined ? "LIMIT -1 " : "";
23
23
  return `${limitSql}OFFSET ${offset}`;
24
24
  }
25
- compileDateWhere(type, column, operator, value) {
25
+ compileDateWhere(type, column, operator, value, binding) {
26
+ const val = binding ? binding(value) : this.escape(value);
26
27
  switch (type) {
27
28
  case "date":
28
- return `date(${column}) ${operator} ${this.escape(value)}`;
29
+ return `date(${column}) ${operator} ${val}`;
29
30
  case "day":
30
- return `CAST(strftime('%d', ${column}) AS INTEGER) ${operator} ${this.escape(value)}`;
31
+ return `CAST(strftime('%d', ${column}) AS INTEGER) ${operator} ${val}`;
31
32
  case "month":
32
- return `CAST(strftime('%m', ${column}) AS INTEGER) ${operator} ${this.escape(value)}`;
33
+ return `CAST(strftime('%m', ${column}) AS INTEGER) ${operator} ${val}`;
33
34
  case "year":
34
- return `CAST(strftime('%Y', ${column}) AS INTEGER) ${operator} ${this.escape(value)}`;
35
+ return `CAST(strftime('%Y', ${column}) AS INTEGER) ${operator} ${val}`;
35
36
  case "time":
36
- return `time(${column}) ${operator} ${this.escape(value)}`;
37
+ return `time(${column}) ${operator} ${val}`;
37
38
  default:
38
- return `${column} ${operator} ${this.escape(value)}`;
39
+ return `${column} ${operator} ${val}`;
39
40
  }
40
41
  }
41
42
  compileInsertOrIgnore(table, columns, values) {
@@ -47,18 +48,18 @@ export class SQLiteGrammar extends Grammar {
47
48
  .join(", ");
48
49
  return `INSERT INTO ${table} (${columns.map((c) => this.wrap(c)).join(", ")}) VALUES ${values.join(", ")} ON CONFLICT(${uniqueBy.map((c) => this.wrap(c)).join(", ")}) DO UPDATE SET ${updateCols}`;
49
50
  }
50
- compileJsonContains(column, value) {
51
- return `${column} IN (SELECT value FROM json_each(${column})) AND ${this.escape(value)} IN (SELECT value FROM json_each(${column}))`;
51
+ compileJsonContains(column, value, binding) {
52
+ return `${column} IN (SELECT value FROM json_each(${column})) AND ${binding ? binding(value) : this.escape(value)} IN (SELECT value FROM json_each(${column}))`;
52
53
  }
53
- compileJsonLength(column, operator, value) {
54
- return `(SELECT COUNT(*) FROM json_each(${column})) ${operator} ${this.escape(value)}`;
54
+ compileJsonLength(column, operator, value, binding) {
55
+ return `(SELECT COUNT(*) FROM json_each(${column})) ${operator} ${binding ? binding(value) : this.escape(value)}`;
55
56
  }
56
- compileRegexp(column, value, not) {
57
+ compileRegexp(column, value, not, binding) {
57
58
  const op = not ? "NOT REGEXP" : "REGEXP";
58
- return `${column} ${op} ${this.escape(value)}`;
59
+ return `${column} ${op} ${binding ? binding(value) : this.escape(value)}`;
59
60
  }
60
- compileFullText(columns, value) {
61
- return columns.map((c) => `${this.wrap(c)} LIKE ${this.escape(`%${value}%`)}`).join(" OR ");
61
+ compileFullText(columns, value, binding) {
62
+ return columns.map((c) => `${this.wrap(c)} LIKE ${binding ? binding(`%${value}%`) : this.escape(`%${value}%`)}`).join(" OR ");
62
63
  }
63
64
  compileExplain(sql) {
64
65
  return `EXPLAIN QUERY PLAN ${sql}`;
@@ -135,48 +135,57 @@ export class Schema {
135
135
  const driver = connection.getDriverName();
136
136
  const schema = connection.getSchema() || "public";
137
137
  let sql;
138
+ let bindings = [];
138
139
  if (driver === "sqlite") {
139
- sql = `SELECT name FROM sqlite_master WHERE type='table' AND name='${table}'`;
140
+ sql = "SELECT name FROM sqlite_master WHERE type='table' AND name = ?";
141
+ bindings = [table];
140
142
  }
141
143
  else if (driver === "mysql") {
142
- sql = `SHOW TABLES LIKE '${table}'`;
144
+ sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?";
145
+ bindings = [table];
143
146
  }
144
147
  else {
145
- sql = `SELECT * FROM information_schema.tables WHERE table_schema = '${schema}' AND table_name = '${table}'`;
148
+ sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = $1 AND table_name = $2";
149
+ bindings = [schema, table];
146
150
  }
147
- const result = await connection.query(sql);
151
+ const result = await connection.query(sql, bindings);
148
152
  return result.length > 0;
149
153
  }
150
154
  static async hasColumn(table, column) {
151
155
  const connection = this.getConnection();
152
156
  const driver = connection.getDriverName();
153
157
  const schema = connection.getSchema() || "public";
158
+ const grammar = this.getGrammar();
154
159
  let sql;
160
+ let bindings = [];
155
161
  if (driver === "sqlite") {
156
- sql = `PRAGMA table_info(${table})`;
162
+ sql = `PRAGMA table_info(${grammar.wrap(table)})`;
157
163
  const result = await connection.query(sql);
158
164
  return result.some((row) => row.name === column);
159
165
  }
160
166
  else if (driver === "mysql") {
161
- sql = `SHOW COLUMNS FROM ${table} LIKE '${column}'`;
167
+ sql = "SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?";
168
+ bindings = [table, column];
162
169
  }
163
170
  else {
164
- sql = `SELECT column_name FROM information_schema.columns WHERE table_schema = '${schema}' AND table_name = '${table}' AND column_name = '${column}'`;
171
+ sql = "SELECT column_name FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 AND column_name = $3";
172
+ bindings = [schema, table, column];
165
173
  }
166
- const result = await connection.query(sql);
174
+ const result = await connection.query(sql, bindings);
167
175
  return result.length > 0;
168
176
  }
169
177
  static async getColumn(table, column) {
170
178
  const connection = this.getConnection();
171
179
  const driver = connection.getDriverName();
172
180
  const schema = connection.getSchema() || "public";
181
+ const grammar = this.getGrammar();
173
182
  if (driver === "sqlite") {
174
- const rows = await connection.query(`PRAGMA table_info(${table})`);
183
+ const rows = await connection.query(`PRAGMA table_info(${grammar.wrap(table)})`);
175
184
  const row = rows.find((item) => item.name === column);
176
185
  return row ? { name: row.name, type: row.type, primary: row.pk > 0, autoIncrement: false } : null;
177
186
  }
178
187
  if (driver === "mysql") {
179
- const rows = await connection.query(`SHOW COLUMNS FROM ${table} LIKE '${column}'`);
188
+ const rows = await connection.query("SELECT column_name AS Field, column_type AS Type, column_key AS `Key`, extra AS Extra FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?", [table, column]);
180
189
  const row = rows[0];
181
190
  return row ? { name: row.Field, type: row.Type, primary: row.Key === "PRI", autoIncrement: String(row.Extra || "").toLowerCase().includes("auto_increment") } : null;
182
191
  }
@@ -189,9 +198,9 @@ export class Schema {
189
198
  LEFT JOIN information_schema.table_constraints tc
190
199
  ON kcu.table_schema = tc.table_schema
191
200
  AND kcu.constraint_name = tc.constraint_name
192
- WHERE c.table_schema = '${schema}'
193
- AND c.table_name = '${table}'
194
- AND c.column_name = '${column}'`);
201
+ WHERE c.table_schema = $1
202
+ AND c.table_name = $2
203
+ AND c.column_name = $3`, [schema, table, column]);
195
204
  const row = rows[0];
196
205
  return row ? { name: row.column_name, type: row.data_type, primary: !!row.primary_key, autoIncrement: false } : null;
197
206
  }
@@ -5,7 +5,7 @@ export class Grammar {
5
5
  return value.split(".").map((v) => this.wrap(v)).join(".");
6
6
  }
7
7
  const { prefix, suffix } = this.wrappers;
8
- return `${prefix}${value}${suffix}`;
8
+ return `${prefix}${value.replaceAll(suffix, `${suffix}${suffix}`)}${suffix}`;
9
9
  }
10
10
  wrapArray(values) {
11
11
  return values.map((v) => this.wrap(v));
@@ -29,12 +29,16 @@ export interface ForeignKeyDefinition {
29
29
  onUpdate?: string;
30
30
  }
31
31
  export interface WhereClause {
32
- type: "basic" | "in" | "null" | "raw" | "between" | "column" | "exists";
32
+ type: "basic" | "in" | "null" | "raw" | "nested" | "between" | "column" | "exists" | "like" | "regexp" | "fulltext" | "json_contains" | "json_length" | "date" | "all" | "any";
33
33
  column: string;
34
+ columns?: string[];
34
35
  operator?: string;
35
36
  value?: any;
36
37
  boolean: "and" | "or";
37
38
  scope?: string;
39
+ not?: boolean;
40
+ dateType?: string;
41
+ query?: WhereClause[];
38
42
  }
39
43
  export interface OrderClause {
40
44
  column: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunnykit/orm",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "An Eloquent-inspired ORM for Bun's native SQL client supporting SQLite, MySQL, and PostgreSQL.",
5
5
  "license": "MIT",
6
6
  "packageManager": "bun@1.3.12",