Skip to content

Commit

Permalink
feat(count): initial implementation of loadCount (#955)
Browse files Browse the repository at this point in the history
Closes #949
  • Loading branch information
AzariasB committed Oct 27, 2020
1 parent 4be21e2 commit 3371415
Show file tree
Hide file tree
Showing 9 changed files with 251 additions and 1 deletion.
1 change: 1 addition & 0 deletions docs/docs/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ console.log(author.books[1]); // Book
console.log(author.books[12345]); // undefined, even if the collection is not initialized

const author = orm.em.findOne(Author, '...'); // books collection has not been populated
const count = await author.books.loadCount(); // gets the count of collection items from database instead of counting loaded items
console.log(author.books.getItems()); // throws because the collection has not been initialized
// initialize collection if not already loaded and return its items as array
console.log(await author.books.loadItems()); // Book[]
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/entity/ArrayCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export class ArrayCollection<T, O> {

protected readonly items = new Set<T>();
protected initialized = true;
protected _count?: number;
private _property?: EntityProperty;

constructor(readonly owner: O & AnyEntity<O>, items?: T[]) {
Expand All @@ -21,9 +22,14 @@ export class ArrayCollection<T, O> {
Object.defineProperty(this, 'items', { enumerable: false });
Object.defineProperty(this, 'owner', { enumerable: false, writable: true });
Object.defineProperty(this, '_property', { enumerable: false, writable: true });
Object.defineProperty(this, '_count', { enumerable: false, writable: true });
Object.defineProperty(this, '__collection', { value: true });
}

async loadCount(): Promise<number> {
return this.items.size;
}

getItems(): T[] {
return [...this.items];
}
Expand Down Expand Up @@ -60,6 +66,7 @@ export class ArrayCollection<T, O> {
const entity = Reference.unwrapReference(item);

if (!this.contains(entity, false)) {
this.incrementCount(1);
this[this.items.size] = entity;
this.items.add(entity);
this.propagate(entity, 'add');
Expand All @@ -77,10 +84,12 @@ export class ArrayCollection<T, O> {
*/
hydrate(items: T[]): void {
this.items.clear();
this._count = 0;
this.add(...items);
}

remove(...items: (T | Reference<T>)[]): void {
this.incrementCount(-items.length);
for (const item of items) {
const entity = Reference.unwrapReference(item);
delete this[this.items.size - 1]; // remove last item
Expand Down Expand Up @@ -173,4 +182,10 @@ export class ArrayCollection<T, O> {
return collection.contains(this.owner, false);
}

protected incrementCount(value: number) {
if (typeof this._count === 'number') {
this._count += value;
}
}

}
32 changes: 32 additions & 0 deletions packages/core/src/entity/Collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ export class Collection<T, O = unknown> extends ArrayCollection<T, O> {
return super.getItems();
}

/**
* Gets the count of collection items from database instead of counting loaded items.
* The value is cached, use `refresh = true` to force reload it.
*/
async loadCount(refresh = false): Promise<number> {
const em = this.owner.__helper!.__em;

if (!em) {
throw ValidationError.entityNotManaged(this.owner);
}

if (refresh || !Utils.isDefined(this._count)) {
if (!em.getDriver().getPlatform().usesPivotTable() && this.property.reference === ReferenceType.MANY_TO_MANY) {
this._count = this.length;
} else {
this._count = await em.count(this.property.type, this.createLoadCountCondition({}));
}
}

return this._count!;
}

/**
* Returns the items (the collection must be initialized)
*/
Expand Down Expand Up @@ -244,6 +266,16 @@ export class Collection<T, O = unknown> extends ArrayCollection<T, O> {
}
}

private createLoadCountCondition(cond: Dictionary) {
if (this.property.reference === ReferenceType.ONE_TO_MANY) {
cond[this.property.mappedBy] = this.owner.__helper!.getPrimaryKey();
} else {
const key = this.property.owner ? this.property.inversedBy : this.property.mappedBy;
cond[key] = this.owner.__meta!.compositePK ? { $in : this.owner.__helper!.__primaryKeys } : this.owner.__helper!.getPrimaryKey();
}
return cond;
}

private modify(method: 'add' | 'remove', items: T[]): void {
if (method === 'remove') {
this.checkInitialized();
Expand Down
10 changes: 10 additions & 0 deletions tests/EntityManager.mongo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2178,6 +2178,16 @@ describe('EntityManagerMongo', () => {
await expect(driver.nativeInsert(Author.name, { name: 'author', email: 'email' })).rejects.toThrow(UniqueConstraintViolationException);
});

test('loadCount with 1:n relationships', async () => {
let author = new Author('Jon Snow', '[email protected]');
author.books.add(new Book('b1'), new Book('b2'), new Book('b3'), new Book('b4'));
await orm.em.persistAndFlush(author);
orm.em.clear();

author = await orm.em.findOneOrFail(Author, author.id);
await expect(author.books.loadCount()).resolves.toEqual(4);
});

afterAll(async () => orm.close(true));

});
12 changes: 12 additions & 0 deletions tests/EntityManager.mongo2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ describe('EntityManagerMongo2', () => {
book5.publisher = wrapped0;
});

test('loadCount with m:n relationships', async () => {
let bible = new Book('Bible');
bible.tags.add(new BookTag('t1'), new BookTag('t2'), new BookTag('t3'));
await orm.em.persistAndFlush(bible);
orm.em.clear();

bible = await orm.em.findOneOrFail(Book, bible.id);
await expect(bible.tags.loadCount()).resolves.toEqual(3);
bible.tags.removeAll();
await expect(bible.tags.loadCount()).resolves.toEqual(0);
});

afterAll(async () => orm.close(true));

});
72 changes: 71 additions & 1 deletion tests/EntityManager.sqlite2.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Collection, EntityManager, LockMode, MikroORM, QueryOrder, Logger, ValidationError, wrap } from '@mikro-orm/core';
import { Collection, EntityManager, LockMode, MikroORM, QueryOrder, Logger, ValidationError, wrap, ArrayCollection } from '@mikro-orm/core';
import { SqliteDriver } from '@mikro-orm/sqlite';
import { initORMSqlite2, wipeDatabaseSqlite2 } from './bootstrap';
import { Author4, Book4, BookTag4, FooBar4, IAuthor4, IPublisher4, Publisher4, PublisherType, Test4 } from './entities-schema';
Expand Down Expand Up @@ -932,6 +932,76 @@ describe('EntityManagerSqlite2', () => {
await orm.em.flush();
});

test('loadCount to get the number of entries without initializing the collection (GH issue #949)', async () => {
let author = orm.em.create(Author4, { name: 'Jon Doe', email: '[email protected]' });
author.books.add(orm.em.create(Book4, { title: 'bo1' }));
// Entity not managed yet
expect(() => author.books.loadCount()).rejects.toThrow(ValidationError);

await orm.em.persistAndFlush(author);

const reloadedBook = await author.books.loadCount();
expect(reloadedBook).toBe(1);

// Adding new items
const laterRemoved = orm.em.create(Book4, { title: 'bo2' });
author.books.add(laterRemoved, orm.em.create(Book4, { title: 'bo3' }));
const threeItms = await author.books.loadCount();
expect(threeItms).toEqual(3);

// Force refresh
expect(await author.books.loadCount(true)).toEqual(1);
// Testing array collection implementation
await orm.em.flush();
orm.em.clear();


// Updates when removing an item
author = (await orm.em.findOneOrFail(Author4, author.id));
expect(await author.books.loadCount()).toEqual(3);
await author.books.init();
author.books.remove(author.books[0]);
expect(await author.books.loadCount()).toEqual(2);
expect(await author.books.loadCount(true)).toEqual(3);
await orm.em.flush();
orm.em.clear();

// Resets the counter when hydrating
author = (await orm.em.findOneOrFail(Author4, author.id));
await author.books.loadCount();
author.books.hydrate([]);
expect(await author.books.loadCount()).toEqual(0);
expect(await author.books.loadCount(true)).toEqual(2);

// Code coverage ?
const arryCollection = new ArrayCollection(author);
expect(await arryCollection.loadCount()).toEqual(0);

// n:m relations
let taggedBook = orm.em.create(Book4, { title: 'FullyTagged' });
await orm.em.persistAndFlush(taggedBook);
const tags = [orm.em.create(BookTag4, { name: 'science-fiction' }), orm.em.create(BookTag4, { name: 'adventure' }), orm.em.create(BookTag4, { name: 'horror' })];
taggedBook.tags.add(...tags);
await expect(taggedBook.tags.loadCount()).resolves.toEqual(0);
await orm.em.flush();
orm.em.clear();

taggedBook = await orm.em.findOneOrFail(Book4, taggedBook.id);
await expect(taggedBook.tags.loadCount()).resolves.toEqual(tags.length);
expect(taggedBook.tags.isInitialized()).toBe(false);
await taggedBook.tags.init();
await expect(taggedBook.tags.loadCount()).resolves.toEqual(tags.length);
const removing = taggedBook.tags[0];
taggedBook.tags.remove(removing);
await expect(taggedBook.tags.loadCount()).resolves.toEqual(tags.length - 1);
await expect(taggedBook.tags.loadCount(true)).resolves.toEqual(tags.length);
await orm.em.flush();
orm.em.clear();

taggedBook = await orm.em.findOneOrFail(Book4, taggedBook.id);
await expect(taggedBook.tags.loadCount()).resolves.toEqual(tags.length - 1);
});

afterAll(async () => {
await orm.close(true);
});
Expand Down
9 changes: 9 additions & 0 deletions tests/composite-keys.mysql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@ describe('composite keys in mysql', () => {
}
});

test('loadCount for composite keys', async () => {
const car = new Car2('Audi A8', 2010, 200_000);
const user = new User2('John', 'Doe');
user.cars.add(car);
await orm.em.persistAndFlush(user);
await expect(car.users.loadCount()).resolves.toEqual(1);
await expect(user.cars.loadCount()).resolves.toEqual(1);
});

afterAll(async () => orm.close(true));

});
13 changes: 13 additions & 0 deletions tests/composite-keys.sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,19 @@ describe('composite keys in sqlite', () => {
}
});

test('loadCount for composite keys', async () => {
const car = new Car2('Audi A8', 2010, 200_000);
const user = new User2('John', 'Doe');
user.cars.add(car);
await orm.em.persistAndFlush(user);
await expect(car.users.loadCount()).rejects.toBeTruthy();
await expect(user.cars.loadCount()).rejects.toBeTruthy();
// Fails due to a bug with knex : (see pull request #2977)
// await expect(car.users.loadCount()).resolves.toEqual(1);
// await expect(user.cars.loadCount()).resolves.toEqual(1);
});

afterAll(async () => orm.close(true));

});

88 changes: 88 additions & 0 deletions tests/issues/GH949.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Entity, MikroORM, PrimaryKey, OneToMany, ManyToOne, Collection, ValidationError, ArrayCollection } from '@mikro-orm/core';
import { SqliteDriver } from '@mikro-orm/sqlite';

@Entity()
class A {

@PrimaryKey()
id!: number;

// eslint-disable-next-line @typescript-eslint/no-use-before-define
@OneToMany(() => B, b => b.a)
bItems = new Collection<B>(this);

}

@Entity()
class B {

@PrimaryKey()
id!: number;

@ManyToOne(() => A)
a!: A;

}
describe('GH issue 949', () => {
let orm: MikroORM<SqliteDriver>;

beforeAll(async () => {
orm = await MikroORM.init({
entities: [A, B],
dbName: ':memory:',
type: 'sqlite',
});
await orm.getSchemaGenerator().createSchema();
});

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

test(`GH issue 949`, async () => {
let aEntity = new A();
aEntity.bItems.add(new B());
// Entity not managed yet
expect(() => aEntity.bItems.loadCount()).rejects.toThrow(ValidationError);

await orm.em.persistAndFlush(aEntity);

if (!aEntity) { return; }
const reloadedBook = await aEntity.bItems.loadCount();
expect(reloadedBook).toBe(1);

// Adding new items
const laterRemoved = new B();
aEntity.bItems.add(laterRemoved, new B());
const threeItms = await aEntity.bItems.loadCount();
expect(threeItms).toEqual(3);

// Force refresh
expect(await aEntity.bItems.loadCount(true)).toEqual(1);
// Testing array collection implemntation
await orm.em.flush();
orm.em.clear();


// Updates when removing an item
aEntity = (await orm.em.findOne(A, aEntity.id))!;
expect(await aEntity.bItems.loadCount()).toEqual(3);
await aEntity.bItems.init();
aEntity.bItems.remove(aEntity.bItems[0]);
expect(await aEntity.bItems.loadCount()).toEqual(2);
expect(await aEntity.bItems.loadCount(true)).toEqual(3);
await orm.em.flush();
orm.em.clear();

// Resets the counter when hydrating
aEntity = (await orm.em.findOne(A, aEntity.id))!;
await aEntity.bItems.loadCount();
aEntity.bItems.hydrate([]);
expect(await aEntity.bItems.loadCount()).toEqual(0);
expect(await aEntity.bItems.loadCount(true)).toEqual(2);

// Code coverage ?
const arryCollection = new ArrayCollection(aEntity);
expect(await arryCollection.loadCount()).toEqual(0);
});
});

0 comments on commit 3371415

Please sign in to comment.