import type { Query as PiniaOrmQuery, Repository, Collection as PiniaOrmCollection, } from 'pinia-orm' import { v4 as uuid4 } from 'uuid' import * as _ from 'lodash-es' import { computed, type ComputedRef } from 'vue' import type ApiRequestService from './apiRequestService' import UrlUtils from '~/services/utils/urlUtils' import type ApiModel from '~/models/ApiModel' import type ApiResource from '~/models/ApiResource' import type { AnyJson, AssociativeArray, Collection } from '~/types/data.d' import modelsIndex from '~/models/models' import HydraNormalizer from '~/services/data/normalizer/hydraNormalizer' import ObjectUtils from '~/services/utils/objectUtils' import type Query from '~/services/data/Query' /** * Entity manager: make operations on the models defined with the Pinia-Orm library * * TODO: améliorer le typage des méthodes sur le modèle de find() * @see https://pinia-orm.codedredd.de/ */ class EntityManager { protected CLONE_PREFIX = '_clone_' /** * In instance of ApiRequestService * @protected */ protected apiRequestService: ApiRequestService /** * A method to retrieve the repository of a given ApiResource * @protected */ protected _getRepo: ( model: T, ) => Repository> protected _getProfileMask: () => object public constructor( apiRequestService: ApiRequestService, getRepo: ( model: T, ) => Repository>, getProfileMask: () => object, ) { this.apiRequestService = apiRequestService this._getRepo = getRepo this._getProfileMask = getProfileMask } /** * Return the repository for the model * * @param model */ public getRepository( model: T, ): Repository> { return this._getRepo(model) } /** * Return a pinia-orm query for the model * * @param model */ public getQuery( model: T, ): PiniaOrmQuery> { // TODO: quid des uuid? return this.getRepository(model).where((val) => Number.isInteger(val.id)) } public getModel(instance: ApiResource): typeof ApiResource { return instance.constructor as typeof ApiModel } /** * Cast an object as an ApiResource * This in used internally to ensure the object is recognized as an ApiResource * * @param model * @param instance */ // noinspection JSMethodCanBeStatic public cast( model: T, instance: InstanceType, ): InstanceType { return new model(instance) as InstanceType } /** * Return the model class with the given entity name * * @param entityName */ public async getModelFor(entityName: string): Promise { if (!Object.prototype.hasOwnProperty.call(modelsIndex, entityName)) { throw new Error("No model found for entity name '" + entityName + "'") } return await modelsIndex[entityName]() } /** * Return the model class from an Opentalent Api IRI * * @param iri An IRI of the form .../api//... */ public async getModelFromIri(iri: string): Promise { const matches = iri.match(/^\/api\/(\w+)\/.*/) if (!matches || !matches[1]) { throw new Error('cannot parse the IRI') } return await this.getModelFor(matches[1]) } /** * Create a new instance of the given model * * @param model * @param properties */ public newInstance( model: T, properties: object = {}, ): InstanceType { const repository = this.getRepository(model) const instance = repository.make(properties) if ( !Object.prototype.hasOwnProperty.call(properties, 'id') || // @ts-expect-error Si la première condition passe, on sait que id existe !properties.id ) { // Object has no id yet, we give him a temporary one instance.id = 'tmp' + uuid4() } return this.save(instance, true) } /** * Save the model instance into the store * * @param instance * @param permanent Is the change already persisted in the datasource? If this is the case, the initial state of this * record is also updated. */ public save( instance: T, permanent: boolean = false, ): T { const model = this.getModel(instance) this.validateEntity(instance) if (permanent) { this.saveInitialState(model, instance) } return this.getRepository(model).save(instance) as T } /** * Find the model instance in the store * TODO: comment réagit la fonction si l'id n'existe pas dans le store? * * @param model * @param id */ public find(model: T, id: number | string): T { const repository = this.getRepository(model) return repository.find(id) as T } /** * Fetch an ApiModel / ApiResource by its id, save it to the store and returns it * * @param model Model of the object to fetch * @param id Id of the object to fetch */ public async fetch( model: T, id?: number | null, ): Promise> { // Else, get the object from the API const url = UrlUtils.join('api', model.entity, id ? String(id) : '') const response = await this.apiRequestService.get(url) // deserialize the response const attributes = HydraNormalizer.denormalize(response, model) .data as object return this.newInstance(model, attributes) } /** * Fetch a collection of model instances * * @param model * @param parent * @param query */ public async fetchCollection( model: T, parent: T | null, query: Query | null = null, ): Promise>>> { let url if (parent !== null) { url = UrlUtils.join('api', parent.entity, '' + parent.id, model.entity) } else { url = UrlUtils.join('api', model.entity) } if (query) { url += '?' + query.getUrlQuery() } const response = await this.apiRequestService.get(url) // deserialize the response const apiCollection = HydraNormalizer.denormalize(response, model) apiCollection.data.map((attributes: object) => { return this.newInstance(model, attributes) }) let piniaOrmQuery = this.getQuery(model) if (query) { piniaOrmQuery = query.applyToPiniaOrmQuery(piniaOrmQuery) } const res = computed(() => { const items: PiniaOrmCollection> = piniaOrmQuery.get() return { items, totalItems: apiCollection.metadata.totalItems, pagination: { first: apiCollection.metadata.first || 1, last: apiCollection.metadata.last || 1, next: apiCollection.metadata.next || undefined, previous: apiCollection.metadata.previous || undefined, }, } }) // @ts-expect-error Needed to avoid 'Cannot stringify non POJO' occasional bugs res.toJSON = () => { return 'Computed result from fetchCollection at : ' + url } return res } /** * Persist the model instance as it is in the store into the data source via the API * * @param instance */ public async persist(instance: ApiModel) { const model = this.getModel(instance) let url = UrlUtils.join('api', model.entity) let response this.validateEntity(instance) const data: AnyJson = HydraNormalizer.normalizeEntity(instance) const headers = { profileHash: await this.makeProfileHash() } if (!instance.isNew()) { if (!model.isIdLess()) { url = UrlUtils.join(url, String(instance.id)) } response = await this.apiRequestService.patch(url, data, null, headers) } else { delete data.id response = await this.apiRequestService.post(url, data, null, headers) } const hydraResponse = HydraNormalizer.denormalize(response, model) const newInstance = this.newInstance(model, hydraResponse.data) // Si l'instance était nouvelle avant d'être persistée, elle vient désormais de recevoir un id définitif. On // peut donc supprimer l'instance temporaire. if (instance.isNew()) { this.removeTempAfterPersist(model, instance.id) } return newInstance } /** * Send an update request (PATCH) to the API with the given data on an existing model instance * * @param model * @param id * @param data */ public async patch( model: typeof ApiModel, id: number, data: AssociativeArray, ) { const url = UrlUtils.join('api', model.entity, '' + id) const body = JSON.stringify(data) const response = await this.apiRequestService.patch(url, body) const hydraResponse = HydraNormalizer.denormalize(response, model) return this.newInstance(model, hydraResponse.data) } /** * Delete the model instance from the datasource via the API * * @param instance * @param instance */ public async delete(instance: T) { const model = this.getModel(instance) instance = this.cast(model, instance) as T console.log('delete', instance) this.validateEntity(instance) const repository = this.getRepository(model) this.validateEntity(instance) // If object has been persisted to the datasource, send a delete request if (!instance.isNew()) { const url = UrlUtils.join('api', model.entity, String(instance.id)) await this.apiRequestService.delete(url) } // reactiveUpdate the store repository.destroy(instance.id) } /** * Reset the model instance to its initial state (i.e. the state it had when it was fetched from the API) * * @param model * @param instance */ public reset(instance: T) { const model = this.getModel(instance) const initialInstance = this.getInitialStateOf(model, instance.id) if (initialInstance === null) { throw new Error( 'no initial state recorded for this object - abort [' + model.entity + '/' + instance.id + ']', ) } const repository = this.getRepository(model) repository.save(initialInstance) return initialInstance } /** * Delete all records in the repository of the model * * @param model */ public flush(model: T) { const repository = this.getRepository(model) repository.flush() } /** * Is the model instance a new one, or does it already exist in the data source (=API) * * This is a convenient way of testing a model instance you did not already fetch, else prefer the use of the * isNew() method of ApiResource * * @param model * @param id */ public isNewInstance( model: T, id: number | string, ): boolean { const repository = this.getRepository(model) const item = repository.find(id) if (!item || typeof item === 'undefined') { // TODO: est-ce qu'il ne faudrait pas lever une erreur ici plutôt? console.error(model.entity + '/' + id + ' does not exist!') return false } return item.isNew() } /** * Save the state of the model instance in the store, so this state could be restored later * * @param model * @param instance * @private */ protected saveInitialState( model: T, instance: InstanceType, ) { const repository = this.getRepository(model) // Clone and prefix id const clone = _.cloneDeep(instance) clone.id = this.CLONE_PREFIX + clone.id repository.save(clone) } /** * Return the saved state of the model instance from the store * * @param model * @param id * @private */ protected getInitialStateOf( model: T, id: string | number, ): InstanceType | null { const repository = this.getRepository(model) // Find the clone by id const instance = repository.find(this.CLONE_PREFIX + id) if (instance === null) { return null } // Restore the initial id instance.id = id return instance } /** * Delete the temporary model instance from the repo after it was persisted via the api, replaced by the instance * that has been returned by the api with is definitive id. * * @param model * @param tempInstanceId * @private */ protected removeTempAfterPersist( model: T, tempInstanceId: number | string, ) { const repository = this.getRepository(model) const instance = repository.find(tempInstanceId) if (!instance || typeof instance === 'undefined') { // TODO: il vaudrait peut-être mieux lever une erreur ici? console.error(model.entity + '/' + tempInstanceId + ' does not exist!') return } if (!instance.isNew()) { throw new Error('Error: Can not remove a non-temporary model instance') } repository.destroy(tempInstanceId) repository.destroy(this.CLONE_PREFIX + tempInstanceId) } protected async makeProfileHash(): Promise { const mask = this._getProfileMask() return await ObjectUtils.hash(mask) } /** * Validate the entity, and throw an error if it's not correctly defined. * @param instance * @protected */ protected validateEntity(instance: unknown): void { if (Object.prototype.hasOwnProperty.call(instance, 'id')) { // @ts-expect-error At this point, we're sure there is an id property const id = instance.id if ( !(typeof id === 'number') && !(typeof id === 'string' && id.startsWith('tmp')) ) { // The id is a pinia orm Uid, the entity has been created using the `new` keyword (not supported for now) throw new Error( 'Definition error for the entity, did you use the entityManager.newInstance(...) method?\n' + JSON.stringify(instance), ) } } } } export default EntityManager