uql-orm 0.27.0 → 0.28.1

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
  import { getMeta } from '../entity/index.js';
2
2
  import { AbstractQuerier, enrichError } from '../querier/index.js';
3
- import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
3
+ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  export class MongodbQuerier extends AbstractQuerier {
5
5
  dialect;
6
6
  conn;
@@ -30,11 +30,13 @@ export class MongodbQuerier extends AbstractQuerier {
30
30
  await this.fillToManyRelations(entity, documents, q.$populate);
31
31
  }
32
32
  else {
33
- const relationSummary = getRelationRequestSummary(meta, q.$populate);
34
33
  // A relation condition needs `$lookup`, so it forces the aggregation path just like populating
35
- // one does; a plain `find` cursor cannot express it.
36
- if (relationSummary.requestedKeys.length || this.dialect.constrainsRelations(entity, q.$where)) {
37
- const pipeline = this.dialect.aggregationPipeline(entity, q, relationSummary, opts);
34
+ // one does - and so does ordering by a relation, which reads what a lookup produced. A plain
35
+ // `find` cursor can express none of the three.
36
+ if (populatesRelations(meta, q.$populate) ||
37
+ this.dialect.constrainsRelations(entity, q.$where) ||
38
+ this.dialect.sortsRelations(entity, q.$sort)) {
39
+ const pipeline = this.dialect.aggregationPipeline(entity, q, opts);
38
40
  documents = await this.runPipeline(entity, meta, pipeline);
39
41
  await this.fillToManyRelations(entity, documents, q.$populate);
40
42
  }
@@ -83,7 +85,7 @@ export class MongodbQuerier extends AbstractQuerier {
83
85
  if (hasKeys(select)) {
84
86
  cursor.project(select);
85
87
  }
86
- const sort = this.dialect.sort(entity, q.$sort);
88
+ const sort = this.dialect.sort(entity, q.$sort, q.$populate);
87
89
  if (hasKeys(sort)) {
88
90
  cursor.sort(sort);
89
91
  }
@@ -105,43 +107,18 @@ export class MongodbQuerier extends AbstractQuerier {
105
107
  * `$vectorSearch` is always the first stage; `$where` is merged into its `filter`.
106
108
  */
107
109
  buildVectorPipeline(entity, q, vectorSort, opts) {
108
- const pipeline = [];
109
- pipeline.push(this.dialect.buildVectorSearchStage(entity, vectorSort.vectorKey, vectorSort.vectorSearch, q.$where, q.$limit ?? 10, opts));
110
- const meta = getMeta(entity);
111
- const relationSummary = getRelationRequestSummary(meta, q.$populate);
112
110
  const scoreAlias = vectorSort.vectorSearch.$project;
113
- // With relations the score is captured with `$addFields` before the lookups, and the scalar
114
- // projection waits until after them: projecting any earlier drops the join keys and the joined
115
- // documents, which is why `$populate` used to come back empty under a vector sort.
116
- if (relationSummary.requestedKeys.length) {
117
- if (scoreAlias) {
118
- pipeline.push({ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } });
119
- }
120
- pipeline.push(...this.dialect.relationStages(entity, q, relationSummary));
121
- const projection = this.dialect.pipelineProjection(entity, q, relationSummary);
122
- if (projection) {
123
- // `$addFields` already made the score a real field, so it projects like any other.
124
- pipeline.push({ $project: scoreAlias ? { ...projection, [scoreAlias]: 1 } : projection });
125
- }
126
- }
127
- else if (scoreAlias) {
128
- const select = q.$select || q.$exclude ? this.buildScalarProjection(entity, q) : {};
129
- pipeline.push({
130
- $project: {
131
- ...select,
132
- [scoreAlias]: { $meta: 'vectorSearchScore' },
133
- },
134
- });
135
- }
136
- else if ((q.$select && hasKeys(q.$select)) || (q.$exclude && hasKeys(q.$exclude))) {
137
- pipeline.push({ $project: this.buildScalarProjection(entity, q) });
138
- }
139
- // Secondary sort for non-vector fields
140
- const regularSort = this.dialect.sort(entity, vectorSort.regularSort);
141
- if (hasKeys(regularSort)) {
142
- pipeline.push({ $sort: regularSort });
143
- }
144
- return pipeline;
111
+ return [
112
+ this.dialect.buildVectorSearchStage(entity, vectorSort.vectorKey, vectorSort.vectorSearch, q.$where, q.$limit ?? 10, opts),
113
+ // The score becomes a real field before anything reads it, so the lookups and the projection
114
+ // that follow treat it like any other - and a query with no projection keeps its own columns.
115
+ ...(scoreAlias ? [{ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } }] : []),
116
+ // `$vectorSearch` has already applied `$limit`, so the pager is its own.
117
+ ...this.dialect.readStages(entity, q, opts, {
118
+ sort: this.dialect.sort(entity, vectorSort.regularSort, q.$populate),
119
+ project: scoreAlias ? { [scoreAlias]: 1 } : undefined,
120
+ }),
121
+ ];
145
122
  }
146
123
  async internalAggregate(entity, q, opts) {
147
124
  return this.timed('internalAggregate', undefined, async () => {
@@ -27,7 +27,7 @@ export declare class SqliteDialect extends AbstractSqlDialect {
27
27
  * when declared, else `NULL` (which is also how SQLite auto-generates INTEGER PRIMARY KEYs).
28
28
  */
29
29
  protected appendDefaultInsertValue(ctx: QueryContext, field: FieldOptions | undefined): void;
30
- protected ilikeExpr(f: string, ph: string): string;
30
+ protected readonly caseInsensitiveMatch = "native";
31
31
  protected get neOp(): string;
32
32
  normalizeValue(value: unknown): unknown;
33
33
  /**
@@ -53,9 +53,9 @@ export class SqliteDialect extends AbstractSqlDialect {
53
53
  ctx.append('NULL');
54
54
  }
55
55
  }
56
- ilikeExpr(f, ph) {
57
- return `${f} LIKE ${ph}`;
58
- }
56
+ // SQLite's `LIKE` already ignores case on both sides, for ASCII - and only ASCII, with or without
57
+ // `NOCASE`, so folding the pattern here would break the accented text the engine leaves alone.
58
+ caseInsensitiveMatch = 'native';
59
59
  get neOp() {
60
60
  return 'IS NOT';
61
61
  }
@@ -7,9 +7,11 @@ import type { Type } from './utility.js';
7
7
  */
8
8
  export type QueryComparisonOptions = QueryOptions & {
9
9
  /**
10
- * use precedence for the comparison or not.
10
+ * Whether this fragment is rendered as an operand of an enclosing `AND`/`OR`/`NOT`. An operand
11
+ * parenthesizes itself when it emits more than one term, so no fragment ever depends on the
12
+ * engine's operator precedence. Only the `WHERE` clause as a whole is not an operand.
11
13
  */
12
- usePrecedence?: boolean;
14
+ operand?: boolean;
13
15
  };
14
16
  /**
15
17
  * query filter options.
@@ -31,6 +33,12 @@ export interface QueryContext {
31
33
  * alias would let the inner occurrence shadow the outer one it needs to correlate against.
32
34
  */
33
35
  nextAlias(prefix: string): string;
36
+ /**
37
+ * A context for a fragment of this same statement: it renders its own SQL in isolation while
38
+ * sharing the bound values and the generated aliases, so both stay unique and correctly numbered
39
+ * across the statement. See {@link AbstractSqlDialect.buildFragment}.
40
+ */
41
+ createFragment(): QueryContext;
34
42
  readonly sql: string;
35
43
  readonly values: unknown[];
36
44
  }
@@ -148,7 +156,7 @@ export interface QueryDialect {
148
156
  * @param forbidQualified don't escape dots
149
157
  * @param addDot use a dot as suffix
150
158
  */
151
- escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string;
159
+ escapeId(val: string | undefined, forbidQualified?: boolean, addDot?: boolean): string;
152
160
  /**
153
161
  * escape a value.
154
162
  * @param val the value to escape
@@ -121,12 +121,12 @@ export type QuerySortValue = QuerySortDirection | QueryVectorSearch;
121
121
  * like `QueryWhereMap`), relation sort via nested objects, and vector similarity search on
122
122
  * `number[]` fields.
123
123
  */
124
- export type QuerySortMap<E> = {
125
- [K in FieldKey<E>]?: NonNullable<E[K]> extends readonly number[] ? QuerySortValue : QuerySortDirection;
124
+ export type QuerySortMap<E, Vector extends boolean = true> = {
125
+ [K in FieldKey<E>]?: Vector extends true ? NonNullable<E[K]> extends readonly number[] ? QuerySortValue : QuerySortDirection : QuerySortDirection;
126
126
  } & {
127
127
  [P in JsonFieldPaths<E>]?: QuerySortDirection;
128
128
  } & {
129
- [K in RelationKey<E>]?: QuerySortMap<NonNullable<Unpacked<E[K]>>>;
129
+ [K in RelationKey<E> as NonNullable<E[K]> extends readonly unknown[] ? never : K]?: QuerySortMap<NonNullable<E[K]>, false>;
130
130
  };
131
131
  /**
132
132
  * pager options.
@@ -1,4 +1,4 @@
1
- import { type CascadeType, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
1
+ import { type CascadeType, type EntityMeta, type FieldKey, type FieldOptions, type JsonUpdateOp, type OnFieldCallback, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QueryVectorSearch, type QueryWhere, type QueryWhereMap, type RelationKey } from '../type/index.js';
2
2
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
3
3
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: E, callbackKey: CallbackKey): FieldKey<E>[];
4
4
  /**
@@ -47,7 +47,6 @@ export declare function isCascadable(action: CascadeType, configuration?: boolea
47
47
  */
48
48
  export declare function asSelectMap<E>(select: QuerySelectValue<E> | undefined): QuerySelect<E> | undefined;
49
49
  export declare function normalizeScalarFieldSelection<E>(meta: EntityMeta<E>, select?: QuerySelect<E>, exclude?: QueryExclude<E>): FieldKey<E>[];
50
- export declare function buildSortMap<E>(sort: QuerySortMap<E> | undefined): QuerySortMap<E>;
51
50
  /** Type guard: checks whether a sort value is a vector similarity search. */
52
51
  export declare function isVectorSearch(value: unknown): value is QueryVectorSearch;
53
52
  /** Type guard: checks whether an update payload value is a JSON operator object. */
@@ -141,9 +141,6 @@ export function normalizeScalarFieldSelection(meta, select, exclude) {
141
141
  const excluded = excludedFields;
142
142
  return allFields.filter((it) => !excluded.has(it));
143
143
  }
144
- export function buildSortMap(sort) {
145
- return (sort ?? {});
146
- }
147
144
  /** Type guard: checks whether a sort value is a vector similarity search. */
148
145
  export function isVectorSearch(value) {
149
146
  return value !== null && typeof value === 'object' && '$vector' in value;
@@ -1,12 +1,26 @@
1
- import type { EntityMeta, Except, Query, QueryPopulate, RelationKey } from '../type/index.js';
1
+ import type { EntityMeta, Except, Query, QueryPopulate, RelationKey, RelationMeta } from '../type/index.js';
2
2
  export type RelationRequestSummary<E> = {
3
3
  readonly requestedKeys: RelationKey<E>[];
4
4
  readonly joinableKeys: RelationKey<E>[];
5
5
  readonly toManyKeys: RelationKey<E>[];
6
6
  };
7
+ /**
8
+ * Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. Takes
9
+ * the one field it reads, so it answers for a relation being declared as well as for a resolved one.
10
+ */
11
+ export declare function isToManyRelation(relation: Pick<RelationMeta, 'cardinality'>): boolean;
12
+ /**
13
+ * What a joined relation cannot carry, and why. A to-many is loaded by a query of its own, which is
14
+ * what gives these four a meaning there; a to-one is one row of the parent's, so every backend used
15
+ * to drop them without a word. `satisfies` ties each key to {@link RelationQuery}, so renaming one
16
+ * breaks this list at compile time rather than quietly stopping the check.
17
+ */
18
+ declare const JOINED_RELATION_REJECTIONS: readonly [readonly ["$sort", "a join brings one row per parent, so there is nothing to order"], readonly ["$limit", "a join brings one row per parent, so there is nothing to page"], readonly ["$skip", "a join brings one row per parent, so there is nothing to page"], readonly ["$distinct", "it applies to the whole statement, not to one of its joins"]];
19
+ /** A key only a to-many's own query can carry, and that a joined relation therefore rejects. */
20
+ export type JoinedRelationRejectedKey = (typeof JOINED_RELATION_REJECTIONS)[number][0];
7
21
  export declare function getRelationRequestSummary<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): RelationRequestSummary<E>;
8
22
  /** True when `$populate` includes at least one relation key. */
9
- export declare function isPopulatingRelations<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): boolean;
23
+ export declare function populatesRelations<E>(meta: EntityMeta<E>, populate?: QueryPopulate<E>): boolean;
10
24
  export type RelationQuery<E extends object = object> = Except<Query<E>, '$lock'> & {
11
25
  $required?: boolean;
12
26
  };
@@ -20,3 +34,4 @@ export declare function parseRelationQueryValue<E extends object = object>(value
20
34
  /** Parses the relation payload for `relKey` */
21
35
  export declare function parseRelationAtKey<E>(relKey: RelationKey<E>, populate?: QueryPopulate<E>): ParsedRelationQuery;
22
36
  export declare function forEachRequestedRelation<E extends object>(meta: EntityMeta<E>, populate: QueryPopulate<E> | undefined, fn: (relKey: RelationKey<E>, rawValue: unknown) => void): void;
37
+ export {};
@@ -1,4 +1,34 @@
1
1
  import { getKeys } from './object.util.js';
2
+ /**
3
+ * Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. Takes
4
+ * the one field it reads, so it answers for a relation being declared as well as for a resolved one.
5
+ */
6
+ export function isToManyRelation(relation) {
7
+ return relation.cardinality === '1m' || relation.cardinality === 'mm';
8
+ }
9
+ /**
10
+ * What a joined relation cannot carry, and why. A to-many is loaded by a query of its own, which is
11
+ * what gives these four a meaning there; a to-one is one row of the parent's, so every backend used
12
+ * to drop them without a word. `satisfies` ties each key to {@link RelationQuery}, so renaming one
13
+ * breaks this list at compile time rather than quietly stopping the check.
14
+ */
15
+ const JOINED_RELATION_REJECTIONS = [
16
+ ['$sort', 'a join brings one row per parent, so there is nothing to order'],
17
+ ['$limit', 'a join brings one row per parent, so there is nothing to page'],
18
+ ['$skip', 'a join brings one row per parent, so there is nothing to page'],
19
+ ['$distinct', 'it applies to the whole statement, not to one of its joins'],
20
+ ];
21
+ const JOINED_RELATION_REJECTED_KEYS = new Map(JOINED_RELATION_REJECTIONS);
22
+ function assertJoinableRelationQuery(relKey, value) {
23
+ if (!value || typeof value !== 'object') {
24
+ return;
25
+ }
26
+ for (const [key, reason] of JOINED_RELATION_REJECTED_KEYS) {
27
+ if (key in value) {
28
+ throw new TypeError(`'${key}' is not supported inside $populate of the to-one relation '${relKey}': ${reason}.`);
29
+ }
30
+ }
31
+ }
2
32
  export function getRelationRequestSummary(meta, populate) {
3
33
  const requestedKeys = [];
4
34
  const joinableKeys = [];
@@ -12,17 +42,20 @@ export function getRelationRequestSummary(meta, populate) {
12
42
  if (!relOpts)
13
43
  continue;
14
44
  requestedKeys.push(key);
15
- if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
45
+ if (isToManyRelation(relOpts)) {
16
46
  toManyKeys.push(key);
17
47
  }
18
48
  else {
49
+ // Validated where the cardinality is decided, so every backend and every nesting level rejects
50
+ // the same shapes - the SQL dialects, MongoDB's lookups, and whatever reads this summary next.
51
+ assertJoinableRelationQuery(key, populate[key]);
19
52
  joinableKeys.push(key);
20
53
  }
21
54
  }
22
55
  return { requestedKeys, joinableKeys, toManyKeys };
23
56
  }
24
57
  /** True when `$populate` includes at least one relation key. */
25
- export function isPopulatingRelations(meta, populate) {
58
+ export function populatesRelations(meta, populate) {
26
59
  if (!populate)
27
60
  return false;
28
61
  return getKeys(populate).some((key) => populate[key] && key in meta.relations);
@@ -1,6 +1,5 @@
1
1
  import type { InsertIdSource, QueryUpdateResult, RawRow } from '../type/index.js';
2
2
  import type { PrimaryKey } from '../type/utility.js';
3
- export declare function flatObject<E extends object>(obj: E, pre?: string): E;
4
3
  export declare function unflatObjects<T extends object>(objects: RawRow[]): T[];
5
4
  /**
6
5
  * Unflattens a single raw row using pre-computed attribute paths.
@@ -17,7 +16,7 @@ export declare function obtainAttrsPaths<T extends object>(row: T): {
17
16
  * @param forbidQualified whether to forbid qualified identifiers (containing dots)
18
17
  * @param addDot whether to add a dot suffix
19
18
  */
20
- export declare function escapeSqlId(val: string, escapeIdChar?: '`' | '"', forbidQualified?: boolean, addDot?: boolean): string;
19
+ export declare function escapeSqlId(val: string | undefined, escapeIdChar?: '`' | '"', forbidQualified?: boolean, addDot?: boolean): string;
21
20
  /**
22
21
  * Payload for building a QueryUpdateResult.
23
22
  */
@@ -1,17 +1,6 @@
1
- import { getKeys, hasKeys } from './object.util.js';
1
+ import { hasKeys } from './object.util.js';
2
2
  /** Pre-computed regex for each SQL identifier escape character to avoid per-call allocation. */
3
3
  const escapeIdRegexCache = { '`': /`/g, '"': /"/g };
4
- export function flatObject(obj, pre) {
5
- return getKeys(obj).reduce((acc, key) => flatObjectEntry(acc, key, obj[key], typeof obj[key] === 'object' ? '' : pre), {});
6
- }
7
- function flatObjectEntry(map, key, val, pre) {
8
- const prefix = pre ? `${pre}.${key}` : key;
9
- if (typeof val === 'object' && val !== null) {
10
- return getKeys(val).reduce((acc, prop) => flatObjectEntry(acc, prop, val[prop], prefix), map);
11
- }
12
- map[prefix] = val;
13
- return map;
14
- }
15
4
  export function unflatObjects(objects) {
16
5
  if (!Array.isArray(objects) || !objects.length) {
17
6
  return objects;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "uql-orm",
3
3
  "homepage": "https://uql-orm.dev",
4
- "description": "Extremely fast, type-safe TypeScript ORM - one API for every database",
4
+ "description": "JSON-native TypeScript ORM: queries are plain JSON, typed to the leaf. One API for SQL databases and MongoDB.",
5
5
  "license": "MIT",
6
- "version": "0.27.0",
6
+ "version": "0.28.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"