@mikro-orm/core 7.2.0-dev.16 → 7.2.0-dev.18

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.
@@ -65,13 +65,22 @@ export declare abstract class DatabaseDriver<C extends Connection> implements ID
65
65
  where: FilterQuery<T>;
66
66
  };
67
67
  /**
68
- * Restores the JS value of a single cursor offset: ISO strings become `Date` instances based on the
69
- * property type (never based on the string shape alone), and custom types are restored via
70
- * `convertToJSValue`. Values compared against a JSON document keep their serialized form instead,
71
- * unless the platform preserves native date types inside JSON documents (mongo).
68
+ * Resolves a leaf `orderBy` direction into the two flags the rewritten `orderBy` and the cursor
69
+ * condition have to agree on, or pagination skips rows at the null boundary. Platforms that cannot
70
+ * order nulls explicitly sort them as the lowest value, elsewhere the requested placement wins,
71
+ * defaulting to nulls last for `asc` and nulls first for `desc`.
72
+ */
73
+ private parseCursorDirection;
74
+ /**
75
+ * Restores the JS value of a single cursor offset. String-cursor values (`fromJson`) are decoded
76
+ * JSON: types implementing `fromJSON` own the round trip, other custom types restore via
77
+ * `convertToJSValue`, with `Date` healing for date-like columns based on the property type (never
78
+ * based on the string shape alone). POJO values are already JS values and only date-like strings
79
+ * are healed. Values compared against a JSON document keep their serialized form instead, unless
80
+ * the platform preserves native date types inside JSON documents (mongo).
72
81
  */
73
82
  private mapCursorOffset;
74
- protected createCursorCondition<T extends object>(definition: (readonly [keyof T & string, QueryOrder])[], offsets: Dictionary[], inverse: boolean, meta: EntityMetadata<T>): FilterQuery<T>;
83
+ protected createCursorCondition<T extends object>(definition: (readonly [keyof T & string, QueryOrder])[], offsets: Dictionary[], inverse: boolean, meta: EntityMetadata<T>, fromJson?: boolean): FilterQuery<T>;
75
84
  /** @internal */
76
85
  mapDataToFieldNames(data: Dictionary, stringifyJsonArrays: boolean, properties?: Record<string, EntityProperty>, convertCustomTypes?: boolean, object?: boolean): Dictionary;
77
86
  protected inlineEmbeddables<T extends object>(meta: EntityMetadata<T>, data: T, where?: boolean): void;
@@ -10,7 +10,6 @@ import { helper } from '../entity/wrap.js';
10
10
  import { Reference } from '../entity/Reference.js';
11
11
  import { PolymorphicRef } from '../entity/PolymorphicRef.js';
12
12
  import { JsonType } from '../types/JsonType.js';
13
- import { DateTimeType } from '../types/DateTimeType.js';
14
13
  import { QueryHelper } from '../utils/QueryHelper.js';
15
14
  import { MikroORM } from '../MikroORM.js';
16
15
  /** Abstract base class for all database drivers, implementing common driver logic. */
@@ -146,6 +145,7 @@ export class DatabaseDriver {
146
145
  const createCursor = (val, key, inverse = false) => {
147
146
  const def = Reference.unwrapReference((isCursor(val, key) ? val[key] : val));
148
147
  let offsets;
148
+ let fromJson = false;
149
149
  // entity (and reference) instances are supported as cursors too, their properties are read the same way
150
150
  if (Utils.isPlainObject(def) || Utils.isEntity(def)) {
151
151
  // POJO values are already JS values, extract them ordered per the definition,
@@ -158,11 +158,17 @@ export class DatabaseDriver {
158
158
  });
159
159
  }
160
160
  else {
161
- /* v8 ignore next */
162
- offsets = def ? Cursor.decode(def) : [];
161
+ try {
162
+ /* v8 ignore next */
163
+ offsets = def ? Cursor.decode(def) : [];
164
+ }
165
+ catch (error) {
166
+ throw CursorError.invalidCursor(meta.className, error);
167
+ }
168
+ fromJson = true;
163
169
  }
164
170
  if (definition.length > 0 && definition.length === offsets.length) {
165
- return this.createCursorCondition(definition, offsets, inverse, meta);
171
+ return this.createCursorCondition(definition, offsets, inverse, meta, fromJson);
166
172
  }
167
173
  /* v8 ignore next */
168
174
  return {};
@@ -176,16 +182,25 @@ export class DatabaseDriver {
176
182
  if (limit != null) {
177
183
  options.limit = limit + (overfetch ? 1 : 0);
178
184
  }
179
- const createOrderBy = (prop, direction) => {
185
+ const createOrderBy = (prop, direction, properties = meta.properties, nullable = false) => {
186
+ const propMeta = properties[prop];
187
+ // nullable relations and embeddables null out their joined columns, and a formula can yield null unannounced
188
+ nullable ||= !!propMeta?.nullable || !!propMeta?.formula;
180
189
  if (Utils.isPlainObject(direction)) {
190
+ const childProps = propMeta?.kind === ReferenceKind.EMBEDDED ? propMeta.embeddedProps : propMeta?.targetMeta?.properties;
181
191
  const value = Utils.getObjectQueryKeys(direction).reduce((o, key) => {
182
- Object.assign(o, createOrderBy(key, direction[key]));
192
+ Object.assign(o, createOrderBy(key, direction[key], childProps ?? {}, nullable));
183
193
  return o;
184
194
  }, {});
185
195
  return { [prop]: value };
186
196
  }
187
- const desc = direction === QueryOrderNumeric.DESC || direction.toString().toLowerCase() === 'desc';
197
+ const { desc, nullsFirst } = this.parseCursorDirection(direction);
188
198
  const dir = Utils.xor(desc, isLast) ? 'desc' : 'asc';
199
+ // the condition assumes a placement, spell it out instead of taking the database default
200
+ if (nullable && this.platform.supportsNullsOrdering()) {
201
+ const nulls = Utils.xor(nullsFirst, isLast) ? 'first' : 'last';
202
+ return { [prop]: `${dir} nulls ${nulls}` };
203
+ }
189
204
  return { [prop]: dir };
190
205
  };
191
206
  // the cursor condition is created at the driver level, after the EM already converted custom types
@@ -203,12 +218,34 @@ export class DatabaseDriver {
203
218
  };
204
219
  }
205
220
  /**
206
- * Restores the JS value of a single cursor offset: ISO strings become `Date` instances based on the
207
- * property type (never based on the string shape alone), and custom types are restored via
208
- * `convertToJSValue`. Values compared against a JSON document keep their serialized form instead,
209
- * unless the platform preserves native date types inside JSON documents (mongo).
221
+ * Resolves a leaf `orderBy` direction into the two flags the rewritten `orderBy` and the cursor
222
+ * condition have to agree on, or pagination skips rows at the null boundary. Platforms that cannot
223
+ * order nulls explicitly sort them as the lowest value, elsewhere the requested placement wins,
224
+ * defaulting to nulls last for `asc` and nulls first for `desc`.
225
+ */
226
+ parseCursorDirection(direction) {
227
+ const dir = ('' + direction).toLowerCase();
228
+ const desc = direction === QueryOrderNumeric.DESC || dir.startsWith('desc');
229
+ if (!this.platform.supportsNullsOrdering()) {
230
+ return { desc, nullsFirst: !desc };
231
+ }
232
+ if (dir.includes('nulls first')) {
233
+ return { desc, nullsFirst: true };
234
+ }
235
+ if (dir.includes('nulls last')) {
236
+ return { desc, nullsFirst: false };
237
+ }
238
+ return { desc, nullsFirst: desc };
239
+ }
240
+ /**
241
+ * Restores the JS value of a single cursor offset. String-cursor values (`fromJson`) are decoded
242
+ * JSON: types implementing `fromJSON` own the round trip, other custom types restore via
243
+ * `convertToJSValue`, with `Date` healing for date-like columns based on the property type (never
244
+ * based on the string shape alone). POJO values are already JS values and only date-like strings
245
+ * are healed. Values compared against a JSON document keep their serialized form instead, unless
246
+ * the platform preserves native date types inside JSON documents (mongo).
210
247
  */
211
- mapCursorOffset(prop, value, insideJson) {
248
+ mapCursorOffset(prop, value, insideJson, fromJson) {
212
249
  if (Utils.isScalarReference(value)) {
213
250
  value = value.unwrap();
214
251
  }
@@ -219,25 +256,65 @@ export class DatabaseDriver {
219
256
  if (value == null) {
220
257
  return value;
221
258
  }
259
+ // mongo preserves native date types inside JSON documents, restored JS values compare directly
222
260
  if (insideJson && !this.platform.preservesDatesInsideJson()) {
223
- // compared against the JSON document, which holds the serialized form
261
+ // compared against the JSON document, which holds the JSON form of the database value
224
262
  if (value instanceof Date) {
225
263
  return value.toISOString();
226
264
  }
227
- // restore the JS value from the serialized form, `processWhere` then converts it to
228
- // the database form, which is what the JSON document holds for custom typed props
229
- return prop?.customType ? prop.customType.convertToJSValue(value, this.platform) : value;
265
+ if (prop?.customType && fromJson) {
266
+ const restored = prop.customType.fromJSON
267
+ ? prop.customType.fromJSON(value, this.platform)
268
+ : prop.customType.convertToJSValue(value, this.platform);
269
+ // a restored `Date` compares against its ISO string in the document, everything else
270
+ // keeps the JS value and gets its single `convertToDatabaseValue` in `processWhere`
271
+ if (restored instanceof Date) {
272
+ const converted = prop.customType.convertToDatabaseValue(restored, this.platform, {
273
+ fromQuery: true,
274
+ key: prop.name,
275
+ mode: 'query',
276
+ });
277
+ if (converted instanceof Date) {
278
+ return converted.toISOString();
279
+ }
280
+ }
281
+ return restored;
282
+ }
283
+ return value;
230
284
  }
231
- if (typeof value === 'string' &&
232
- (prop?.runtimeType === 'Date' ||
233
- (prop?.customType && this.platform.getMappedType(prop.columnTypes?.[0] ?? '') instanceof DateTimeType))) {
234
- value = new Date(value);
285
+ if (prop?.customType) {
286
+ if (fromJson && prop.customType.fromJSON) {
287
+ // the type owns its JSON round trip
288
+ return prop.customType.fromJSON(value, this.platform);
289
+ }
290
+ // A string is either a serialized form (string cursor) or a hand-written one (POJO).
291
+ // Types whose JS value is a `Date` must receive one. The rest get the string first,
292
+ // so any sub-millisecond precision survives.
293
+ if (typeof value === 'string' &&
294
+ (prop.runtimeType === 'Date' || this.platform.getMappedType(prop.columnTypes?.[0] ?? '').runtimeType === 'Date')) {
295
+ if (prop.runtimeType !== 'Date') {
296
+ try {
297
+ return prop.customType.convertToJSValue(value, this.platform);
298
+ }
299
+ catch {
300
+ // the type cannot read the serialized string, fall back to the `Date` form
301
+ }
302
+ }
303
+ return prop.customType.convertToJSValue(new Date(value), this.platform);
304
+ }
305
+ // serialized non-string values still need restoring; POJO values are already JS values
306
+ return fromJson ? prop.customType.convertToJSValue(value, this.platform) : value;
307
+ }
308
+ if (typeof value === 'string' && prop?.runtimeType === 'Date') {
309
+ return new Date(value);
235
310
  }
236
- return prop?.customType ? prop.customType.convertToJSValue(value, this.platform) : value;
311
+ return value;
237
312
  }
238
- createCursorCondition(definition, offsets, inverse, meta) {
239
- const createCondition = (prop, direction, offset, eq = false, path = prop, properties = meta.properties, insideJson = false) => {
313
+ createCursorCondition(definition, offsets, inverse, meta, fromJson = false) {
314
+ const createCondition = (prop, direction, offset, eq = false, path = prop, properties = meta.properties, insideJson = false, nullable = false) => {
240
315
  const propMeta = properties[prop];
316
+ // nullable relations and embeddables null out their joined columns, and a formula can yield null unannounced
317
+ nullable ||= !!propMeta?.nullable || !!propMeta?.formula;
241
318
  if (Utils.isPlainObject(direction)) {
242
319
  if (offset === undefined) {
243
320
  throw CursorError.missingValue(meta.className, path);
@@ -247,40 +324,45 @@ export class DatabaseDriver {
247
324
  const childProps = propMeta?.kind === ReferenceKind.EMBEDDED ? propMeta.embeddedProps : propMeta?.targetMeta?.properties;
248
325
  insideJson ||=
249
326
  (propMeta?.kind === ReferenceKind.EMBEDDED && !!propMeta.object) || propMeta?.customType instanceof JsonType;
250
- const value = Utils.keys(direction).reduce((o, key) => {
251
- Object.assign(o, createCondition(key, direction[key], offset?.[key], eq, `${path}.${key}`, childProps ?? {}, insideJson));
252
- return o;
253
- }, {});
254
- return { [prop]: value };
255
- }
256
- const isDesc = direction === QueryOrderNumeric.DESC || direction.toString().toLowerCase() === 'desc';
257
- const dirStr = direction.toString().toLowerCase();
258
- let nullsFirst;
259
- if (dirStr.includes('nulls first')) {
260
- nullsFirst = true;
261
- }
262
- else if (dirStr.includes('nulls last')) {
263
- nullsFirst = false;
264
- }
265
- else {
266
- // Default: NULLS LAST for ASC, NULLS FIRST for DESC (matches most databases)
267
- nullsFirst = isDesc;
327
+ const children = Utils.keys(direction)
328
+ .map(key => createCondition(key, direction[key],
329
+ // a null relation offset means the whole sort key is null, propagate it to the leaves
330
+ offset === null ? null : offset[key], eq, `${path}.${key}`, childProps ?? {}, insideJson, nullable))
331
+ .filter(child => Utils.hasObjectKeys(child));
332
+ // children comparing against nulls are group conditions, those cannot be merged into one object
333
+ const value = children.length > 1 && children.some(child => '$or' in child)
334
+ ? { $and: children }
335
+ : children.reduce((o, child) => Object.assign(o, child), {});
336
+ // an unconstrained child condition must not degrade to `{ [prop]: {} }`
337
+ return children.length > 0 ? { [prop]: value } : {};
268
338
  }
339
+ const { desc: isDesc, nullsFirst } = this.parseCursorDirection(direction);
269
340
  const operator = Utils.xor(isDesc, inverse) ? '$lt' : '$gt';
270
341
  // For leaf-level properties, undefined means missing value
271
342
  if (offset === undefined) {
272
343
  throw CursorError.missingValue(meta.className, path);
273
344
  }
274
- offset = this.mapCursorOffset(propMeta, offset, insideJson);
345
+ // string-cursor values are client supplied, so a value the type cannot restore is an
346
+ // invalid cursor, letting callers map `CursorError` to a client error response
347
+ try {
348
+ offset = this.mapCursorOffset(propMeta, offset, insideJson, fromJson);
349
+ }
350
+ catch (error) {
351
+ if (!fromJson || error instanceof CursorError) {
352
+ throw error;
353
+ }
354
+ throw CursorError.invalidCursor(meta.className, error);
355
+ }
275
356
  // Handle null offset (intentional null cursor value)
276
357
  if (offset === null) {
358
+ // hasItemsAfterNull: forward + nullsFirst, or backward + nullsLast
359
+ const hasItemsAfterNull = Utils.xor(nullsFirst, inverse);
277
360
  if (eq) {
278
- // Equal to null
279
- return { [prop]: null };
361
+ // at-or-after a null sort key means every row when non-null rows follow the null block,
362
+ // so the tie-breaker in the `$or` sibling can reach past it
363
+ return hasItemsAfterNull ? {} : { [prop]: null };
280
364
  }
281
365
  // Strict comparison with null cursor value
282
- // hasItemsAfterNull: forward + nullsFirst, or backward + nullsLast
283
- const hasItemsAfterNull = Utils.xor(nullsFirst, inverse);
284
366
  if (hasItemsAfterNull) {
285
367
  return { [prop]: { $ne: null } };
286
368
  }
@@ -288,7 +370,12 @@ export class DatabaseDriver {
288
370
  return { [prop]: [] };
289
371
  }
290
372
  // Non-null offset
291
- return { [prop]: { [operator + (eq ? 'e' : '')]: offset } };
373
+ const condition = { [prop]: { [operator + (eq ? 'e' : '')]: offset } };
374
+ // null sort keys lie past the offset here, and no comparison ever matches them
375
+ if (nullable && !Utils.xor(nullsFirst, inverse)) {
376
+ return { $or: [condition, { [prop]: null }] };
377
+ }
378
+ return condition;
292
379
  };
293
380
  const [order, ...otherOrders] = definition;
294
381
  const [offset, ...otherOffsets] = offsets;
@@ -296,13 +383,18 @@ export class DatabaseDriver {
296
383
  if (!otherOrders.length) {
297
384
  return createCondition(prop, direction, offset);
298
385
  }
299
- return {
300
- ...createCondition(prop, direction, offset, true),
386
+ const atOrPast = createCondition(prop, direction, offset, true);
387
+ const past = {
301
388
  $or: [
302
389
  createCondition(prop, direction, offset),
303
- this.createCursorCondition(otherOrders, otherOffsets, inverse, meta),
390
+ this.createCursorCondition(otherOrders, otherOffsets, inverse, meta, fromJson),
304
391
  ],
305
392
  };
393
+ // the `at or past` prefix is itself a group condition when it compares against nulls
394
+ if ('$or' in atOrPast) {
395
+ return { $and: [atOrPast, past] };
396
+ }
397
+ return { ...atOrPast, ...past };
306
398
  }
307
399
  /** @internal */
308
400
  mapDataToFieldNames(data, stringifyJsonArrays, properties, convertCustomTypes, object) {
package/errors.d.ts CHANGED
@@ -41,6 +41,7 @@ export declare class ValidationError<T extends AnyEntity = AnyEntity> extends Er
41
41
  export declare class CursorError<T extends AnyEntity = AnyEntity> extends ValidationError<T> {
42
42
  static entityNotPopulated(entity: AnyEntity, prop: string): ValidationError;
43
43
  static missingValue(entityName: string, prop: string): ValidationError;
44
+ static invalidCursor(entityName: string, cause: Error): CursorError;
44
45
  }
45
46
  /** Error thrown when an optimistic lock conflict is detected during entity persistence. */
46
47
  export declare class OptimisticLockError<T extends AnyEntity = AnyEntity> extends ValidationError<T> {
package/errors.js CHANGED
@@ -140,6 +140,11 @@ export class CursorError extends ValidationError {
140
140
  static missingValue(entityName, prop) {
141
141
  return new CursorError(`Invalid cursor condition, value for '${entityName}.${prop}' is missing.`);
142
142
  }
143
+ static invalidCursor(entityName, cause) {
144
+ const error = new CursorError(`Invalid cursor for entity ${entityName}: ${cause.message}`);
145
+ error.cause = cause;
146
+ return error;
147
+ }
143
148
  }
144
149
  /** Error thrown when an optimistic lock conflict is detected during entity persistence. */
145
150
  export class OptimisticLockError extends ValidationError {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mikro-orm/core",
3
- "version": "7.2.0-dev.16",
3
+ "version": "7.2.0-dev.18",
4
4
  "description": "TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, PostgreSQL and SQLite databases as well as usage with vanilla JavaScript.",
5
5
  "keywords": [
6
6
  "data-mapper",
@@ -230,6 +230,8 @@ export declare abstract class Platform {
230
230
  convertsJsonAutomatically(): boolean;
231
231
  /** Whether date values inside JSON documents keep their native type (e.g. BSON dates), instead of being serialized to ISO strings. */
232
232
  preservesDatesInsideJson(): boolean;
233
+ /** Whether `nulls first`/`nulls last` can be requested in an `orderBy`. Platforms returning `false` always sort nulls as the lowest value. */
234
+ supportsNullsOrdering(): boolean;
233
235
  /** Converts a JS value to its JSON database representation (typically JSON.stringify). */
234
236
  convertJsonToDatabaseValue(value: unknown, context?: TransformContext): unknown;
235
237
  /** Converts a database JSON value to its JS representation. */
@@ -471,6 +471,10 @@ export class Platform {
471
471
  preservesDatesInsideJson() {
472
472
  return false;
473
473
  }
474
+ /** Whether `nulls first`/`nulls last` can be requested in an `orderBy`. Platforms returning `false` always sort nulls as the lowest value. */
475
+ supportsNullsOrdering() {
476
+ return true;
477
+ }
474
478
  /** Converts a JS value to its JSON database representation (typically JSON.stringify). */
475
479
  convertJsonToDatabaseValue(value, context) {
476
480
  return JSON.stringify(value);
@@ -11,6 +11,7 @@ export declare class BigIntType<Mode extends 'bigint' | 'number' | 'string' = 'b
11
11
  convertToDatabaseValue(value: JSTypeByMode<Mode> | null | undefined): string | null | undefined;
12
12
  convertToJSValue(value: string | bigint | null | undefined): JSTypeByMode<Mode> | null | undefined;
13
13
  toJSON(value: JSTypeByMode<Mode> | null | undefined): JSTypeByMode<Mode> | null | undefined;
14
+ fromJSON(value: unknown): JSTypeByMode<Mode> | null | undefined;
14
15
  getColumnType(prop: EntityProperty, platform: Platform): string;
15
16
  compareAsType(): string;
16
17
  compareValues(a: string, b: string): boolean;
@@ -1,4 +1,5 @@
1
1
  import { Type } from './Type.js';
2
+ import { ValidationError } from '../errors.js';
2
3
  /**
3
4
  * This type will automatically convert string values returned from the database to native JS bigints (default)
4
5
  * or numbers (safe only for values up to `Number.MAX_SAFE_INTEGER`), or strings, depending on the `mode`.
@@ -36,6 +37,28 @@ export class BigIntType extends Type {
36
37
  }
37
38
  return this.convertToDatabaseValue(value);
38
39
  }
40
+ fromJSON(value) {
41
+ // the serialized form is a decimal string, or a plain number in `number` mode
42
+ const valid = (typeof value === 'string' && /^-?\d+$/.test(value)) || (typeof value === 'number' && Number.isInteger(value));
43
+ if (!valid) {
44
+ throw ValidationError.invalidType(BigIntType, value, 'JSON');
45
+ }
46
+ switch (this.mode) {
47
+ case 'number': {
48
+ // `Number` silently rounds past `MAX_SAFE_INTEGER`, tampered cursors must fail loudly
49
+ const num = Number(value);
50
+ if (!Number.isSafeInteger(num)) {
51
+ throw ValidationError.invalidType(BigIntType, value, 'JSON');
52
+ }
53
+ return num;
54
+ }
55
+ case 'string':
56
+ return String(value);
57
+ case 'bigint':
58
+ default:
59
+ return BigInt(value);
60
+ }
61
+ }
39
62
  getColumnType(prop, platform) {
40
63
  return platform.getBigIntTypeDeclarationSQL(prop);
41
64
  }
@@ -5,6 +5,7 @@ import type { EntityProperty } from '../typings.js';
5
5
  export declare class DateTimeType extends Type<Date, string> {
6
6
  getColumnType(prop: EntityProperty, platform: Platform): string;
7
7
  compareAsType(): string;
8
+ fromJSON(value: unknown): Date;
8
9
  get runtimeType(): string;
9
10
  ensureComparable(): boolean;
10
11
  getDefaultLength(platform: Platform): number;
@@ -1,4 +1,5 @@
1
1
  import { Type } from './Type.js';
2
+ import { ValidationError } from '../errors.js';
2
3
  /** Maps a database DATETIME/TIMESTAMP column to a JS `Date` object. */
3
4
  export class DateTimeType extends Type {
4
5
  getColumnType(prop, platform) {
@@ -7,6 +8,13 @@ export class DateTimeType extends Type {
7
8
  compareAsType() {
8
9
  return 'Date';
9
10
  }
11
+ fromJSON(value) {
12
+ const date = new Date(value);
13
+ if (typeof value !== 'string' || Number.isNaN(date.getTime())) {
14
+ throw ValidationError.invalidType(DateTimeType, value, 'JSON');
15
+ }
16
+ return date;
17
+ }
10
18
  get runtimeType() {
11
19
  return 'Date';
12
20
  }
package/types/Type.d.ts CHANGED
@@ -59,6 +59,17 @@ export declare abstract class Type<JSType = string, DBType = JSType> {
59
59
  * By default uses the runtime value.
60
60
  */
61
61
  toJSON(value: JSType, platform: Platform): JSType | DBType;
62
+ /**
63
+ * Converts a value from its serialized JSON form back to its JS representation. Used when
64
+ * decoding cursor values. The input is what `toJSON` produced, after a `JSON.parse` round
65
+ * trip, and never an already restored JS value. Cursors are client supplied, so the value
66
+ * can be any JSON shape: validate it and throw for values the type cannot restore, and
67
+ * `findByCursor` surfaces the failure as a `CursorError`.
68
+ * Implementing this method also makes cursor encoding use `toJSON`. Without it, cursors
69
+ * carry the raw JS value, and decoding falls back to `convertToJSValue`, with type-based
70
+ * `Date` restoration for date-like columns.
71
+ */
72
+ fromJSON?(value: unknown, platform: Platform): JSType;
62
73
  /**
63
74
  * Gets the SQL declaration snippet for a field of this type.
64
75
  */
package/utils/Cursor.d.ts CHANGED
@@ -61,6 +61,8 @@ export declare class Cursor<Entity extends object, Hint extends string = never,
61
61
  * Computes the cursor value for a given entity.
62
62
  */
63
63
  from(entity: Entity | Loaded<Entity, Hint, Fields, Excludes>): string;
64
+ /** Serializes a single cursor value, walking nested directions and reading the owner's properties. */
65
+ private static serialize;
64
66
  [Symbol.iterator](): IterableIterator<Loaded<Entity, Hint, Fields, Excludes>>;
65
67
  get length(): number;
66
68
  /**
package/utils/Cursor.js CHANGED
@@ -58,6 +58,7 @@ export class Cursor {
58
58
  hasPrevPage;
59
59
  hasNextPage;
60
60
  #definition;
61
+ #meta;
61
62
  constructor(items, totalCount, options, meta) {
62
63
  this.items = items;
63
64
  this.totalCount = totalCount;
@@ -76,6 +77,7 @@ export class Cursor {
76
77
  }
77
78
  }
78
79
  this.#definition = Cursor.getDefinition(meta, orderBy);
80
+ this.#meta = meta;
79
81
  }
80
82
  get startCursor() {
81
83
  if (this.items.length === 0) {
@@ -93,37 +95,46 @@ export class Cursor {
93
95
  * Computes the cursor value for a given entity.
94
96
  */
95
97
  from(entity) {
96
- const processEntity = (entity, prop, direction, object = false) => {
97
- if (Utils.isPlainObject(direction)) {
98
- const unwrapped = Reference.unwrapReference(entity[prop]);
99
- // Check if the relation is loaded - for nested properties, undefined means not populated
100
- if (Utils.isEntity(unwrapped) && !helper(unwrapped).isInitialized()) {
101
- throw CursorError.entityNotPopulated(entity, prop);
102
- }
103
- return Utils.keys(direction).reduce((o, key) => {
104
- Object.assign(o, processEntity(unwrapped, key, direction[key], true));
105
- return o;
106
- }, {});
107
- }
108
- let value = entity[prop];
109
- // Allow null/undefined values in cursor - they will be handled in createCursorCondition
110
- // undefined can occur with forceUndefined config option which converts null to undefined
111
- if (value == null) {
112
- return object ? { [prop]: null } : null;
113
- }
114
- if (Utils.isEntity(value, true)) {
115
- value = helper(value).getPrimaryKey();
116
- }
117
- if (Utils.isScalarReference(value)) {
118
- value = value.unwrap();
98
+ const value = this.#definition.map(([key, direction]) => Cursor.serialize(this.#meta.properties, entity, key, direction));
99
+ return Cursor.encode(value);
100
+ }
101
+ /** Serializes a single cursor value, walking nested directions and reading the owner's properties. */
102
+ static serialize(properties, owner, key, direction) {
103
+ const prop = properties[key];
104
+ let value = owner[key];
105
+ if (Utils.isPlainObject(direction)) {
106
+ const unwrapped = Reference.unwrapReference(value);
107
+ // for nested properties, an uninitialized relation means not populated
108
+ if (Utils.isEntity(unwrapped) && !helper(unwrapped).isInitialized()) {
109
+ throw CursorError.entityNotPopulated(owner, key);
119
110
  }
120
- if (object) {
121
- return { [prop]: value };
111
+ if (unwrapped == null || typeof unwrapped !== 'object') {
112
+ return unwrapped;
122
113
  }
123
- return value;
124
- };
125
- const value = this.#definition.map(([key, direction]) => processEntity(entity, key, direction));
126
- return Cursor.encode(value);
114
+ const childProps = prop?.kind === ReferenceKind.EMBEDDED ? prop.embeddedProps : prop?.targetMeta?.properties;
115
+ return Utils.keys(direction).reduce((o, childKey) => {
116
+ o[childKey] = Cursor.serialize(childProps ?? {}, unwrapped, childKey, direction[childKey]);
117
+ return o;
118
+ }, {});
119
+ }
120
+ // allow null/undefined values in cursor - they will be handled in createCursorCondition
121
+ // undefined can occur with forceUndefined config option which converts null to undefined
122
+ if (value == null) {
123
+ return null;
124
+ }
125
+ if (Utils.isEntity(value, true)) {
126
+ value = helper(value).getPrimaryKey();
127
+ }
128
+ if (Utils.isScalarReference(value)) {
129
+ value = value.unwrap();
130
+ }
131
+ // only types implementing `fromJSON` own their wire format, others keep the raw JS value,
132
+ // so their cursors stay decodable by the `convertToJSValue` fallback
133
+ if (prop?.customType?.fromJSON) {
134
+ // the platform is assigned to the type instance during discovery
135
+ return prop.customType.toJSON(value, prop.customType.platform);
136
+ }
137
+ return value;
127
138
  }
128
139
  *[Symbol.iterator]() {
129
140
  for (const item of this.items) {
@@ -138,12 +149,11 @@ export class Cursor {
138
149
  */
139
150
  static for(meta, entity, orderBy) {
140
151
  const definition = this.getDefinition(meta, orderBy);
141
- return Cursor.encode(definition.map(([key]) => {
142
- const value = entity[key];
143
- if (value === undefined) {
152
+ return Cursor.encode(definition.map(([key, direction]) => {
153
+ if (entity[key] === undefined) {
144
154
  throw CursorError.missingValue(meta.className, key);
145
155
  }
146
- return value;
156
+ return this.serialize(meta.properties, entity, key, direction);
147
157
  }));
148
158
  }
149
159
  static encode(value) {
package/utils/Utils.js CHANGED
@@ -153,7 +153,7 @@ export function parseJsonSafe(value) {
153
153
  /** Collection of general-purpose utility methods used throughout the ORM. */
154
154
  export class Utils {
155
155
  static PK_SEPARATOR = '~~~';
156
- static #ORM_VERSION = '7.2.0-dev.16';
156
+ static #ORM_VERSION = '7.2.0-dev.18';
157
157
  /** Default session variable name backing an RLS filter argument (`current_setting('mikro.<filter>.<arg>')`). */
158
158
  static getRlsSettingName(filterName, argName) {
159
159
  return `mikro.${filterName}.${argName}`;