@getstrata/core 1.0.5 → 1.0.7

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.
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/database/model.ts
3
- import { NotFoundError } from "@getstrata/core/errors/http";
3
+ import { ConflictError, NotFoundError } from "@getstrata/core/errors/http";
4
4
 
5
5
  // ../../src/core/database/inflection.ts
6
6
  function singularize(word) {
@@ -60,15 +60,32 @@ function pushParam(values, value) {
60
60
  values.push(value);
61
61
  return currentSqlDialect().placeholder(values.length);
62
62
  }
63
- function buildInClause(column, values, params) {
63
+ var SUPPORTED_OPERATORS = new Set([
64
+ "eq",
65
+ "ne",
66
+ "in",
67
+ "notIn",
68
+ "gt",
69
+ "gte",
70
+ "lt",
71
+ "lte",
72
+ "isNull",
73
+ "ilike",
74
+ "tsMatch"
75
+ ]);
76
+ function buildInClause(column, values, params, negated = false) {
64
77
  if (values.length === 0) {
65
- return "1 = 0";
78
+ return negated ? "1 = 1" : "1 = 0";
66
79
  }
67
80
  const placeholders = values.map((value) => pushParam(params, value)).join(", ");
68
- return `${column} IN (${placeholders})`;
81
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
69
82
  }
70
83
  function buildOperatorClauses(column, operator, params) {
71
84
  const clauses = [];
85
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
86
+ if (unsupported.length > 0) {
87
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
88
+ }
72
89
  if (operator.isNull === true) {
73
90
  clauses.push(`${column} IS NULL`);
74
91
  }
@@ -82,9 +99,19 @@ function buildOperatorClauses(column, operator, params) {
82
99
  clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
83
100
  }
84
101
  }
102
+ if (operator.ne !== undefined) {
103
+ if (operator.ne === null) {
104
+ clauses.push(`${column} IS NOT NULL`);
105
+ } else {
106
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
107
+ }
108
+ }
85
109
  if (operator.in !== undefined) {
86
110
  clauses.push(buildInClause(column, operator.in, params));
87
111
  }
112
+ if (operator.notIn !== undefined) {
113
+ clauses.push(buildInClause(column, operator.notIn, params, true));
114
+ }
88
115
  if (operator.gt !== undefined) {
89
116
  clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
90
117
  }
@@ -304,6 +331,10 @@ function buildSelectList(table, select, params = []) {
304
331
  if (item.kind === "literalText") {
305
332
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
306
333
  }
334
+ if (item.kind === "subqueryCount") {
335
+ const body = remapExistsSql(item.sql, item.params, params);
336
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
337
+ }
307
338
  const column = qualifyColumn(item.table, item.column);
308
339
  const placeholder = pushParam(params, item.query);
309
340
  return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
@@ -398,6 +429,52 @@ function buildInsertQuery(table, values) {
398
429
  params
399
430
  };
400
431
  }
432
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
433
+ const entries = getDefinedColumnEntries(table, values);
434
+ if (entries.length === 0) {
435
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
436
+ }
437
+ if (conflictColumns.length === 0) {
438
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
439
+ }
440
+ const insertable = new Set(entries.map(([column]) => column));
441
+ const conflict = new Set(conflictColumns);
442
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
443
+ const params = [];
444
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
445
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
446
+ const returningColumns = buildReturningColumns(table);
447
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
448
+ return {
449
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
450
+ params
451
+ };
452
+ }
453
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
454
+ if (!Number.isFinite(amount)) {
455
+ throw new Error("Increment amount must be a finite number.");
456
+ }
457
+ if (!table.columns.includes(column)) {
458
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
459
+ }
460
+ const params = [];
461
+ const target = quoteIdentifier(column);
462
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
463
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
464
+ exclude: [table.primaryKey, column]
465
+ })) {
466
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
467
+ }
468
+ const primaryKeyPlaceholder = pushParam(params, id);
469
+ const returningColumns = buildReturningColumns(table);
470
+ const scopeClauses = [];
471
+ appendSoftDeleteScope(table, {}, scopeClauses);
472
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
473
+ return {
474
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
475
+ params
476
+ };
477
+ }
401
478
  function buildUpdateQuery(table, id, changes) {
402
479
  const entries = getDefinedColumnEntries(table, changes, {
403
480
  exclude: [table.primaryKey]
@@ -458,6 +535,30 @@ function buildDeleteByIdQuery(table, id) {
458
535
  };
459
536
  }
460
537
 
538
+ // ../../src/core/database/pluck.ts
539
+ function uniqueColumnSelect(table, columns) {
540
+ const seen = new Set;
541
+ const select = [];
542
+ for (const column of columns) {
543
+ if (seen.has(column)) {
544
+ continue;
545
+ }
546
+ seen.add(column);
547
+ select.push({ kind: "column", table, column, as: column });
548
+ }
549
+ return select;
550
+ }
551
+ function projectPluck(rows, column, keyBy) {
552
+ if (keyBy === undefined) {
553
+ return rows.map((row) => row[column]);
554
+ }
555
+ const keyed = new Map;
556
+ for (const row of rows) {
557
+ keyed.set(row[keyBy], row[column]);
558
+ }
559
+ return keyed;
560
+ }
561
+
461
562
  // ../../src/core/database/relationQuery.ts
462
563
  function asWhere(where) {
463
564
  return where;
@@ -474,6 +575,12 @@ function ownerId(owner, ownerKey) {
474
575
  function thenGet(get, onfulfilled, onrejected) {
475
576
  return get().then(onfulfilled ?? undefined, onrejected ?? undefined);
476
577
  }
578
+ function pluckFromQuery(query, column, keyBy) {
579
+ if (!query) {
580
+ return Promise.resolve(keyBy === undefined ? [] : new Map);
581
+ }
582
+ return keyBy === undefined ? query.pluck(column) : query.pluck(column, keyBy);
583
+ }
477
584
 
478
585
  class HasManyRelationQuery {
479
586
  parent;
@@ -539,6 +646,12 @@ class HasManyRelationQuery {
539
646
  async count() {
540
647
  return this.scopedQuery().count();
541
648
  }
649
+ async pluck(column, keyBy) {
650
+ return pluckFromQuery(this.scopedQuery(), column, keyBy);
651
+ }
652
+ async value(column) {
653
+ return this.scopedQuery().value(column);
654
+ }
542
655
  then(onfulfilled, onrejected) {
543
656
  return thenGet(() => this.get(), onfulfilled, onrejected);
544
657
  }
@@ -604,6 +717,13 @@ class HasOneRelationQuery {
604
717
  async count() {
605
718
  return this.inner.count();
606
719
  }
720
+ async pluck(column, keyBy) {
721
+ const query = this.inner.limit(1);
722
+ return keyBy === undefined ? query.pluck(column) : query.pluck(column, keyBy);
723
+ }
724
+ async value(column) {
725
+ return this.inner.limit(1).value(column);
726
+ }
607
727
  then(onfulfilled, onrejected) {
608
728
  return thenGet(() => this.get(), onfulfilled, onrejected);
609
729
  }
@@ -650,6 +770,23 @@ class BelongsToRelationQuery {
650
770
  return { sql, params: extra.params };
651
771
  }
652
772
  async get() {
773
+ const query = this.relatedQuery();
774
+ if (!query) {
775
+ return null;
776
+ }
777
+ const row = await query.first();
778
+ return row ? this.related.newFromRecord(row) : null;
779
+ }
780
+ async first() {
781
+ return this.get();
782
+ }
783
+ async pluck(column, keyBy) {
784
+ return pluckFromQuery(this.relatedQuery(), column, keyBy);
785
+ }
786
+ async value(column) {
787
+ return this.relatedQuery()?.value(column) ?? null;
788
+ }
789
+ relatedQuery() {
653
790
  const foreign = this.parent.get(this.relation.foreignKey);
654
791
  if (foreign === null || foreign === undefined) {
655
792
  return null;
@@ -659,11 +796,7 @@ class BelongsToRelationQuery {
659
796
  if (this.extraOptions.orderBy) {
660
797
  query = query.orderBy(this.extraOptions.orderBy);
661
798
  }
662
- const row = await query.first();
663
- return row ? this.related.newFromRecord(row) : null;
664
- }
665
- async first() {
666
- return this.get();
799
+ return query;
667
800
  }
668
801
  then(onfulfilled, onrejected) {
669
802
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -751,6 +884,31 @@ class BelongsToManyRelationQuery {
751
884
  const rows = await this.connection().unsafe(`SELECT COUNT(*) AS count FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
752
885
  return Number(rows[0]?.count ?? 0);
753
886
  }
887
+ async pluck(column, keyBy) {
888
+ return pluckFromQuery(await this.relatedQuery(), column, keyBy);
889
+ }
890
+ async value(column) {
891
+ const query = await this.relatedQuery();
892
+ return query ? query.value(column) : null;
893
+ }
894
+ async relatedQuery() {
895
+ const parentId = this.parent.get(this.relation.parentKey);
896
+ const pivotRows = await this.connection().unsafe(`SELECT * FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
897
+ if (pivotRows.length === 0) {
898
+ return null;
899
+ }
900
+ const relatedIds = [
901
+ ...new Set(pivotRows.map((row) => row[this.relation.relatedPivotKey]))
902
+ ];
903
+ let query = this.related.repository().withConnection(this.connection()).query(asWhere({
904
+ [this.relation.relatedKey]: relatedIds,
905
+ ...this.extraWhere
906
+ }));
907
+ if (this.extraOptions.orderBy) {
908
+ query = query.orderBy(this.extraOptions.orderBy);
909
+ }
910
+ return query;
911
+ }
754
912
  then(onfulfilled, onrejected) {
755
913
  return thenGet(() => this.get(), onfulfilled, onrejected);
756
914
  }
@@ -834,13 +992,15 @@ class MorphManyRelationQuery {
834
992
  const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)} WHERE ${qualifyColumn(childTable, this.relation.morphIdKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
835
993
  return { sql, params: extra.params };
836
994
  }
837
- async get() {
838
- const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
839
- const rows = await repository.query(asWhere({
995
+ scopedQuery() {
996
+ return this.related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({
840
997
  [this.relation.morphTypeKey]: this.relation.morphType,
841
998
  [this.relation.morphIdKey]: this.parent.get(this.relation.localKey),
842
999
  ...this.extraWhere
843
- })).get();
1000
+ }));
1001
+ }
1002
+ async get() {
1003
+ const rows = await this.scopedQuery().get();
844
1004
  return rows.map((row) => this.related.newFromRecord(row));
845
1005
  }
846
1006
  async first() {
@@ -848,12 +1008,13 @@ class MorphManyRelationQuery {
848
1008
  return rows[0] ?? null;
849
1009
  }
850
1010
  async count() {
851
- const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
852
- return repository.query(asWhere({
853
- [this.relation.morphTypeKey]: this.relation.morphType,
854
- [this.relation.morphIdKey]: this.parent.get(this.relation.localKey),
855
- ...this.extraWhere
856
- })).count();
1011
+ return this.scopedQuery().count();
1012
+ }
1013
+ async pluck(column, keyBy) {
1014
+ return pluckFromQuery(this.scopedQuery(), column, keyBy);
1015
+ }
1016
+ async value(column) {
1017
+ return this.scopedQuery().value(column);
857
1018
  }
858
1019
  then(onfulfilled, onrejected) {
859
1020
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -904,6 +1065,12 @@ class MorphOneRelationQuery {
904
1065
  async count() {
905
1066
  return this.inner.count();
906
1067
  }
1068
+ async pluck(column, keyBy) {
1069
+ return keyBy === undefined ? this.inner.pluck(column) : this.inner.pluck(column, keyBy);
1070
+ }
1071
+ async value(column) {
1072
+ return this.inner.value(column);
1073
+ }
907
1074
  then(onfulfilled, onrejected) {
908
1075
  return thenGet(() => this.get(), onfulfilled, onrejected);
909
1076
  }
@@ -952,15 +1119,31 @@ class MorphToRelationQuery {
952
1119
  };
953
1120
  }
954
1121
  async get() {
1122
+ const query = this.relatedQuery();
1123
+ if (!query) {
1124
+ return null;
1125
+ }
1126
+ const row = await query.first();
1127
+ return row ? this.relatedForCurrentType()?.newFromRecord(row) ?? null : null;
1128
+ }
1129
+ async pluck(column, keyBy) {
1130
+ return pluckFromQuery(this.relatedQuery(), column, keyBy);
1131
+ }
1132
+ async value(column) {
1133
+ return this.relatedQuery()?.value(column) ?? null;
1134
+ }
1135
+ relatedForCurrentType() {
955
1136
  const type = String(this.parent.get(this.relation.morphTypeKey) ?? "");
1137
+ return this.relatedByType[type];
1138
+ }
1139
+ relatedQuery() {
956
1140
  const id = this.parent.get(this.relation.morphIdKey);
957
- const related = this.relatedByType[type];
1141
+ const related = this.relatedForCurrentType();
958
1142
  if (!related || id === null || id === undefined) {
959
1143
  return null;
960
1144
  }
961
1145
  const table = related.repository().getTable();
962
- const row = await related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({ [table.primaryKey]: id, ...this.extraWhere })).first();
963
- return row ? related.newFromRecord(row) : null;
1146
+ return related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({ [table.primaryKey]: id, ...this.extraWhere }));
964
1147
  }
965
1148
  then(onfulfilled, onrejected) {
966
1149
  return thenGet(() => this.get(), onfulfilled, onrejected);
@@ -1007,10 +1190,7 @@ class HasManyThroughRelationQuery {
1007
1190
  return { sql, params: extra.params };
1008
1191
  }
1009
1192
  async get() {
1010
- const rows = await this.related.repository().withConnection(this.parent.getRepository().getConnection()).findHasManyThrough(this.parent.get(this.relation.localKey), this.relation, {
1011
- ...this.extraOptions,
1012
- where: this.extraWhere
1013
- });
1193
+ const rows = await this.farRows();
1014
1194
  return rows.map((row) => this.related.newFromRecord(row));
1015
1195
  }
1016
1196
  async first() {
@@ -1021,6 +1201,20 @@ class HasManyThroughRelationQuery {
1021
1201
  const rows = await this.get();
1022
1202
  return rows.length;
1023
1203
  }
1204
+ async pluck(column, keyBy) {
1205
+ const rows = await this.farRows();
1206
+ return keyBy === undefined ? projectPluck(rows, column) : projectPluck(rows, column, keyBy);
1207
+ }
1208
+ async value(column) {
1209
+ const values = await this.limit(1).pluck(column);
1210
+ return values[0] ?? null;
1211
+ }
1212
+ async farRows() {
1213
+ return this.related.repository().withConnection(this.parent.getRepository().getConnection()).findHasManyThrough(this.parent.get(this.relation.localKey), this.relation, {
1214
+ ...this.extraOptions,
1215
+ where: this.extraWhere
1216
+ });
1217
+ }
1024
1218
  then(onfulfilled, onrejected) {
1025
1219
  return thenGet(() => this.get(), onfulfilled, onrejected);
1026
1220
  }
@@ -1407,6 +1601,17 @@ function ensureBooted(model) {
1407
1601
  function getGlobalScopes(model) {
1408
1602
  return modelGlobalScopes.get(model) ?? [];
1409
1603
  }
1604
+ var PASSWORD_HASH_PATTERN = /^\$(?:2[aby]?|argon2(?:i|d|id)?)\$/;
1605
+ function isAlreadyHashed(value) {
1606
+ return PASSWORD_HASH_PATTERN.test(value);
1607
+ }
1608
+ function hashCastValue(value) {
1609
+ const plain = String(value);
1610
+ if (isAlreadyHashed(plain)) {
1611
+ return plain;
1612
+ }
1613
+ return Bun.password.hashSync(plain, { algorithm: "bcrypt", cost: 10 });
1614
+ }
1410
1615
  function hydrateValue(value, cast) {
1411
1616
  if (value === null || value === undefined) {
1412
1617
  return value;
@@ -1446,12 +1651,15 @@ function dehydrateValue(value, cast) {
1446
1651
  case "int":
1447
1652
  return value === "" ? null : Number(value);
1448
1653
  case "hashed":
1449
- return value;
1654
+ return hashCastValue(value);
1450
1655
  default:
1451
1656
  return value;
1452
1657
  }
1453
1658
  }
1454
1659
  function filterMassAssignable(fillable, guarded, input) {
1660
+ if (fillable === undefined && guarded === undefined && Object.keys(input).length > 0) {
1661
+ throw new Error("Mass assignment is not configured for this model. Declare static $fillable = [...] to allow specific columns, or static $guarded = [] to allow all of them.");
1662
+ }
1455
1663
  const resolvedGuarded = guarded ?? true;
1456
1664
  if (fillable && fillable.length > 0) {
1457
1665
  const allowed = new Set(fillable);
@@ -1476,6 +1684,10 @@ function applyCasts(values, casts, direction) {
1476
1684
  }
1477
1685
  return result;
1478
1686
  }
1687
+ function castPluckedValue(modelClass, column, value) {
1688
+ const cast = modelStatics(modelClass).$casts?.[column];
1689
+ return cast ? hydrateValue(value, cast) : value;
1690
+ }
1479
1691
  function applyTimestampsOnCreate(columns, values, enabled) {
1480
1692
  if (!enabled) {
1481
1693
  return values;
@@ -1549,10 +1761,38 @@ class ModelQuery {
1549
1761
  this.query.whereNull(column);
1550
1762
  return this;
1551
1763
  }
1764
+ whereNotNull(column) {
1765
+ this.query.whereNotNull(column);
1766
+ return this;
1767
+ }
1552
1768
  whereIn(column, values) {
1553
1769
  this.query.whereIn(column, values);
1554
1770
  return this;
1555
1771
  }
1772
+ whereNotIn(column, values) {
1773
+ this.query.whereNotIn(column, values);
1774
+ return this;
1775
+ }
1776
+ groupBy(groupBy) {
1777
+ this.query.groupBy(groupBy);
1778
+ return this;
1779
+ }
1780
+ having(having) {
1781
+ this.query.having(having);
1782
+ return this;
1783
+ }
1784
+ join(left, right) {
1785
+ this.query.join(left, right);
1786
+ return this;
1787
+ }
1788
+ leftJoin(left, right) {
1789
+ this.query.leftJoin(left, right);
1790
+ return this;
1791
+ }
1792
+ async paginate(options) {
1793
+ const { data, meta } = await this.query.paginate(options);
1794
+ return { data: await this.hydrateRows(data), meta };
1795
+ }
1556
1796
  whereExists(sql, params = []) {
1557
1797
  this.query.whereExists(sql, params);
1558
1798
  return this;
@@ -1610,8 +1850,10 @@ class ModelQuery {
1610
1850
  return this;
1611
1851
  }
1612
1852
  async get() {
1853
+ return await this.hydrateRows(await this.query.get());
1854
+ }
1855
+ async hydrateRows(rows) {
1613
1856
  const statics = modelStatics(this.modelClass);
1614
- const rows = await this.query.get();
1615
1857
  const models = [];
1616
1858
  for (const row of rows) {
1617
1859
  const model = statics.newFromRecord(row, true);
@@ -1630,6 +1872,25 @@ class ModelQuery {
1630
1872
  const models = await this.get();
1631
1873
  return models[0] ?? null;
1632
1874
  }
1875
+ async count() {
1876
+ return this.query.count();
1877
+ }
1878
+ async pluck(column, keyBy) {
1879
+ if (keyBy === undefined) {
1880
+ const values = await this.query.pluck(column);
1881
+ return values.map((value) => castPluckedValue(this.modelClass, column, value));
1882
+ }
1883
+ const keyed = await this.query.pluck(column, keyBy);
1884
+ const result = new Map;
1885
+ for (const [key, value] of keyed) {
1886
+ result.set(castPluckedValue(this.modelClass, keyBy, key), castPluckedValue(this.modelClass, column, value));
1887
+ }
1888
+ return result;
1889
+ }
1890
+ async value(column) {
1891
+ const value = await this.query.value(column);
1892
+ return castPluckedValue(this.modelClass, column, value);
1893
+ }
1633
1894
  async find(id) {
1634
1895
  const primaryKey = resolveModelRepository(this.modelClass).getTable().primaryKey;
1635
1896
  return this.where({ [primaryKey]: id }).first();
@@ -1644,6 +1905,23 @@ class ModelQuery {
1644
1905
  then(onfulfilled, onrejected) {
1645
1906
  return this.get().then(onfulfilled ?? undefined, onrejected ?? undefined);
1646
1907
  }
1908
+ withCount(name, alias = `${name}_count`) {
1909
+ const statics = modelStatics(this.modelClass);
1910
+ ensureBooted(this.modelClass);
1911
+ const repository = resolveModelRepository(this.modelClass);
1912
+ const dummy = statics.newFromRecord({});
1913
+ const method = dummy[name];
1914
+ if (typeof method !== "function") {
1915
+ throw new Error(`${this.modelClass.name} has no relation method ${name}().`);
1916
+ }
1917
+ const relationQuery = method.call(dummy);
1918
+ const exists = relationQuery.toExistsClause(repository.getTable().name);
1919
+ if (!exists.sql.startsWith("SELECT 1 ")) {
1920
+ throw new Error(`Cannot count relation ${name}: unexpected subquery shape.`);
1921
+ }
1922
+ this.query.withSubqueryCount(alias, `SELECT COUNT(*) ${exists.sql.slice("SELECT 1 ".length)}`, exists.params);
1923
+ return this;
1924
+ }
1647
1925
  constrainExists(name, constrain, not) {
1648
1926
  const statics = modelStatics(this.modelClass);
1649
1927
  ensureBooted(this.modelClass);
@@ -1877,6 +2155,16 @@ class Model {
1877
2155
  static where(where) {
1878
2156
  return Model.query.call(this).where(where);
1879
2157
  }
2158
+ static async count() {
2159
+ return Model.query.call(this).count();
2160
+ }
2161
+ static pluck(column, keyBy) {
2162
+ const query = Model.query.call(this);
2163
+ return keyBy === undefined ? query.pluck(column) : query.pluck(column, keyBy);
2164
+ }
2165
+ static value(column) {
2166
+ return Model.query.call(this).value(column);
2167
+ }
1880
2168
  static async firstWhere(where, options = {}) {
1881
2169
  let query = Model.query.call(this).where(where);
1882
2170
  if (options.orderBy) {
@@ -1892,11 +2180,23 @@ class Model {
1892
2180
  return modelStatics(this).newFromRecord({ ...where, ...values }, false);
1893
2181
  }
1894
2182
  static async firstOrCreate(where, values = {}) {
1895
- const existing = await Model.firstWhere.call(this, where);
2183
+ const findExisting = () => Model.firstWhere.call(this, where);
2184
+ const existing = await findExisting();
1896
2185
  if (existing) {
1897
2186
  return existing;
1898
2187
  }
1899
- return Model.create.call(this, { ...where, ...values });
2188
+ try {
2189
+ return await Model.create.call(this, { ...where, ...values });
2190
+ } catch (error) {
2191
+ if (!(error instanceof ConflictError)) {
2192
+ throw error;
2193
+ }
2194
+ const raced = await findExisting();
2195
+ if (!raced) {
2196
+ throw error;
2197
+ }
2198
+ return raced;
2199
+ }
1900
2200
  }
1901
2201
  static async updateOrCreate(where, values = {}) {
1902
2202
  const existing = await Model.firstWhere.call(this, where);
@@ -37,15 +37,32 @@ function pushParam(values, value) {
37
37
  values.push(value);
38
38
  return currentSqlDialect().placeholder(values.length);
39
39
  }
40
- function buildInClause(column, values, params) {
40
+ var SUPPORTED_OPERATORS = new Set([
41
+ "eq",
42
+ "ne",
43
+ "in",
44
+ "notIn",
45
+ "gt",
46
+ "gte",
47
+ "lt",
48
+ "lte",
49
+ "isNull",
50
+ "ilike",
51
+ "tsMatch"
52
+ ]);
53
+ function buildInClause(column, values, params, negated = false) {
41
54
  if (values.length === 0) {
42
- return "1 = 0";
55
+ return negated ? "1 = 1" : "1 = 0";
43
56
  }
44
57
  const placeholders = values.map((value) => pushParam(params, value)).join(", ");
45
- return `${column} IN (${placeholders})`;
58
+ return `${column}${negated ? " NOT" : ""} IN (${placeholders})`;
46
59
  }
47
60
  function buildOperatorClauses(column, operator, params) {
48
61
  const clauses = [];
62
+ const unsupported = Object.keys(operator).filter((key) => !SUPPORTED_OPERATORS.has(key));
63
+ if (unsupported.length > 0) {
64
+ throw new Error(`Unsupported query operator(s) for ${column}: ${unsupported.join(", ")}. Supported: ${[...SUPPORTED_OPERATORS].join(", ")}.`);
65
+ }
49
66
  if (operator.isNull === true) {
50
67
  clauses.push(`${column} IS NULL`);
51
68
  }
@@ -59,9 +76,19 @@ function buildOperatorClauses(column, operator, params) {
59
76
  clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
60
77
  }
61
78
  }
79
+ if (operator.ne !== undefined) {
80
+ if (operator.ne === null) {
81
+ clauses.push(`${column} IS NOT NULL`);
82
+ } else {
83
+ clauses.push(`${column} <> ${pushParam(params, operator.ne)}`);
84
+ }
85
+ }
62
86
  if (operator.in !== undefined) {
63
87
  clauses.push(buildInClause(column, operator.in, params));
64
88
  }
89
+ if (operator.notIn !== undefined) {
90
+ clauses.push(buildInClause(column, operator.notIn, params, true));
91
+ }
65
92
  if (operator.gt !== undefined) {
66
93
  clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
67
94
  }
@@ -281,6 +308,10 @@ function buildSelectList(table, select, params = []) {
281
308
  if (item.kind === "literalText") {
282
309
  return `${currentSqlDialect().castToText(pushParam(params, item.value))} AS ${quoteIdentifier(item.as)}`;
283
310
  }
311
+ if (item.kind === "subqueryCount") {
312
+ const body = remapExistsSql(item.sql, item.params, params);
313
+ return `(${body}) AS ${quoteIdentifier(item.as)}`;
314
+ }
284
315
  const column = qualifyColumn(item.table, item.column);
285
316
  const placeholder = pushParam(params, item.query);
286
317
  return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
@@ -375,6 +406,52 @@ function buildInsertQuery(table, values) {
375
406
  params
376
407
  };
377
408
  }
409
+ function buildUpsertQuery(table, values, conflictColumns, updateColumns) {
410
+ const entries = getDefinedColumnEntries(table, values);
411
+ if (entries.length === 0) {
412
+ throw new Error(`Cannot upsert into ${table.name} without any column values.`);
413
+ }
414
+ if (conflictColumns.length === 0) {
415
+ throw new Error(`Cannot upsert into ${table.name} without any conflict columns.`);
416
+ }
417
+ const insertable = new Set(entries.map(([column]) => column));
418
+ const conflict = new Set(conflictColumns);
419
+ const updatable = (updateColumns ?? entries.map(([column]) => column).filter((c) => !conflict.has(c))).filter((column) => insertable.has(column));
420
+ const params = [];
421
+ const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
422
+ const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
423
+ const returningColumns = buildReturningColumns(table);
424
+ const suffix = currentSqlDialect().upsertSuffix(conflictColumns, updatable);
425
+ return {
426
+ text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders})${suffix}${returningSuffix(returningColumns)}`,
427
+ params
428
+ };
429
+ }
430
+ function buildIncrementQuery(table, id, column, amount, extra = {}) {
431
+ if (!Number.isFinite(amount)) {
432
+ throw new Error("Increment amount must be a finite number.");
433
+ }
434
+ if (!table.columns.includes(column)) {
435
+ throw new Error(`Unknown column ${String(column)} on ${table.name}.`);
436
+ }
437
+ const params = [];
438
+ const target = quoteIdentifier(column);
439
+ const assignments = [`${target} = ${target} + ${pushParam(params, amount)}`];
440
+ for (const [name, value] of getDefinedColumnEntries(table, extra, {
441
+ exclude: [table.primaryKey, column]
442
+ })) {
443
+ assignments.push(`${quoteIdentifier(name)} = ${pushParam(params, value)}`);
444
+ }
445
+ const primaryKeyPlaceholder = pushParam(params, id);
446
+ const returningColumns = buildReturningColumns(table);
447
+ const scopeClauses = [];
448
+ appendSoftDeleteScope(table, {}, scopeClauses);
449
+ const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
450
+ return {
451
+ text: `UPDATE ${quoteIdentifier(table.name)} SET ${assignments.join(", ")} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix}${returningSuffix(returningColumns)}`,
452
+ params
453
+ };
454
+ }
378
455
  function buildUpdateQuery(table, id, changes) {
379
456
  const entries = getDefinedColumnEntries(table, changes, {
380
457
  exclude: [table.primaryKey]
@@ -440,6 +517,7 @@ export {
440
517
  buildCountQuery,
441
518
  buildDeleteByIdQuery,
442
519
  buildGroupedCountQuery,
520
+ buildIncrementQuery,
443
521
  buildInsertQuery,
444
522
  buildJoinClause,
445
523
  buildOrderByClause,
@@ -449,6 +527,7 @@ export {
449
527
  buildSelectQuery,
450
528
  buildSoftDeleteByIdQuery,
451
529
  buildUpdateQuery,
530
+ buildUpsertQuery,
452
531
  buildWhereClause,
453
532
  parseQualifiedColumn,
454
533
  qualifyColumn,