@mikro-orm/core
Version:
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.
189 lines (188 loc) • 7.04 kB
JavaScript
import { Utils } from './Utils.js';
import { ReferenceKind } from '../enums.js';
import { Reference } from '../entity/Reference.js';
import { helper } from '../entity/wrap.js';
import { Raw } from '../utils/RawQueryFragment.js';
import { CursorError } from '../errors.js';
import { inspect } from '../logging/inspect.js';
/**
* As an alternative to the offset-based pagination with `limit` and `offset`, we can paginate based on a cursor.
* A cursor is an opaque string that defines a specific place in ordered entity graph. You can use `em.findByCursor()`
* to access those options. Under the hood, it will call `em.find()` and `em.count()` just like the `em.findAndCount()`
* method, but will use the cursor options instead.
*
* Supports `before`, `after`, `first` and `last` options while disallowing `limit` and `offset`. Explicit `orderBy` option is required.
*
* Use `first` and `after` for forward pagination, or `last` and `before` for backward pagination.
*
* - `first` and `last` are numbers and serve as an alternative to `offset`, those options are mutually exclusive, use only one at a time
* - `before` and `after` specify the previous cursor value
*
* ```ts
* const currentCursor = await em.findByCursor(User, {}, {
* first: 10,
* after: previousCursor, // can be either string or `Cursor` instance
* orderBy: { id: 'desc' },
* });
*
* // to fetch next page
* const nextCursor = await em.findByCursor(User, {}, {
* first: 10,
* after: currentCursor.endCursor, // or currentCursor.endCursor
* orderBy: { id: 'desc' },
* });
* ```
*
* The `Cursor` object provides the following interface:
*
* ```ts
* Cursor<User> {
* items: [
* User { ... },
* User { ... },
* User { ... },
* ...
* ],
* totalCount: 50,
* length: 10,
* startCursor: 'WzRd',
* endCursor: 'WzZd',
* hasPrevPage: true,
* hasNextPage: true,
* }
* ```
*/
export class Cursor {
items;
totalCount;
hasPrevPage;
hasNextPage;
#definition;
constructor(items, totalCount, options, meta) {
this.items = items;
this.totalCount = totalCount;
const { first, last, before, after, orderBy, overfetch } = options;
const limit = first ?? last;
const isLast = !first && !!last;
const hasMorePages = !!overfetch && limit != null && items.length > limit;
this.hasPrevPage = isLast ? hasMorePages : !!after;
this.hasNextPage = isLast ? !!before : hasMorePages;
if (hasMorePages) {
if (isLast) {
items.shift();
}
else {
items.pop();
}
}
this.#definition = Cursor.getDefinition(meta, orderBy);
}
get startCursor() {
if (this.items.length === 0) {
return null;
}
return this.from(this.items[0]);
}
get endCursor() {
if (this.items.length === 0) {
return null;
}
return this.from(this.items[this.items.length - 1]);
}
/**
* Computes the cursor value for a given entity.
*/
from(entity) {
const processEntity = (entity, prop, direction, object = false) => {
if (Utils.isPlainObject(direction)) {
const unwrapped = Reference.unwrapReference(entity[prop]);
// Check if the relation is loaded - for nested properties, undefined means not populated
if (Utils.isEntity(unwrapped) && !helper(unwrapped).isInitialized()) {
throw CursorError.entityNotPopulated(entity, prop);
}
return Utils.keys(direction).reduce((o, key) => {
Object.assign(o, processEntity(unwrapped, key, direction[key], true));
return o;
}, {});
}
let value = entity[prop];
// Allow null/undefined values in cursor - they will be handled in createCursorCondition
// undefined can occur with forceUndefined config option which converts null to undefined
if (value == null) {
return object ? { [prop]: null } : null;
}
if (Utils.isEntity(value, true)) {
value = helper(value).getPrimaryKey();
}
if (Utils.isScalarReference(value)) {
value = value.unwrap();
}
if (object) {
return { [prop]: value };
}
return value;
};
const value = this.#definition.map(([key, direction]) => processEntity(entity, key, direction));
return Cursor.encode(value);
}
*[Symbol.iterator]() {
for (const item of this.items) {
yield item;
}
}
get length() {
return this.items.length;
}
/**
* Computes the cursor value for given entity and order definition.
*/
static for(meta, entity, orderBy) {
const definition = this.getDefinition(meta, orderBy);
return Cursor.encode(definition.map(([key]) => {
const value = entity[key];
if (value === undefined) {
throw CursorError.missingValue(meta.className, key);
}
return value;
}));
}
static encode(value) {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
static decode(value) {
return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')).map((value) => {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}/.exec(value)) {
return new Date(value);
}
return value;
});
}
static getDefinition(meta, orderBy) {
return Utils.asArray(orderBy).flatMap(order => {
const ret = [];
for (const key of Utils.getObjectQueryKeys(order)) {
if (Raw.isKnownFragmentSymbol(key)) {
ret.push([key, order[key]]);
continue;
}
const prop = meta.properties[key];
/* v8 ignore next */
if (!prop ||
!([ReferenceKind.SCALAR, ReferenceKind.EMBEDDED, ReferenceKind.MANY_TO_ONE].includes(prop.kind) ||
(prop.kind === ReferenceKind.ONE_TO_ONE && prop.owner))) {
continue;
}
ret.push([prop.name, order[prop.name]]);
}
return ret;
});
}
/** @ignore */
/* v8 ignore next */
[Symbol.for('nodejs.util.inspect.custom')]() {
const type = this.items[0]?.constructor.name;
const { items, startCursor, endCursor, hasPrevPage, hasNextPage, totalCount, length } = this;
const options = inspect({ startCursor, endCursor, totalCount, hasPrevPage, hasNextPage, items, length }, { depth: 0 });
return `Cursor${type ? `<${type}>` : ''} ${options.replace('items: [Array]', 'items: [...]')}`;
}
}