Skip to main content
Version: Next

Collections

OneToMany and ManyToMany properties are stored in a Collection wrapper.

Working with collections

The Collection class implements iterator, so we can use for of loop to iterate through it.

Another way to access collection items is to use bracket syntax like when we access array items. Keep in mind that this approach will not check if the collection is initialized, while using get method will throw error in this case.

Note that array access in Collection is available only for reading already loaded items, we cannot add new items to Collection this way.

To get all entities stored in a Collection, we can use getItems() method. It will throw in case the Collection is not initialized. If we want to disable this validation, we can use getItems(false). This will give us the entity instances managed by the identity map.

Alternatively we can use toArray() which will serialize the Collection to an array of DTOs. Modifying those will have no effect on the actual entity instances.

const author = em.findOne(Author, '...', { populate: ['books'] }); // populating books collection

// Or we could lazy load books collection later via `load()` method.
// Unlike `init()` it will check the state and do nothing if the collection is already initialized.
// await author.books.load();

for (const book of author.books) {
console.log(book.title); // initialized
console.log(book.author.isInitialized()); // true
console.log(book.author.id);
console.log(book.author.name); // Jon Snow
console.log(book.publisher); // just reference
console.log(book.publisher.isInitialized()); // false
console.log(book.publisher.id);
console.log(book.publisher.name); // undefined
}

// collection needs to be initialized before we can work with it
author.books.add(book);
console.log(author.books.contains(book)); // true
console.log(author.books.exists(item => item === book)); // true
console.log(author.books.find(item => item === book)); // book
console.log(author.books.map(item => item.title)); // array of book titles
console.log(author.books.filter(item => item.title.startsWith('Foo'))); // array of books matching the callback
author.books.remove(book);
console.log(author.books.contains(book)); // false
author.books.add(book);
console.log(author.books.count()); // 1
console.log(author.books.slice(0, 1)); // Book[]
console.log(author.books.slice()); // Book[]
console.log(author.books.slice().length); // 1
author.books.removeAll();
console.log(author.books.isEmpty()); // true
console.log(author.books.contains(book)); // false
console.log(author.books.count()); // 0
console.log(author.books.getItems()); // Book[]
console.log(author.books.getIdentifiers()); // array of string | number
console.log(author.books.getIdentifiers('_id')); // array of ObjectId

// array access works as well
console.log(author.books[1]); // Book
console.log(author.books[12345]); // undefined, even if the collection is not initialized

// getting array of the items
console.log(author.books.getItems()); // Book[]

// serializing the collection
console.log(author.books.toArray()); // EntityDTO<Book>[]

const author = 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[]

Removing items from collection

Removing items from a collection does not necessarily imply deleting the target entity, it means we are disconnecting the relation - removing items from collection, not removing entities from database - Collection.remove() is not the same as em.remove(). When you use em.assign() to update entities you can also remove/disconnect entities from a collection, they do not get automatically removed from the database. If we want to delete the entity by removing it from collection, we need to enable orphanRemoval: true, which tells the ORM we don't want orphaned entities to exist, so we know those should be removed. Also check the documentation on Orphan Removal

OneToMany Collections

OneToMany collections are inverse side of ManyToOne references, to which they need to point via fk attribute:

@Entity()
export class Book {

@PrimaryKey()
_id!: ObjectId;

@ManyToOne()
author!: Author;

}

@Entity()
export class Author {

@PrimaryKey()
_id!: ObjectId;

@OneToMany(() => Book, book => book.author)
books1 = new Collection<Book>(this);

// or via options object
@OneToMany({ entity: () => Book, mappedBy: 'author' })
books2 = new Collection<Book>(this);

}

ManyToMany Collections

For ManyToMany, SQL drivers use pivot table that holds reference to both entities.

As opposed to them, with MongoDB we do not need to have join tables for ManyToMany relations. All references are stored as an array of ObjectIds on owning entity.

Unidirectional

Unidirectional ManyToMany relations are defined only on one side, if we define only entity attribute, then it will be considered the owning side:

@ManyToMany(() => Book)
books1 = new Collection<Book>(this);

// or mark it as owner explicitly via options object
@ManyToMany({ entity: () => Book, owner: true })
books2 = new Collection<Book>(this);

Bidirectional

Bidirectional ManyToMany relations are defined on both sides, while one is owning side (where references are store), marked by inversedBy attribute pointing to the inverse side:

@ManyToMany(() => BookTag, tag => tag.books, { owner: true })
tags = new Collection<BookTag>(this);

// or via options object
@ManyToMany({ entity: () => BookTag, inversedBy: 'books' })
tags = new Collection<BookTag>(this);

And on the inversed side we define it with mappedBy attribute pointing back to the owner:

@ManyToMany(() => Book, book => book.tags)
books = new Collection<Book>(this);

// or via options object
@ManyToMany({ entity: () => Book, mappedBy: 'tags' })
books = new Collection<Book>(this);

Custom pivot table entity

By default, a generated pivot table entity is used under the hood to represent the pivot table. Since v5.1 we can provide our own implementation via pivotEntity option.

The pivot table entity needs to have exactly two many-to-one properties, where first one needs to point to the owning entity and the second to the target entity of the many-to-many relation.

@Entity()
export class Order {

@ManyToMany({ entity: () => Product, pivotEntity: () => OrderItem })
products = new Collection<Product>(this);

}

For bidirectional M:N relations, it is enough to specify the pivotEntity option only on the owning side. We still need to link the sides via inversedBy or mappedBy option.

@Entity()
export class Product {

@ManyToMany({ entity: () => Order, mappedBy: o => o.products })
orders = new Collection<Order>(this);

}

If we want to add new items to such M:N collection, we need to have all non-FK properties to define a database level default value.

@Entity()
export class OrderItem {

@ManyToOne({ primary: true })
order: Order;

@ManyToOne({ primary: true })
product: Product;

@Property({ default: 1 })
amount!: number;

}

Alternatively, we can work with the pivot entity directly:

// create new item
const item = em.create(OrderItem, {
order: 123,
product: 321,
amount: 999,
});
await em.persist(item).flush();

// or remove an item via delete query
const em.nativeDelete(OrderItem, { order: 123, product: 321 });

We can as well define the 1:m properties targeting the pivot entity as in the previous example, and use that for modifying the collection, while using the M:N property for easier reading and filtering purposes.

Forcing fixed order of collection items

Since v3 many to many collections does not require having auto increment primary key, that was used to ensure fixed order of collection items.

To preserve fixed order of collections, we can use fixedOrder: true attribute, which will start ordering by id column. Schema generator will convert the pivot table to have auto increment primary key id. We can also change the order column name via fixedOrderColumn: 'order'.

We can also specify default ordering via orderBy: { ... } attribute. This will be used when we fully populate the collection including its items, as it orders by the referenced entity properties instead of pivot table columns (which fixedOrderColumn is). On the other hand, fixedOrder is used to maintain the insert order of items instead of ordering by some property.

Populating references

Sometimes we might want to know only what items are part of a collection, and we don't care about the values of those items. For this, we can populate the collection only with references:

const book1 = await em.findOne(Book, 1, { populate: ['tags:ref'] });
console.log(book1.tags.isInitialized()); // true
console.log(wrap(book1.tags[0]).isInitialized()); // false

// or alternatively use `init({ ref: true })`
const book2 = await em.findOne(Book, 1);
await book2.tags.init({ ref: true });
console.log(book2.tags.isInitialized()); // true
console.log(wrap(book2.tags[0]).isInitialized()); // false

Propagation of Collection's add() and remove() operations

When we use one of Collection.add() method, the item is added to given collection, and this action is also propagated to its counterpart.

// one to many
const author = new Author(...);
const book = new Book(...);

author.books.add(book);
console.log(book.author); // author will be set thanks to the propagation

For M:N this works in both ways, either from owning side, or from inverse side.

// many to many works both from owning side and from inverse side
const book = new Book(...);
const tag = new BookTag(...);

book.tags.add(tag);
console.log(tag.books.contains(book)); // true

tag.books.add(book);
console.log(book.tags.contains(tag)); // true

Since v5.2.2 propagation of adding new items to inverse side M:N relation also works if the owning collection is not initialized. For propagation of remove operation, both sides still have to be initialized.

Although this propagation works also for M:N inverse side, we should always use owning side to manipulate the collection.

Same applies for Collection.remove().

Filtering and ordering of collection items

When initializing collection items via collection.init(), you can filter the collection as well as order its items:

await book.tags.init({
where: { active: true },
orderBy: { name: QueryOrder.DESC },
});

You should never modify partially loaded collections.

Declarative partial loading

Collections can also represent only a subset of the target entities:

@Entity()
class Author {

@OneToMany(() => Book, b => b.author)
books = new Collection<Book>(this);

@OneToMany(() => Book, b => b.author, { where: { favorite: true } })
favoriteBooks = new Collection<Book>(this);

}

This works also for M:N relations. Note that if you want to declare more relations mapping to the same pivot table, you need to explicitly specify its name (or use the same pivot entity):

@Entity()
class Book {

@ManyToMany(() => BookTag)
tags = new Collection<BookTag>(this);

@ManyToMany({
entity: () => BookTag,
pivotTable: 'book_tags',
where: { popular: true },
})
popularTags = new Collection<BookTag>(this);

}

Filtering Collections

Collections have a matching method that allows to slice parts of data from a collection. By default, it will return the list of entities based on the query. We can use the store boolean parameter to save this list into the collection items - this will mark the collection as readonly, methods like add or remove will throw.

const a = await em.findOneOrFail(Author, 1);

// only loading the list of items
const books = await a.books.matching({ limit: 3, offset: 10, orderBy: { title: 'asc' } });
console.log(books); // [Book, Book, Book]
console.log(a.books.isInitialized()); // false

// storing the items in collection
const tags = await books[0].tags.matching({
limit: 3,
offset: 5,
orderBy: { name: 'asc' },
store: true,
});
console.log(tags); // [BookTag, BookTag, BookTag]
console.log(books[0].tags.isInitialized()); // true
console.log(books[0].tags.getItems()); // [BookTag, BookTag, BookTag]

Mapping Collection items

The Collection class offers several handy helper methods to filter, map, or convert the collection items.

indexBy

When you want to convert the collection to a simple key-value dictionary, use the indexBy() method:

// given `user.settings` is `Collection<Option>`
const settingsDictionary = user.settings.indexBy('key');
// `settingsDictionary` is `Record<string, Option>`

The second argument lets you map to property values instead of the target entity:

const settingsDictionary = user.settings.indexBy('key', 'value');
// `settingsDictionary` is `Record<string, string>`