uql-orm 0.69.0 → 0.71.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.
Files changed (96) hide show
  1. package/README.md +2 -0
  2. package/dist/browser/querier/httpQuerier.d.ts +7 -7
  3. package/dist/browser/type/clientQuerier.d.ts +5 -5
  4. package/dist/browser/uql-browser.min.js +2 -2
  5. package/dist/browser/uql-browser.min.js.map +4 -4
  6. package/dist/cockroachdb/cockroachDialect.d.ts +6 -0
  7. package/dist/cockroachdb/cockroachDialect.js +10 -2
  8. package/dist/d1/d1SqliteDialect.d.ts +3 -0
  9. package/dist/d1/d1SqliteDialect.js +3 -1
  10. package/dist/dialect/abstractDialect.d.ts +8 -2
  11. package/dist/dialect/abstractDialect.js +17 -1
  12. package/dist/dialect/abstractSqlDialect.d.ts +21 -5
  13. package/dist/dialect/abstractSqlDialect.js +91 -46
  14. package/dist/dialect/aliases.d.ts +10 -7
  15. package/dist/dialect/aliases.js +10 -7
  16. package/dist/dialect/hydrateColumn.d.ts +3 -2
  17. package/dist/dialect/hydrateColumn.js +10 -1
  18. package/dist/dialect/mysqlLikeSqlDialect.js +5 -3
  19. package/dist/dialect/pgLikeSqlDialect.d.ts +17 -5
  20. package/dist/dialect/pgLikeSqlDialect.js +34 -15
  21. package/dist/dialect/queryJoins.d.ts +19 -1
  22. package/dist/dialect/queryJoins.js +54 -11
  23. package/dist/dialect/vectorCast.d.ts +2 -0
  24. package/dist/dialect/vectorCast.js +7 -0
  25. package/dist/dialect/vectorSqlDialect.d.ts +7 -3
  26. package/dist/dialect/vectorSqlDialect.js +15 -10
  27. package/dist/entity/decorator/members.d.ts +25 -11
  28. package/dist/entity/metadata/definition.d.ts +8 -3
  29. package/dist/libsql/libsqlDialect.d.ts +1 -1
  30. package/dist/libsql/libsqlDialect.js +3 -3
  31. package/dist/maria/mariaDialect.d.ts +3 -9
  32. package/dist/maria/mariaDialect.js +4 -13
  33. package/dist/maria/mariadbQuerier.js +9 -3
  34. package/dist/migrate/codegen/entityCodeGenerator.js +4 -2
  35. package/dist/migrate/ddl/index.d.ts +1 -0
  36. package/dist/migrate/ddl/index.js +4 -2
  37. package/dist/migrate/ddl/indexDdl.d.ts +2 -0
  38. package/dist/migrate/ddl/indexDdl.js +10 -2
  39. package/dist/migrate/ddl/pgIndexDdl.d.ts +3 -4
  40. package/dist/migrate/ddl/pgIndexDdl.js +11 -11
  41. package/dist/migrate/ddl/sqliteIndexDdl.d.ts +11 -0
  42. package/dist/migrate/ddl/sqliteIndexDdl.js +38 -0
  43. package/dist/migrate/generator/mongoCommand.d.ts +28 -0
  44. package/dist/migrate/generator/mongoCommand.js +8 -0
  45. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +2 -0
  46. package/dist/migrate/generator/mongoSchemaGenerator.js +50 -6
  47. package/dist/migrate/introspection/mongoIntrospector.js +33 -7
  48. package/dist/migrate/introspection/postgresIntrospector.js +19 -0
  49. package/dist/migrate/introspection/sqliteIntrospector.d.ts +6 -0
  50. package/dist/migrate/introspection/sqliteIntrospector.js +29 -3
  51. package/dist/migrate/migrator.d.ts +2 -1
  52. package/dist/migrate/migrator.js +5 -3
  53. package/dist/mongo/mongoDialect.d.ts +33 -27
  54. package/dist/mongo/mongoDialect.js +178 -114
  55. package/dist/mongo/mongodbQuerier.d.ts +0 -2
  56. package/dist/mongo/mongodbQuerier.js +14 -13
  57. package/dist/mssql/mssqlDialect.js +4 -2
  58. package/dist/postgres/postgresDialect.js +2 -2
  59. package/dist/querier/abstractQuerier.d.ts +16 -11
  60. package/dist/querier/abstractQuerier.js +28 -8
  61. package/dist/querier/abstractQuerierPool.d.ts +9 -9
  62. package/dist/querier/abstractSqlQuerier.d.ts +1 -1
  63. package/dist/querier/abstractSqlQuerier.js +3 -3
  64. package/dist/schema/canonicalType.js +10 -4
  65. package/dist/schema/indexDifferences.d.ts +5 -2
  66. package/dist/schema/indexDifferences.js +5 -0
  67. package/dist/schema/schemaASTBuilder.js +3 -2
  68. package/dist/sqlite/localSqliteQuerierPool.d.ts +10 -11
  69. package/dist/sqlite/localSqliteQuerierPool.js +8 -11
  70. package/dist/sqlite/nodeSqliteQuerierPool.d.ts +4 -2
  71. package/dist/sqlite/nodeSqliteQuerierPool.js +7 -4
  72. package/dist/sqlite/sqliteDialect.d.ts +9 -1
  73. package/dist/sqlite/sqliteDialect.js +43 -3
  74. package/dist/sqlite/sqliteQuerierPool.d.ts +6 -5
  75. package/dist/sqlite/sqliteQuerierPool.js +10 -7
  76. package/dist/turso/tursoDialect.d.ts +1 -1
  77. package/dist/turso/tursoDialect.js +6 -2
  78. package/dist/turso/tursoLocalDialect.d.ts +1 -1
  79. package/dist/turso/tursoLocalDialect.js +1 -1
  80. package/dist/turso/tursoLocalQuerierPool.d.ts +4 -5
  81. package/dist/turso/tursoLocalQuerierPool.js +3 -10
  82. package/dist/type/dialect.d.ts +11 -0
  83. package/dist/type/entity.d.ts +45 -5
  84. package/dist/type/migration.d.ts +14 -9
  85. package/dist/type/query.d.ts +6 -0
  86. package/dist/type/queryAggregate.d.ts +73 -42
  87. package/dist/type/queryAggregate.js +4 -21
  88. package/dist/type/universalQuerier.d.ts +9 -9
  89. package/dist/type/vector.d.ts +17 -0
  90. package/dist/type/vector.js +6 -0
  91. package/dist/util/ddlExpression.util.d.ts +2 -0
  92. package/dist/util/ddlExpression.util.js +5 -0
  93. package/dist/util/dialect.util.d.ts +17 -3
  94. package/dist/util/dialect.util.js +40 -6
  95. package/package.json +5 -3
  96. package/skills/uql-orm/SKILL.md +142 -0
@@ -3,10 +3,10 @@ import { COUNT_RESULT_KEY, parseQueryLock, QueryRaw, RAW_ALIAS, VECTOR_QUERY_KEY
3
3
  import { isInlinedExpression } from '../util/field.util.js';
4
4
  import { asSelectMap, assertNonNegativeInteger, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, columnFamily, countedRelations, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorKey, isVectorSearch, normalizeScalarFieldSelection, parentJoins, targetKeyColumns, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, populatesRelations, aggregateOf, raw, refs, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
5
5
  import { escapeAnsiSqlLiteral } from '../util/sqlLiteral.js';
6
- import { AGGREGATE_PAGE_ALIAS, AGGREGATE_VALUE_ALIAS, COUNT_ALIAS, COUNTED_ROWS_ALIAS, JSON_ELEM_ALIAS, JSON_PULL_ALIAS, relationSortColumn, } from './aliases.js';
6
+ import { AGGREGATE_PAGE_ALIAS, AGGREGATE_VALUE_ALIAS, ROWS_ALIAS, JSON_ELEM_ALIAS, JSON_PULL_ALIAS, relationSortColumn, } from './aliases.js';
7
7
  import { holdsOperator, isJsonScalar, jsonCompareMode, jsonElemExists, jsonPath, } from './jsonSql.js';
8
8
  import { SqlQueryContext } from './queryContext.js';
9
- import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
9
+ import { groupPathField, NO_JOINS, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
10
10
  import { resolveVectorCast } from './vectorCast.js';
11
11
  import { VectorSqlDialect } from './vectorSqlDialect.js';
12
12
  /** The key a term answers under in a populated relation's row, which a raw expression has only once aliased. */
@@ -150,7 +150,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
150
150
  if (opts.prefix !== prefix) {
151
151
  opts = { ...opts, prefix };
152
152
  }
153
- this.where(ctx, entity, q.$where, opts);
153
+ this.where(ctx, entity, this.rankedWhere(meta, q, prefix), opts);
154
154
  const sorted = order
155
155
  ? this.orderCarried(ctx, q, order)
156
156
  : this.sort(ctx, entity, q.$sort, { prefix, joins, distinct: q.$distinct });
@@ -916,7 +916,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
916
916
  count(ctx, entity, q, opts) {
917
917
  const { $where, $skip, $limit } = q;
918
918
  if ($skip === undefined && $limit === undefined) {
919
- this.select(ctx, entity, { $select: [raw `COUNT(*)`.as(COUNT_ALIAS)] });
919
+ this.select(ctx, entity, { $select: [raw `COUNT(*)`.as(AGGREGATE_VALUE_ALIAS)] });
920
920
  this.search(ctx, entity, { $where }, opts);
921
921
  return;
922
922
  }
@@ -936,9 +936,9 @@ export class AbstractSqlDialect extends VectorSqlDialect {
936
936
  }
937
937
  /** `SELECT COUNT(*)` over the rows `rows` appends, as a derived table. */
938
938
  countRows(ctx, rows) {
939
- ctx.append(`SELECT COUNT(*) ${this.escapeId(COUNT_ALIAS, true)} FROM (`);
939
+ ctx.append(`SELECT COUNT(*) ${this.escapeId(AGGREGATE_VALUE_ALIAS, true)} FROM (`);
940
940
  rows();
941
- ctx.append(`) ${this.escapeId(COUNTED_ROWS_ALIAS, true)}`);
941
+ ctx.append(`) ${this.escapeId(ROWS_ALIAS, true)}`);
942
942
  }
943
943
  /**
944
944
  * The statistic the engine already keeps, as a `count` column. Overridden by the dialects that
@@ -958,35 +958,40 @@ export class AbstractSqlDialect extends VectorSqlDialect {
958
958
  };
959
959
  aggregate(ctx, entity, q, opts = {}) {
960
960
  const meta = getMeta(entity);
961
- const tableName = this.escapedTableName(meta);
961
+ const entries = parseGroupMap(q.$group, q.$select);
962
+ if (!entries.length) {
963
+ throw new TypeError('aggregate requires at least one $group column or $select function');
964
+ }
965
+ const table = this.tableRef(meta, this.readOptions(ctx, meta).alias);
966
+ const { joins, where } = resolveGroupJoins(meta, q, (path) => ctx.claimAlias(path));
967
+ const prefix = joins.size ? table.alias : undefined;
968
+ const reads = entries.map((entry) => ({ entry, value: this.aggregateValue(ctx, entity, entry, joins, prefix) }));
969
+ // Only bare columns are read inline: SQL Server refuses a subquery inside an aggregate or a
970
+ // `GROUP BY`, and a filter's bound values would repeat in `HAVING`. A derived table answers by alias.
971
+ const derived = reads.some(({ value }) => !value.bare);
972
+ const named = (sql, alias) => sql === this.escapeId(alias) ? sql : `${sql} ${this.escapeId(alias)}`;
962
973
  const groupKeys = [];
963
974
  const selectParts = [];
964
975
  // Every column the statement emits, mapped to the SQL that references it. `$having` and `$sort`
965
976
  // may name these and nothing else, so one map answers both "is this legal?" and "what do I
966
977
  // emit for it?".
967
978
  const emittedColumns = {};
968
- for (const entry of parseGroupMap(q.$group, q.$select)) {
979
+ for (const { entry, value } of reads) {
980
+ const column = derived ? this.escapeId(entry.alias) : value.sql;
981
+ const expr = entry.kind === 'key' ? column : this.aggregateFn(entry.op, value.sql === '*' ? '*' : column, entry.distinct);
969
982
  if (entry.kind === 'key') {
970
- const field = meta.fields[entry.alias];
971
- const columnName = this.resolveColumnName(entry.alias, field);
972
- const escaped = this.escapeId(columnName);
973
- groupKeys.push(escaped);
974
- emittedColumns[entry.alias] = escaped;
975
- selectParts.push(columnName !== entry.alias ? `${escaped} ${this.escapeId(entry.alias)}` : escaped);
976
- }
977
- else {
978
- const sqlFn = AbstractSqlDialect.AGGREGATE_FN[entry.op];
979
- const sqlArg = entry.fieldRef === '*' ? '*' : this.escapeId(this.columnOf(meta, entry.fieldRef));
980
- const expr = `${sqlFn}(${entry.distinct ? 'DISTINCT ' : ''}${sqlArg})`;
981
- emittedColumns[entry.alias] = expr;
982
- selectParts.push(`${expr} ${this.escapeId(entry.alias)}`);
983
+ groupKeys.push(expr);
983
984
  }
985
+ emittedColumns[entry.alias] = expr;
986
+ selectParts.push(named(expr, entry.alias));
984
987
  }
985
- if (!selectParts.length) {
986
- throw new TypeError('aggregate requires at least one $group column or $select function');
988
+ const columns = reads.flatMap(({ entry, value }) => (value.sql === '*' ? [] : [named(value.sql, entry.alias)]));
989
+ ctx.append(`SELECT ${selectParts.join(', ')} FROM ${derived ? `(SELECT ${columns.join(', ')} FROM ` : ''}${table.ref}`);
990
+ this.selectRelationJoins(ctx, meta, table.alias, joins);
991
+ this.where(ctx, entity, where, { ...opts, prefix });
992
+ if (derived) {
993
+ ctx.append(`) ${this.escapeId(ROWS_ALIAS, true)}`);
987
994
  }
988
- ctx.append(`SELECT ${selectParts.join(', ')} FROM ${tableName}`);
989
- this.where(ctx, entity, q.$where, opts);
990
995
  if (groupKeys.length) {
991
996
  ctx.append(` GROUP BY ${groupKeys.join(', ')}`);
992
997
  }
@@ -996,6 +1001,34 @@ export class AbstractSqlDialect extends VectorSqlDialect {
996
1001
  const sorted = this.aggregateSort(ctx, q.$sort, emittedColumns);
997
1002
  this.pager(ctx, q, sorted);
998
1003
  }
1004
+ /**
1005
+ * What one entry reads: a grouped field, through the join its path passes, or an aggregate's argument,
1006
+ * narrowed by its own `$where` to `CASE WHEN … THEN … END`. Bare where it is a column or `'*'`.
1007
+ */
1008
+ aggregateValue(ctx, entity, entry, joins, prefix) {
1009
+ if (entry.kind === 'key') {
1010
+ const { key, join } = groupPathField(joins, entry.path);
1011
+ return join
1012
+ ? this.aggregateOperand(ctx, join.entity, key, join.alias)
1013
+ : this.aggregateOperand(ctx, entity, key, prefix);
1014
+ }
1015
+ const arg = entry.fieldRef === '*' ? undefined : this.aggregateOperand(ctx, entity, entry.fieldRef, prefix);
1016
+ const { where } = entry;
1017
+ const condition = where &&
1018
+ this.buildFragment(ctx, (fragment) => this.renderWhere(fragment, entity, where, { clause: false, prefix }));
1019
+ if (!condition) {
1020
+ return arg ?? { sql: '*', bare: true };
1021
+ }
1022
+ return { sql: `CASE WHEN ${condition} THEN ${arg?.sql ?? '1'} END`, bare: false };
1023
+ }
1024
+ /** A field as an aggregate reads it: its column, or the expression an inlined one stands for. */
1025
+ aggregateOperand(ctx, entity, key, prefix) {
1026
+ const field = getMeta(entity).fields[key];
1027
+ return {
1028
+ sql: this.resolveOperandField(ctx, entity, key, { prefix }),
1029
+ bare: field === undefined || !isInlinedExpression(field),
1030
+ };
1031
+ }
999
1032
  /**
1000
1033
  * ORDER BY for aggregate queries - handles both entity-field and alias references. A grouped
1001
1034
  * statement has no joins to address, so a relation key is rejected rather than emitted as an alias
@@ -1094,6 +1127,13 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1094
1127
  ctx.append(` ${returning}`);
1095
1128
  }
1096
1129
  }
1130
+ /**
1131
+ * What a text search matches and a fulltext index covers, which have to agree for the index to serve the
1132
+ * search: the columns themselves, where the engine indexes them as they are (MySQL's `MATCH (a, b)`).
1133
+ */
1134
+ textSearchTarget(columns, _config) {
1135
+ return columns.join(', ');
1136
+ }
1097
1137
  /** Where an insert's id clause goes: `RETURNING` at the end, or SQL Server's `OUTPUT` before `VALUES`. */
1098
1138
  returningPosition = 'suffix';
1099
1139
  /**
@@ -1296,7 +1336,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1296
1336
  }
1297
1337
  const decoded = [];
1298
1338
  for (const [key, field] of Object.entries(meta.fields)) {
1299
- const kind = this.aggregateHydrateKind(meta, field) ?? this.hydrateKind(field);
1339
+ const kind = this.fieldKind(meta, field);
1300
1340
  if (kind) {
1301
1341
  decoded.push([key, kind]);
1302
1342
  }
@@ -1306,13 +1346,13 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1306
1346
  }
1307
1347
  /** The same for an aggregate's row, per query: each alias decodes by {@link aggregateKind}. */
1308
1348
  hydratableAggregates(entity, q) {
1309
- const { fields } = getMeta(entity);
1349
+ const meta = getMeta(entity);
1350
+ const { joins } = resolveGroupJoins(meta, q);
1310
1351
  const decoded = [];
1311
1352
  for (const entry of parseGroupMap(q.$group, q.$select)) {
1312
- // A grouped column is the field itself; an aggregate reads the one it aggregates, `'*'` for a tally.
1313
- const key = entry.kind === 'fn' ? entry.fieldRef : entry.alias;
1314
- const field = fields[key];
1315
- const kind = entry.kind === 'fn' ? this.aggregateKind(entry.op, field) : this.hydrateKind(field);
1353
+ const kind = entry.kind === 'fn'
1354
+ ? this.aggregateKind(entry.op, this.fieldKind(meta, meta.fields[entry.fieldRef]))
1355
+ : this.groupedKind(meta, joins, entry.path);
1316
1356
  if (kind) {
1317
1357
  decoded.push([entry.alias, kind]);
1318
1358
  }
@@ -1327,17 +1367,22 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1327
1367
  *
1328
1368
  * One rule for both readers: a relation aggregate a field declares, and an aggregate a query names.
1329
1369
  */
1330
- aggregateKind(op, field) {
1331
- return op === '$count' || op === '$avg' ? 'number' : this.hydrateKind(field);
1370
+ aggregateKind(op, fieldKind) {
1371
+ return op === '$count' || op === '$avg' ? 'number' : fieldKind;
1372
+ }
1373
+ /** What a grouped path decodes as: the kind of the field it reaches, through its join or on the entity. */
1374
+ groupedKind(meta, joins, path) {
1375
+ const { key, join } = groupPathField(joins, path);
1376
+ return join ? this.fieldKind(join.meta, join.meta.fields[key]) : this.fieldKind(meta, meta.fields[key]);
1332
1377
  }
1333
- /** What a relation aggregate a field declares decodes as: {@link aggregateKind} over the target's column. */
1334
- aggregateHydrateKind(meta, field) {
1378
+ /** What a field decodes as: its column's kind, or a relation aggregate's {@link aggregateKind} over the target's column. */
1379
+ fieldKind(meta, field) {
1335
1380
  const spec = aggregateOf(field);
1336
1381
  if (!spec) {
1337
- return undefined;
1382
+ return this.hydrateKind(field);
1338
1383
  }
1339
1384
  const target = getMeta(relationOf(meta, spec.relation).entity());
1340
- return this.aggregateKind(spec.op, spec.field ? target.fields[spec.field] : undefined);
1385
+ return this.aggregateKind(spec.op, spec.field ? this.fieldKind(target, target.fields[spec.field]) : undefined);
1341
1386
  }
1342
1387
  /** What one column decodes as, the inverse of {@link persistKind}. `BigInt` first, since it shares the numeric family. */
1343
1388
  hydrateKind(field) {
@@ -1349,7 +1394,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1349
1394
  case 'json':
1350
1395
  return 'json';
1351
1396
  case 'vector':
1352
- return this.supportedVectorType(resolveVectorCast(field));
1397
+ return this.features.vectorBytes ? 'float32' : this.supportedVectorType(resolveVectorCast(field));
1353
1398
  case 'boolean':
1354
1399
  return 'boolean';
1355
1400
  case 'numeric':
@@ -1395,8 +1440,6 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1395
1440
  * `$pull` reads the column and every later one its expression once, so no value binds twice.
1396
1441
  */
1397
1442
  formatJsonUpdate(ctx, escapedCol, value, field) {
1398
- // Centralizes the one narrowing cast: the payload's keys are typed against the entity's JSON
1399
- // payload, which the dialects do not need - they only build SQL from keys and values.
1400
1443
  const { $pull, $set, $push, $unset } = value;
1401
1444
  let expr = escapedCol;
1402
1445
  if (hasKeys($pull)) {
@@ -1584,7 +1627,8 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1584
1627
  const meta = getMeta(entity);
1585
1628
  const rel = relationOf(meta, aggregate.relation);
1586
1629
  const parent = prefix || this.resolveTableAlias(meta);
1587
- if (!aggregate.query?.$limit && aggregate.query?.$skip === undefined) {
1630
+ const { $limit, $skip } = aggregate.query ?? {};
1631
+ if ($limit === undefined && $skip === undefined) {
1588
1632
  this.appendRelationSubquery(ctx, meta, aggregate.relation, rel, { prefix: parent }, aggregate);
1589
1633
  return;
1590
1634
  }
@@ -1616,14 +1660,15 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1616
1660
  const joins = resolveQueryJoins(getMeta(entity), query, (path) => ctx.claimAlias(path));
1617
1661
  this.read(ctx, entity, query, { alias }, joins);
1618
1662
  }
1619
- /** One aggregate over an operand, `COALESCE`d where the aggregate answers `0` on no rows rather than null. */
1663
+ /** A relation aggregate over an operand, reading `0` on a parent with no rows where its type says so. */
1620
1664
  aggregateCall(op, operand) {
1621
- if (op === '$count') {
1622
- return 'COUNT(*)';
1623
- }
1624
- const call = `${AbstractSqlDialect.AGGREGATE_FN[op]}(${operand})`;
1665
+ const call = this.aggregateFn(op, op === '$count' ? '*' : operand);
1625
1666
  return op === '$sum' ? `COALESCE(${call}, 0)` : call;
1626
1667
  }
1668
+ /** One aggregate function call, the one spelling every statement that aggregates writes. */
1669
+ aggregateFn(op, operand, distinct) {
1670
+ return `${AbstractSqlDialect.AGGREGATE_FN[op]}(${distinct ? 'DISTINCT ' : ''}${operand})`;
1671
+ }
1627
1672
  /**
1628
1673
  * One equality per key of the parent, anded: a composite correlates on every column, and matching on
1629
1674
  * part of one would find the rows of a different parent. `parentJoins` keeps the two ends the right
@@ -1,17 +1,20 @@
1
- /** The column every internally-built count answers in: `COUNT(*)`, a grouped tally, a `$count` stage. */
2
- export declare const COUNT_ALIAS = "_uql_count";
3
1
  /** The column a paged read carries its own unpaged total in, from `COUNT(*) OVER ()`. */
4
2
  export declare const TOTAL_ALIAS = "_uql_total";
5
- /** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
6
- export declare const COUNTED_ROWS_ALIAS = "_uql_rows";
3
+ /**
4
+ * The derived table a statement wraps the rows it counts or aggregates in: a page, a `$distinct` set, or
5
+ * rows computing an inlined field an aggregate names. MySQL requires the alias.
6
+ */
7
+ export declare const ROWS_ALIAS = "_uql_rows";
7
8
  /** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
8
9
  export declare const AGGREGATE_PAGE_ALIAS = "_uql_page";
9
10
  /**
10
- * What an aggregate answers under: the column a capped page carries out for the aggregate wrapping it,
11
- * and the field a MongoDB `$group` or `$count` leaves its value in. One name, since both ends of each
12
- * are written and read here.
11
+ * The one value a statement that aggregates answers under: a `COUNT(*)` or an estimate, the column a
12
+ * capped page carries out for the aggregate wrapping it, and the field a MongoDB `$group` or `$count`
13
+ * leaves its result in. One name, since both ends of each are written and read here.
13
14
  */
14
15
  export declare const AGGREGATE_VALUE_ALIAS = "_uql_value";
16
+ /** Beside each MongoDB `$sum`, how many values it read: `$sum` answers 0 over none, where SQL answers null. */
17
+ export declare const SUM_COUNT_ALIAS = "_uql_count";
15
18
  /** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
16
19
  export declare const RELATION_ROW_ALIAS = "_uql_row";
17
20
  /** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
@@ -1,19 +1,22 @@
1
1
  // Every identifier UQL invents, `_uql`-prefixed to stay off a user's own, collected in one place:
2
2
  // the ends writing and reading one sit in different modules, and a drift between them fails silently.
3
- /** The column every internally-built count answers in: `COUNT(*)`, a grouped tally, a `$count` stage. */
4
- export const COUNT_ALIAS = '_uql_count';
5
3
  /** The column a paged read carries its own unpaged total in, from `COUNT(*) OVER ()`. */
6
4
  export const TOTAL_ALIAS = '_uql_total';
7
- /** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
8
- export const COUNTED_ROWS_ALIAS = '_uql_rows';
5
+ /**
6
+ * The derived table a statement wraps the rows it counts or aggregates in: a page, a `$distinct` set, or
7
+ * rows computing an inlined field an aggregate names. MySQL requires the alias.
8
+ */
9
+ export const ROWS_ALIAS = '_uql_rows';
9
10
  /** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
10
11
  export const AGGREGATE_PAGE_ALIAS = '_uql_page';
11
12
  /**
12
- * What an aggregate answers under: the column a capped page carries out for the aggregate wrapping it,
13
- * and the field a MongoDB `$group` or `$count` leaves its value in. One name, since both ends of each
14
- * are written and read here.
13
+ * The one value a statement that aggregates answers under: a `COUNT(*)` or an estimate, the column a
14
+ * capped page carries out for the aggregate wrapping it, and the field a MongoDB `$group` or `$count`
15
+ * leaves its result in. One name, since both ends of each are written and read here.
15
16
  */
16
17
  export const AGGREGATE_VALUE_ALIAS = '_uql_value';
18
+ /** Beside each MongoDB `$sum`, how many values it read: `$sum` answers 0 over none, where SQL answers null. */
19
+ export const SUM_COUNT_ALIAS = '_uql_count';
17
20
  /** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
18
21
  export const RELATION_ROW_ALIAS = '_uql_row';
19
22
  /** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
@@ -3,9 +3,10 @@ import { type VectorCast } from './vectorCast.js';
3
3
  * How a stored column is decoded on read: the inverse of `AbstractSqlDialect.persistKind`. `json`
4
4
  * parses; a {@link VectorCast} says which literal; `boolean` undoes an engine with no boolean type,
5
5
  * `number` and `bigint` a driver that hands a wide integer or a decimal back as text, and `date` and
6
- * `bytes` a row that crossed JSON inside its parent's statement, which spells both as text.
6
+ * `bytes` a row that crossed JSON inside its parent's statement, which spells both as text. `float32` is
7
+ * a vector bound as bytes (`DialectFeatures.vectorBytes`).
7
8
  */
8
- export type HydrateKind = 'json' | 'boolean' | 'number' | 'bigint' | 'date' | 'bytes' | VectorCast;
9
+ export type HydrateKind = 'json' | 'boolean' | 'number' | 'bigint' | 'date' | 'bytes' | 'float32' | VectorCast;
9
10
  /**
10
11
  * Decodes one non-null cell. A no-op where the driver already decoded it, since that varies per driver,
11
12
  * and untouched where it does not match its column's format.
@@ -20,6 +20,14 @@ function vectorDecoder(cast) {
20
20
  ? decodeFloat32s(hexBytes(text.slice(BYTES_PREFIX.length)))
21
21
  : (parseVectorLiteral(text, cast) ?? value));
22
22
  }
23
+ const denseVector = vectorDecoder('vector');
24
+ /** A vector bound as bytes: packed float32s as a driver returns them, else hex or text, as JSON or an older row carries it. */
25
+ const float32Decoder = (value) => {
26
+ if (value instanceof ArrayBuffer) {
27
+ return decodeFloat32s(new Uint8Array(value));
28
+ }
29
+ return value instanceof Uint8Array ? decodeFloat32s(value) : denseVector(value);
30
+ };
23
31
  const DECODERS = {
24
32
  // 0/1 from SQLite's INTEGER or MySQL's TINYINT(1). Already a boolean on Postgres.
25
33
  boolean: (value) => (typeof value === 'boolean' ? value : Boolean(value)),
@@ -48,7 +56,8 @@ const DECODERS = {
48
56
  return value;
49
57
  }
50
58
  }),
51
- vector: vectorDecoder('vector'),
59
+ float32: float32Decoder,
60
+ vector: denseVector,
52
61
  halfvec: vectorDecoder('halfvec'),
53
62
  sparsevec: vectorDecoder('sparsevec'),
54
63
  };
@@ -2,7 +2,7 @@ import { getMeta } from '../entity/index.js';
2
2
  import { textSearchFields } from '../util/index.js';
3
3
  import { escapeMysqlSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
4
4
  import { AbstractSqlDialect, } from './abstractSqlDialect.js';
5
- import { COUNT_ALIAS } from './aliases.js';
5
+ import { AGGREGATE_VALUE_ALIAS } from './aliases.js';
6
6
  import { BYTES_PREFIX } from './hydrateColumn.js';
7
7
  import { jsonSetCall, jsonPath, jsonRemoveCall, jsonSetTarget } from './jsonSql.js';
8
8
  import { aggregatesRelations } from './queryJoins.js';
@@ -23,10 +23,12 @@ export const MYSQL_FEATURES = {
23
23
  commentSyntax: 'inline',
24
24
  vectorIndexRequiresNotNull: false,
25
25
  vectorSupportsLength: false,
26
+ vectorBytes: false,
26
27
  supportsTimestamptz: false,
27
28
  stringSizing: 'varchar',
28
29
  supportsUnsigned: true,
29
30
  serverSideCursors: false,
31
+ correlatedWrites: true,
30
32
  rowLocks: true,
31
33
  rowLockWithWindow: true,
32
34
  rowLockOf: true,
@@ -49,7 +51,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
49
51
  estimatedCount(ctx, entity) {
50
52
  const meta = getMeta(entity);
51
53
  const schema = this.resolveSchema(meta);
52
- ctx.append(`SELECT TABLE_ROWS ${this.escapeId(COUNT_ALIAS, true)} FROM information_schema.TABLES WHERE TABLE_SCHEMA = `);
54
+ ctx.append(`SELECT TABLE_ROWS ${this.escapeId(AGGREGATE_VALUE_ALIAS, true)} FROM information_schema.TABLES WHERE TABLE_SCHEMA = `);
53
55
  if (schema) {
54
56
  ctx.addValue(schema);
55
57
  }
@@ -177,7 +179,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
177
179
  */
178
180
  appendTextSearch(ctx, _entity, meta, search) {
179
181
  const columns = textSearchFields(meta, search).map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
180
- ctx.append(`MATCH(${columns.join(', ')}) AGAINST(`);
182
+ ctx.append(`MATCH(${this.textSearchTarget(columns)}) AGAINST(`);
181
183
  ctx.addValue(search.$value);
182
184
  ctx.append(')');
183
185
  }
@@ -1,3 +1,4 @@
1
+ import type { IndexType } from '../schema/types.js';
1
2
  import { type DriverCapabilities, type EntityMeta, type FieldOptions, type JsonColumnType, type Query, type QueryContext, type QueryTextSearchOptions, type SqlDialectFeatures, type Type, type VectorDistance, type VectorMetric } from '../type/index.js';
2
3
  import type { DialectOptions } from './abstractDialect.js';
3
4
  import { AbstractSqlDialect, type RelationRows } from './abstractSqlDialect.js';
@@ -45,11 +46,11 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
45
46
  readonly maxBindValues: number;
46
47
  readonly vectorMetrics: ReadonlyMap<VectorDistance, VectorMetric>;
47
48
  /**
48
- * The GUC each pgvector index type reads for "how much of the index to explore". They are not the
49
+ * The setting each vector index type reads for "how much of the index to explore". They are not the
49
50
  * same quantity - `ef_search` is a candidate-list size, `probes` a count of lists - which is why
50
51
  * `$candidates` is documented in the index's own units rather than as a portable number.
51
52
  */
52
- private static readonly ANN_SETTINGS;
53
+ protected readonly annSettings: ReadonlyMap<IndexType, string>;
53
54
  /**
54
55
  * `SET LOCAL hnsw.ef_search = N`, plus `hnsw.iterative_scan = strict_order` where the query also filters
55
56
  * by distance, which would otherwise drop rows the candidate list missed.
@@ -58,11 +59,22 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
58
59
  normalizeValue(value: unknown): unknown;
59
60
  placeholder(index: number): string;
60
61
  /**
61
- * `TO_TSVECTOR(...) @@ WEBSEARCH_TO_TSQUERY(...)`. `WEBSEARCH_TO_TSQUERY` takes free-form user input
62
- * (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`, which
63
- * rejects anything unparseable - including a plain two-word search.
62
+ * `<document> @@ WEBSEARCH_TO_TSQUERY(...)`, under the `$config` asked for, else that of the fulltext
63
+ * index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
64
+ * input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
64
65
  */
65
66
  protected appendTextSearch<E>(ctx: QueryContext, _entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
67
+ /**
68
+ * The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
69
+ * `NULL` column read as empty rather than emptying it all. The config is a literal: an index is built
70
+ * over one, and a bound one reaches it only where the driver leaves the parameter untyped.
71
+ */
72
+ textSearchTarget(columns: readonly string[], config?: string): string;
73
+ /** A config as the text-search functions' first argument, or nothing, for the server's default. */
74
+ private textConfigArg;
75
+ /** How a config literal is typed, and the function reading a search's text: CockroachDB lacks both of these. */
76
+ protected readonly textConfigCast: string;
77
+ protected readonly textQueryFn: string;
66
78
  protected jsonContains(ctx: QueryContext, slot: JsonSlot, values: readonly unknown[]): string;
67
79
  protected jsonLength(slot: JsonSlot): string;
68
80
  /** Each element stays `jsonb`, so it is read as any path is: `->` for the value, `->>` for its text. */
@@ -1,5 +1,5 @@
1
1
  import { QueryRaw, } from '../type/index.js';
2
- import { hasVectorNear, textSearchFields } from '../util/dialect.util.js';
2
+ import { fulltextConfig, fulltextIndexOver, hasVectorNear, textSearchFields } from '../util/dialect.util.js';
3
3
  import { escapeSingleQuotes } from '../util/sqlLiteral.js';
4
4
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
5
5
  import { JSON_PULL_ALIAS, RELATION_ROW_ALIAS } from './aliases.js';
@@ -13,6 +13,8 @@ export const PG_VECTOR_METRICS = new Map([
13
13
  ['inner', { op: '<#>', index: 'ip' }],
14
14
  ['l1', { op: '<+>', index: 'l1' }],
15
15
  ]);
16
+ /** pgvector's HNSW candidate list, the one setting an iterative scan goes with. */
17
+ const HNSW_EF_SEARCH = 'hnsw.ef_search';
16
18
  /** What the Postgres-wire engines have. */
17
19
  export const PG_FEATURES = {
18
20
  ifNotExists: true,
@@ -25,10 +27,12 @@ export const PG_FEATURES = {
25
27
  commentSyntax: 'statement',
26
28
  vectorIndexRequiresNotNull: false,
27
29
  vectorSupportsLength: true,
30
+ vectorBytes: false,
28
31
  supportsTimestamptz: true,
29
32
  stringSizing: 'bounded-text',
30
33
  supportsUnsigned: false,
31
34
  serverSideCursors: true,
35
+ correlatedWrites: true,
32
36
  rowLocks: true,
33
37
  rowLockWithWindow: false,
34
38
  rowLockOf: true,
@@ -86,12 +90,12 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
86
90
  maxBindValues = 65535;
87
91
  vectorMetrics = PG_VECTOR_METRICS;
88
92
  /**
89
- * The GUC each pgvector index type reads for "how much of the index to explore". They are not the
93
+ * The setting each vector index type reads for "how much of the index to explore". They are not the
90
94
  * same quantity - `ef_search` is a candidate-list size, `probes` a count of lists - which is why
91
95
  * `$candidates` is documented in the index's own units rather than as a portable number.
92
96
  */
93
- static ANN_SETTINGS = new Map([
94
- ['hnsw', 'hnsw.ef_search'],
97
+ annSettings = new Map([
98
+ ['hnsw', HNSW_EF_SEARCH],
95
99
  ['ivfflat', 'ivfflat.probes'],
96
100
  ]);
97
101
  /**
@@ -100,12 +104,12 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
100
104
  */
101
105
  vectorTuningStatements(meta, q) {
102
106
  const indexType = this.tunedVectorIndex(meta, q)?.type;
103
- const setting = indexType ? PgLikeSqlDialect.ANN_SETTINGS.get(indexType) : undefined;
107
+ const setting = indexType ? this.annSettings.get(indexType) : undefined;
104
108
  if (!setting) {
105
109
  return [];
106
110
  }
107
111
  const statements = [`SET LOCAL ${setting} = ${q.$candidates}`];
108
- if (indexType === 'hnsw' && hasVectorNear(q.$where)) {
112
+ if (setting === HNSW_EF_SEARCH && hasVectorNear(q.$where)) {
109
113
  statements.push('SET LOCAL hnsw.iterative_scan = strict_order');
110
114
  }
111
115
  return statements;
@@ -120,20 +124,35 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
120
124
  return `$${index}`;
121
125
  }
122
126
  /**
123
- * `TO_TSVECTOR(...) @@ WEBSEARCH_TO_TSQUERY(...)`. `WEBSEARCH_TO_TSQUERY` takes free-form user input
124
- * (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`, which
125
- * rejects anything unparseable - including a plain two-word search.
127
+ * `<document> @@ WEBSEARCH_TO_TSQUERY(...)`, under the `$config` asked for, else that of the fulltext
128
+ * index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
129
+ * input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
126
130
  */
127
131
  appendTextSearch(ctx, _entity, meta, search) {
128
- const fields = textSearchFields(meta, search)
129
- .map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])))
130
- .join(` || ' ' || `);
131
- // The config is bound once and its numbered placeholder reused by both calls.
132
- const config = search.$config ? `${this.addValue(ctx, search.$config)}::regconfig, ` : '';
133
- ctx.append(`TO_TSVECTOR(${config}${fields}) @@ WEBSEARCH_TO_TSQUERY(${config}`);
132
+ const keys = textSearchFields(meta, search);
133
+ const index = fulltextIndexOver(meta, keys);
134
+ const config = search.$config ?? (index && fulltextConfig(index));
135
+ const columns = keys.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
136
+ ctx.append(`${this.textSearchTarget(columns, config)} @@ ${this.textQueryFn}(${this.textConfigArg(config)}`);
134
137
  ctx.addValue(search.$value);
135
138
  ctx.append(')');
136
139
  }
140
+ /**
141
+ * The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
142
+ * `NULL` column read as empty rather than emptying it all. The config is a literal: an index is built
143
+ * over one, and a bound one reaches it only where the driver leaves the parameter untyped.
144
+ */
145
+ textSearchTarget(columns, config) {
146
+ const document = columns.map((column) => `COALESCE(${column}, '')`).join(` || ' ' || `);
147
+ return `TO_TSVECTOR(${this.textConfigArg(config)}${document})`;
148
+ }
149
+ /** A config as the text-search functions' first argument, or nothing, for the server's default. */
150
+ textConfigArg(config) {
151
+ return config === undefined ? '' : `${this.escape(config)}${this.textConfigCast}, `;
152
+ }
153
+ /** How a config literal is typed, and the function reading a search's text: CockroachDB lacks both of these. */
154
+ textConfigCast = '::regconfig';
155
+ textQueryFn = 'WEBSEARCH_TO_TSQUERY';
137
156
  jsonContains(ctx, slot, values) {
138
157
  return `${this.jsonValue(slot)} @> ${this.jsonVal(ctx, values)}`;
139
158
  }
@@ -1,4 +1,4 @@
1
- import type { EntityMeta, Query, QuerySortMap, RelationMeta, RelationQuery, Type } from '../type/index.js';
1
+ import type { EntityMeta, Query, QueryGroupMap, QuerySortMap, QueryWhere, RelationMeta, RelationQuery, Type } from '../type/index.js';
2
2
  /**
3
3
  * One relation a statement joins, keyed by the alias its columns are addressed by (`tax`,
4
4
  * `tax.category`). `projected` tells a `$populate` join, whose columns are selected, from one only
@@ -39,6 +39,24 @@ export type QuerySortOptions = {
39
39
  * columns, the `ORDER BY` and the lock agree. `claimAlias` names each join's table, parents first.
40
40
  */
41
41
  export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>, claimAlias?: (path: string) => string): QueryJoins;
42
+ /**
43
+ * What an aggregate joins, and the `$where` left to it. Each to-one relation a `$group` path passes through
44
+ * is an `INNER` join, since a group of a path names a related row; a filter on one of them, keyed at the top
45
+ * of the `$where` where an `AND` joins it, moves into that join rather than reading its table again. Under a
46
+ * `$not` it could not: the join would drop the rows the negation keeps.
47
+ */
48
+ export declare function resolveGroupJoins<E>(meta: EntityMeta<E>, q: {
49
+ readonly $group?: QueryGroupMap<E>;
50
+ readonly $where?: QueryWhere<E>;
51
+ }, claimAlias?: (path: string) => string): {
52
+ readonly joins: QueryJoins;
53
+ readonly where: QueryWhere<E> | undefined;
54
+ };
55
+ /** The field a grouped `path` reads, and the join it reads it through: none for the entity's own. */
56
+ export declare function groupPathField(joins: QueryJoins, path: readonly string[]): {
57
+ readonly key: string;
58
+ readonly join: QueryJoin | undefined;
59
+ };
42
60
  /**
43
61
  * Whether a join drops parents that have no match, which is the one thing a join does to *how many*
44
62
  * rows a read returns rather than how wide they are. A count that skips the joins has to be told, or