Skip to content

Commit

Permalink
fix(core): validate missing populate hint for cursor based pagination…
Browse files Browse the repository at this point in the history
… on relation properties

Closes #5155
  • Loading branch information
B4nan committed Jan 24, 2024
1 parent 7108144 commit ea48db0
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 3 deletions.
8 changes: 8 additions & 0 deletions packages/core/src/errors.ts
Expand Up @@ -131,6 +131,14 @@ export class ValidationError<T extends AnyEntity = AnyEntity> extends Error {

}

export class CursorError<T extends AnyEntity = AnyEntity> extends ValidationError<T> {

static entityNotPopulated(entity: AnyEntity, prop: string): ValidationError {
return new CursorError(`Cannot create cursor, value for '${entity.constructor.name}.${prop}' is missing.`);
}

}

export class OptimisticLockError<T extends AnyEntity = AnyEntity> extends ValidationError<T> {

static notVersioned(meta: EntityMetadata): OptimisticLockError {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/typings.ts
Expand Up @@ -368,7 +368,12 @@ export type EntityDTOProp<E, T, C extends TypeConfig = never> = T extends Scalar
? EntityDTONested<T, C>
: T;

export type EntityDTO<T, C extends TypeConfig = never> = { [K in EntityKey<T> as ExcludeHidden<T, K>]: EntityDTOProp<T, T[K], C> };
// export type EntityDTO<T, C extends TypeConfig = never> = { [K in EntityKey<T> as ExcludeHidden<T, K>]: EntityDTOProp<T, T[K], C> };
export type EntityDTO<T, C extends TypeConfig = never> = {
[K in keyof T as RequiredKeys<T, K, never> & ExcludeHidden<T, K>]: EntityDTOProp<T, T[K], C>
} & {
[K in keyof T as OptionalKeys<T, K, never> & ExcludeHidden<T, K>]?: EntityDTOProp<T, T[K], C> | null
};

export type CheckCallback<T> = (columns: Record<keyof T, string>) => string;
export type GeneratedColumnCallback<T> = (columns: Record<keyof T, string>) => string;
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/utils/Cursor.ts
Expand Up @@ -4,7 +4,9 @@ import type { FindByCursorOptions, OrderDefinition } from '../drivers/IDatabaseD
import { Utils } from './Utils';
import { ReferenceKind, type QueryOrder, type QueryOrderKeys } from '../enums';
import { Reference } from '../entity/Reference';
import { helper } from '../entity/wrap';
import { RawQueryFragment } from '../utils/RawQueryFragment';
import { CursorError } from '../errors';

/**
* As an alternative to the offset-based pagination with `limit` and `offset`, we can paginate based on a cursor.
Expand Down Expand Up @@ -118,11 +120,21 @@ export class Cursor<
return ({ [prop]: value });
}

if (entity[prop] == null) {
throw CursorError.entityNotPopulated(entity, prop);
}

let value: unknown = entity[prop];

if (Utils.isEntity(value, true)) {
value = helper(value).getPrimaryKey();
}

if (object) {
return ({ [prop]: entity[prop] });
return ({ [prop]: value });
}

return entity[prop];
return value;
};
const value = this.definition.map(([key, direction]) => processEntity(entity as Entity, key, direction));
return Cursor.encode(value);
Expand Down
126 changes: 126 additions & 0 deletions tests/features/cursor-based-pagination/GH5155.test.ts
@@ -0,0 +1,126 @@
import { Entity, PrimaryKey, Property, ManyToOne, MikroORM, QueryOrder, Ref, ref } from '@mikro-orm/sqlite';

@Entity()
class Org {

@PrimaryKey()
id!: number;

@Property()
name: string;

constructor(name: string) {
this.name = name;
}

}

@Entity()
class User {

@PrimaryKey()
id!: number;

@Property()
name: string;

@Property({ unique: true })
email: string;

constructor(name: string, email: string) {
this.name = name;
this.email = email;
}

}

@Entity()
class OrgMembership {

@PrimaryKey()
id!: number;

@ManyToOne(() => User, { ref: true })
user: Ref<User>;

@ManyToOne(() => Org, { ref: true })
org: Ref<Org>;

constructor(user: User, org: Org) {
this.user = ref(user);
this.org = ref(org);
}

}

let orm: MikroORM;

beforeAll(async () => {
orm = await MikroORM.init({
dbName: ':memory:',
entities: [User, OrgMembership, Org],
});
await orm.schema.createSchema();

const org = orm.em.create(Org, { name: 'The Org' });
const user1 = orm.em.create(User, { name: 'Abc', email: 'foo' });
const user2 = orm.em.create(User, { name: 'Bar', email: 'bar' });
const user3 = orm.em.create(User, { name: 'Bar', email: 'bar2' });
const user4 = orm.em.create(User, { name: 'Baz', email: 'baz' });
orm.em.create(OrgMembership, { user: user1, org });
orm.em.create(OrgMembership, { user: user2, org });
orm.em.create(OrgMembership, { user: user3, org });
orm.em.create(OrgMembership, { user: user4, org });
await orm.em.flush();
orm.em.clear();
});

afterAll(async () => {
await orm.close(true);
});

test('validate missing populate hint', async () => {
const res = await orm.em.findByCursor(OrgMembership, {}, { orderBy: { user: { name: QueryOrder.ASC } }, first: 1 });
expect(() => res.startCursor).toThrow(`Cannot create cursor, value for 'User.name' is missing.`);
const goodCursor = await orm.em.findByCursor(OrgMembership, {}, {
orderBy: { user: { name: QueryOrder.ASC } },
populate: ['user'],
first: 1,
});

expect(goodCursor.endCursor).toBe('W3sidXNlciI6eyJuYW1lIjoiQWJjIn19XQ');
});

test('cursor from multiple order by clauses', async () => {
const cursor1 = await orm.em.findByCursor(OrgMembership, {}, {
populate: ['user'],
orderBy: [
{ user: { name: QueryOrder.ASC } },
{ user: 'asc' },
],
first: 1,
});
expect(cursor1.items[0].user.$.email).toBe('foo');

const cursor2 = await orm.em.findByCursor(OrgMembership, {}, {
populate: ['user'],
orderBy: [
{ user: { name: QueryOrder.ASC } },
{ user: 'asc' },
],
first: 1,
after: cursor1,
});
expect(cursor2.items[0].user.$.email).toBe('bar');

const cursor3 = await orm.em.findByCursor(OrgMembership, {}, {
populate: ['user'],
orderBy: [
{ user: { name: QueryOrder.ASC } },
{ user: 'asc' },
],
first: 1,
after: cursor2,
});
expect(cursor3.items[0].user.$.email).toBe('bar2');
});

0 comments on commit ea48db0

Please sign in to comment.