src/app/core/entity/entity-mapper/entity-mapper.service.ts
One page of entities, and the cursor to continue from.
The cursor is opaque: a Mango bookmark from the database, or whatever a paginated loader uses to mark its own position. Callers only pass it back.
Properties |
| bookmark |
bookmark:
|
Type : string
|
| Optional |
| records |
records:
|
Type : T[]
|
import { inject, Injectable } from "@angular/core";
import { Entity, EntityConstructor } from "../model/entity";
import { EntitySchemaService } from "../schema/entity-schema.service";
import { Observable } from "rxjs";
import { UpdatedEntity } from "../model/entity-update";
import { EntityRegistry } from "../database-entity.decorator";
import { filter, map } from "rxjs/operators";
import { UpdateMetadata } from "../model/update-metadata";
import { CurrentUserSubject } from "../../session/current-user-subject";
import { DatabaseResolverService } from "../../database/database-resolver.service";
import { DatabaseDocChange } from "../../database/database";
import { EntityAbility } from "../../permissions/ability/entity-ability";
import { EntityPermissionError } from "./entity-permission-error";
import { Logging } from "../../logging/logging.service";
import { EntityActionPermission } from "../../permissions/permission-types";
import { DataFilter } from "#src/app/core/filter/filters/filters";
/**
* One page of entities, and the cursor to continue from.
*
* The cursor is opaque: a Mango bookmark from the database, or whatever a
* paginated loader uses to mark its own position. Callers only pass it back.
*/
export interface EntityPage<T extends Entity> {
records: T[];
bookmark?: string;
}
/**
* Handles loading and saving of data for any higher-level feature module.
* The EntityMapperService implicitly transforms objects from instances of Entity classes to the format to be written
* to the database and back - ensuring they you always receive instances of {@link Entity} subclasses, that you can
* simply treat them as normal javascript class instances without worrying about database persistance logic.
*
* To understand more about how to use the Entity system in your own modules, refer to the developer documentation:
* - [Loading and Saving Data]{@link /additional-documentation/concepts/entity-system.html}
* - [How to Create a new Entity Type]{@link /additional-documentation/how-to-guides/create-a-new-entity-type.html}
*/
@Injectable({ providedIn: "root" })
export class EntityMapperService {
private dbResolver = inject(DatabaseResolverService);
private entitySchemaService = inject(EntitySchemaService);
private currentUser = inject(CurrentUserSubject);
private registry = inject(EntityRegistry);
private readonly ability = inject(EntityAbility, { optional: true });
/**
* Load an Entity from the database with the given id or the registered name of that class.
*
* @param entityType Class that implements Entity, which is the type of Entity the results should be transformed to
* @param id The id of the entity to load
* @returns A Promise resolving to an instance of entityType filled with its data.
*/
public async load<T extends Entity>(
entityType: EntityConstructor<T> | string,
id: string,
): Promise<T> {
const ctor = this.resolveConstructor(entityType);
const entityId = Entity.createPrefixedId(ctor.ENTITY_TYPE, id);
const result = await this.dbResolver
.getDatabase(ctor.DATABASE)
.get(entityId);
return this.transformToEntityFormat(result, ctor);
}
/**
* Load all entities from the database of the given type (for example a list of entities of the type User).
* <em>Important:</em> Loading via the constructor is always preferred compared to loading via string. The latter
* doesn't allow strict type-checking and errors can only be discovered later
*
* @param entityType Class that implements Entity, which is the type of Entity the results should be transformed to
* or the registered name of that class.
* @returns A Promise resolving to an array of instances of entityType with the data of the loaded entities.
*/
public async loadType<T extends Entity>(
entityType: EntityConstructor<T> | string,
): Promise<T[]> {
const ctor = this.resolveConstructor(entityType);
const records = await this.dbResolver
.getDatabase(ctor.DATABASE)
.getAll(ctor.ENTITY_TYPE + ":");
return records.map((rec) => this.transformToEntityFormat(rec, ctor));
}
/**
* Similar to loadType but allows supports filtering, pagination and sorting.
*
* Pagination uses a `bookmark` cursor (see {@link Database.find}): pass the
* `bookmark` returned by the previous call to continue after those results.
* It is forward-only - there is no way to jump back to an earlier page.
*
* @param entityType class for which results should be returned
* @param filter a valid Mango Query Syntax query
* @param page optional pagination options
* @param sort optional sort options
*/
public async findType<T extends Entity>(
entityType: EntityConstructor<T> | string,
filter: DataFilter<T>,
page?: { limit: number; bookmark?: string },
sort?: { prop?: string; dir?: "asc" | "desc" },
): Promise<EntityPage<T>> {
const ctor = this.resolveConstructor(entityType);
const result = await this.dbResolver
.getDatabase(ctor.DATABASE)
.find(ctor.ENTITY_TYPE, filter, page, sort);
return {
records: result.docs.map((rec) =>
this.transformToEntityFormat(rec, ctor),
),
bookmark: result.bookmark,
};
}
/**
* Reconstruct an Entity instance from a raw serialized document (e.g. from a JSON export).
* The entity type is detected from the `_id` prefix of the document.
*/
public entityFromRawDoc(doc: Record<string, unknown>): Entity {
const typePrefix = (doc["_id"] as string)?.split(":")[0];
const ctor = this.registry.get(typePrefix);
return this.transformToEntityFormat(doc, ctor);
}
private transformToEntityFormat<T extends Entity>(
record: any,
ctor: EntityConstructor<T>,
): T {
const entity = new ctor("");
try {
this.entitySchemaService.loadDataIntoEntity(entity, record);
} catch (e) {
// add _id information to error message
e.message = `Could not transform entity "${record._id}": ${e.message}`;
throw e;
}
return entity;
}
/**
* subscribe to this observable to receive updates whenever the state of
* an entity of a certain type changes.
* The updated-parameter will return the new entity as well as a field that
* describes the type of update (either "new", "update" or "remove").
* <br>
* This can be used in collaboration with the update(UpdatedEntity, Entities)-function
* to update a list of entities
* <br>
*
* <em>Important:</em> Loading via the constructor is always preferred compared to loading via string. The latter
* doesn't allow strict type-checking and errors can only be discovered later
* @param entityType the type of the entity or the registered name of that class.
*/
public receiveUpdates<T extends Entity>(
entityType: EntityConstructor<T> | string,
): Observable<UpdatedEntity<T>> {
const ctor = this.resolveConstructor(entityType);
const type = new ctor().getType();
return this.dbResolver.changesFeed.pipe(
filter((change) => change?._id.startsWith(type + ":")),
map((doc: DatabaseDocChange) => {
const entity = new ctor();
this.entitySchemaService.loadDataIntoEntity(entity, doc);
if (doc._deleted) {
return { type: "remove", entity: entity };
} else if (doc._rev.startsWith("1-")) {
// This does not cover all the cases as docs with higher rev-number might be synchronized for the first time
return { type: "new", entity: entity };
} else {
return { type: "update", entity: entity };
}
}),
);
}
/**
* Save an entity to the database after transforming it to its database representation.
* @param entity The entity to be saved
* @param forceUpdate Optional flag whether any conflicting version in the database will be quietly overwritten.
* if a conflict occurs without the forceUpdate flag being set, the save will fail, rejecting the returned promise.
*/
public async save<T extends Entity>(
entity: T,
forceUpdate: boolean = false,
): Promise<any> {
this.assertPermission(entity);
this.setEntityMetadata(entity);
const rawData =
this.entitySchemaService.transformEntityToDatabaseFormat(entity);
const result = await this.dbResolver
.getDatabase(entity.getConstructor().DATABASE)
.put(rawData, forceUpdate);
if (result?.ok) {
entity._rev = result.rev;
}
return result;
}
/**
* Saves an array of entities that are possibly heterogeneous, i.e.
* the entity-type of all the entities does not have to be the same.
* This method should be chosen whenever a bigger number of entities needs to be
* saved
* @param entities The entities to save
* @param forceUpdate Optional flag whether any conflicting version in the database will be quietly overwritten.
* if a conflict occurs without the forceUpdate flag being set, the save will fail, rejecting the returned promise.
*/
public async saveAll(
entities: Entity[],
forceUpdate: boolean = false,
): Promise<any[]> {
if (!entities.length) {
return [];
}
entities.forEach((e) => this.assertPermission(e));
entities.forEach((e) => this.setEntityMetadata(e));
const entitiesByDatabase = new Map<string, Entity[]>();
entities.forEach((e) => {
const databaseName = e.getConstructor().DATABASE;
if (!entitiesByDatabase.has(databaseName)) {
entitiesByDatabase.set(databaseName, []);
}
entitiesByDatabase.get(databaseName).push(e);
});
const savePromises = Array.from(entitiesByDatabase.entries()).map(
([databaseName, entitiesInDatabase]) => {
const rawData = entitiesInDatabase.map((e) =>
this.entitySchemaService.transformEntityToDatabaseFormat(e),
);
return this.dbResolver
.getDatabase(databaseName)
.putAll(rawData, forceUpdate);
},
);
const results = (await Promise.all(savePromises)).flat();
results.forEach((res, idx) => {
if (res.ok) {
const entity = entities[idx];
entity._rev = res.rev;
}
});
return results;
}
/**
* Delete an entity from the database.
* @param entity The entity to be deleted
*/
public async remove<T extends Entity>(entity: T): Promise<any> {
this.assertPermission(entity, "delete");
return this.dbResolver
.getDatabase(entity.getConstructor().DATABASE)
.remove(entity);
}
protected resolveConstructor<T extends Entity>(
constructible: EntityConstructor<T> | string,
): EntityConstructor<T> | undefined {
if (typeof constructible === "string") {
return this.registry.get(constructible) as EntityConstructor<T>;
} else {
return constructible;
}
}
private assertPermission(entity: Entity, action?: EntityActionPermission) {
if (!this.ability) {
return;
}
if (!this.ability.initialized) {
// known race during app startup; the server still enforces permissions
Logging.debug(
"Permission check skipped: ability not yet initialized",
entity.getId(),
);
return;
}
const checkedAction = action ?? (entity.isNew ? "create" : "update");
if (this.ability.cannot(checkedAction, entity)) {
throw new EntityPermissionError(
checkedAction,
entity.getId(),
entity.getType(),
);
}
}
protected setEntityMetadata(entity: Entity) {
const currentUserId = this.currentUser.value?.getId();
// allow PublicForm to inject a special created.by already beforehand
const metadataBy =
currentUserId ?? (entity.isNew ? entity.created?.by : undefined);
const newMetadata = new UpdateMetadata(metadataBy);
if (entity.isNew) {
entity.created = newMetadata;
}
entity.updated = newMetadata;
}
}