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 toCollection
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 ObjectId
s 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>`