entityManager.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import type {
  2. Query as PiniaOrmQuery,
  3. Repository,
  4. Collection as PiniaOrmCollection,
  5. } from 'pinia-orm'
  6. import { v4 as uuid4 } from 'uuid'
  7. import * as _ from 'lodash-es'
  8. import { computed, type ComputedRef } from 'vue'
  9. import type ApiRequestService from './apiRequestService'
  10. import UrlUtils from '~/services/utils/urlUtils'
  11. import type ApiModel from '~/models/ApiModel'
  12. import type ApiResource from '~/models/ApiResource'
  13. import type { AnyJson, AssociativeArray, Collection } from '~/types/data.d'
  14. import modelsIndex from '~/models/models'
  15. import HydraNormalizer from '~/services/data/normalizer/hydraNormalizer'
  16. import ObjectUtils from '~/services/utils/objectUtils'
  17. import type Query from '~/services/data/Query'
  18. /**
  19. * Entity manager: make operations on the models defined with the Pinia-Orm library
  20. *
  21. * TODO: améliorer le typage des méthodes sur le modèle de find()
  22. * @see https://pinia-orm.codedredd.de/
  23. */
  24. class EntityManager {
  25. protected CLONE_PREFIX = '_clone_'
  26. /**
  27. * In instance of ApiRequestService
  28. * @protected
  29. */
  30. protected apiRequestService: ApiRequestService
  31. /**
  32. * A method to retrieve the repository of a given ApiResource
  33. * @protected
  34. */
  35. protected _getRepo: <T extends typeof ApiResource>(
  36. model: T,
  37. ) => Repository<InstanceType<T>>
  38. protected _getProfileMask: () => object
  39. public constructor(
  40. apiRequestService: ApiRequestService,
  41. getRepo: <T extends typeof ApiResource>(
  42. model: T,
  43. ) => Repository<InstanceType<T>>,
  44. getProfileMask: () => object,
  45. ) {
  46. this.apiRequestService = apiRequestService
  47. this._getRepo = getRepo
  48. this._getProfileMask = getProfileMask
  49. }
  50. /**
  51. * Return the repository for the model
  52. *
  53. * @param model
  54. */
  55. public getRepository<T extends typeof ApiResource>(
  56. model: T,
  57. ): Repository<InstanceType<T>> {
  58. return this._getRepo(model)
  59. }
  60. /**
  61. * Return a pinia-orm query for the model
  62. *
  63. * @param model
  64. */
  65. public getQuery<T extends typeof ApiResource>(
  66. model: T,
  67. ): PiniaOrmQuery<InstanceType<T>> {
  68. // TODO: quid des uuid?
  69. return this.getRepository(model).where((val) => Number.isInteger(val.id))
  70. }
  71. public getModel(instance: ApiResource): typeof ApiResource {
  72. return instance.constructor as typeof ApiModel
  73. }
  74. /**
  75. * Cast an object as an ApiResource
  76. * This in used internally to ensure the object is recognized as an ApiResource
  77. *
  78. * @param model
  79. * @param instance
  80. */
  81. // noinspection JSMethodCanBeStatic
  82. public cast<T extends typeof ApiResource>(
  83. model: T,
  84. instance: InstanceType<T>,
  85. ): InstanceType<T> {
  86. return new model(instance) as InstanceType<T>
  87. }
  88. /**
  89. * Return the model class with the given entity name
  90. *
  91. * @param entityName
  92. */
  93. public async getModelFor(entityName: string): Promise<typeof ApiResource> {
  94. if (!Object.prototype.hasOwnProperty.call(modelsIndex, entityName)) {
  95. throw new Error("No model found for entity name '" + entityName + "'")
  96. }
  97. return await modelsIndex[entityName]()
  98. }
  99. /**
  100. * Return the model class from an Opentalent Api IRI
  101. *
  102. * @param iri An IRI of the form .../api/<entity>/...
  103. */
  104. public async getModelFromIri(iri: string): Promise<typeof ApiResource> {
  105. const matches = iri.match(/^\/api\/(\w+)\/.*/)
  106. if (!matches || !matches[1]) {
  107. throw new Error('cannot parse the IRI')
  108. }
  109. return await this.getModelFor(matches[1])
  110. }
  111. /**
  112. * Create a new instance of the given model
  113. *
  114. * @param model
  115. * @param properties
  116. */
  117. public newInstance<T extends typeof ApiResource>(
  118. model: T,
  119. properties: object = {},
  120. ): InstanceType<T> {
  121. const repository = this.getRepository(model)
  122. const instance = repository.make(properties)
  123. if (
  124. !Object.prototype.hasOwnProperty.call(properties, 'id') ||
  125. // @ts-expect-error Si la première condition passe, on sait que id existe
  126. !properties.id
  127. ) {
  128. // Object has no id yet, we give him a temporary one
  129. instance.id = 'tmp' + uuid4()
  130. }
  131. return this.save(instance, true)
  132. }
  133. /**
  134. * Save the model instance into the store
  135. *
  136. * @param instance
  137. * @param permanent Is the change already persisted in the datasource? If this is the case, the initial state of this
  138. * record is also updated.
  139. */
  140. public save<T extends ApiResource>(
  141. instance: T,
  142. permanent: boolean = false,
  143. ): T {
  144. const model = this.getModel(instance)
  145. this.validateEntity(instance)
  146. if (permanent) {
  147. this.saveInitialState(model, instance)
  148. }
  149. return this.getRepository(model).save(instance) as T
  150. }
  151. /**
  152. * Find the model instance in the store
  153. * TODO: comment réagit la fonction si l'id n'existe pas dans le store?
  154. *
  155. * @param model
  156. * @param id
  157. */
  158. public find<T extends typeof ApiResource>(model: T, id: number | string): T {
  159. const repository = this.getRepository(model)
  160. return repository.find(id) as T
  161. }
  162. /**
  163. * Fetch an ApiModel / ApiResource by its id, save it to the store and returns it
  164. *
  165. * @param model Model of the object to fetch
  166. * @param id Id of the object to fetch
  167. */
  168. public async fetch<T extends typeof ApiResource>(
  169. model: T,
  170. id?: number | null,
  171. ): Promise<InstanceType<T>> {
  172. // Else, get the object from the API
  173. const url = UrlUtils.join('api', model.entity, id ? String(id) : '')
  174. const response = await this.apiRequestService.get(url)
  175. // deserialize the response
  176. const attributes = HydraNormalizer.denormalize(response, model)
  177. .data as object
  178. return this.newInstance(model, attributes)
  179. }
  180. /**
  181. * Fetch a collection of model instances
  182. *
  183. * @param model
  184. * @param parent
  185. * @param query
  186. */
  187. public async fetchCollection<T extends typeof ApiResource>(
  188. model: T,
  189. parent: T | null,
  190. query: Query | null = null,
  191. ): Promise<ComputedRef<Collection<InstanceType<T>>>> {
  192. let url
  193. if (parent !== null) {
  194. url = UrlUtils.join('api', parent.entity, '' + parent.id, model.entity)
  195. } else {
  196. url = UrlUtils.join('api', model.entity)
  197. }
  198. if (query) {
  199. url += '?' + query.getUrlQuery()
  200. }
  201. const response = await this.apiRequestService.get(url)
  202. // deserialize the response
  203. const apiCollection = HydraNormalizer.denormalize(response, model)
  204. apiCollection.data.map((attributes: object) => {
  205. return this.newInstance(model, attributes)
  206. })
  207. let piniaOrmQuery = this.getQuery(model)
  208. if (query) {
  209. piniaOrmQuery = query.applyToPiniaOrmQuery(piniaOrmQuery)
  210. }
  211. const res = computed(() => {
  212. const items: PiniaOrmCollection<InstanceType<T>> = piniaOrmQuery.get()
  213. return {
  214. items,
  215. totalItems: apiCollection.metadata.totalItems,
  216. pagination: {
  217. first: apiCollection.metadata.first || 1,
  218. last: apiCollection.metadata.last || 1,
  219. next: apiCollection.metadata.next || undefined,
  220. previous: apiCollection.metadata.previous || undefined,
  221. },
  222. }
  223. })
  224. // @ts-expect-error Needed to avoid 'Cannot stringify non POJO' occasional bugs
  225. res.toJSON = () => {
  226. return 'Computed result from fetchCollection at : ' + url
  227. }
  228. return res
  229. }
  230. /**
  231. * Persist the model instance as it is in the store into the data source via the API
  232. *
  233. * @param instance
  234. */
  235. public async persist(instance: ApiModel) {
  236. const model = this.getModel(instance)
  237. let url = UrlUtils.join('api', model.entity)
  238. let response
  239. this.validateEntity(instance)
  240. const data: AnyJson = HydraNormalizer.normalizeEntity(instance)
  241. const headers = { profileHash: await this.makeProfileHash() }
  242. if (!instance.isNew()) {
  243. if (!model.isIdLess()) {
  244. url = UrlUtils.join(url, String(instance.id))
  245. }
  246. response = await this.apiRequestService.patch(url, data, null, headers)
  247. } else {
  248. delete data.id
  249. response = await this.apiRequestService.post(url, data, null, headers)
  250. }
  251. const hydraResponse = HydraNormalizer.denormalize(response, model)
  252. const newInstance = this.newInstance(model, hydraResponse.data)
  253. // Si l'instance était nouvelle avant d'être persistée, elle vient désormais de recevoir un id définitif. On
  254. // peut donc supprimer l'instance temporaire.
  255. if (instance.isNew()) {
  256. this.removeTempAfterPersist(model, instance.id)
  257. }
  258. return newInstance
  259. }
  260. /**
  261. * Send an update request (PATCH) to the API with the given data on an existing model instance
  262. *
  263. * @param model
  264. * @param id
  265. * @param data
  266. */
  267. public async patch(
  268. model: typeof ApiModel,
  269. id: number,
  270. data: AssociativeArray,
  271. ) {
  272. const url = UrlUtils.join('api', model.entity, '' + id)
  273. const body = JSON.stringify(data)
  274. const response = await this.apiRequestService.patch(url, body)
  275. const hydraResponse = HydraNormalizer.denormalize(response, model)
  276. return this.newInstance(model, hydraResponse.data)
  277. }
  278. /**
  279. * Delete the model instance from the datasource via the API
  280. *
  281. * @param instance
  282. * @param instance
  283. */
  284. public async delete<T extends ApiResource>(instance: T) {
  285. const model = this.getModel(instance)
  286. instance = this.cast(model, instance) as T
  287. console.log('delete', instance)
  288. this.validateEntity(instance)
  289. const repository = this.getRepository(model)
  290. this.validateEntity(instance)
  291. // If object has been persisted to the datasource, send a delete request
  292. if (!instance.isNew()) {
  293. const url = UrlUtils.join('api', model.entity, String(instance.id))
  294. await this.apiRequestService.delete(url)
  295. }
  296. // reactiveUpdate the store
  297. repository.destroy(instance.id)
  298. }
  299. /**
  300. * Reset the model instance to its initial state (i.e. the state it had when it was fetched from the API)
  301. *
  302. * @param model
  303. * @param instance
  304. */
  305. public reset<T extends ApiResource>(instance: T) {
  306. const model = this.getModel(instance)
  307. const initialInstance = this.getInitialStateOf(model, instance.id)
  308. if (initialInstance === null) {
  309. throw new Error(
  310. 'no initial state recorded for this object - abort [' +
  311. model.entity +
  312. '/' +
  313. instance.id +
  314. ']',
  315. )
  316. }
  317. const repository = this.getRepository(model)
  318. repository.save(initialInstance)
  319. return initialInstance
  320. }
  321. /**
  322. * Delete all records in the repository of the model
  323. *
  324. * @param model
  325. */
  326. public flush<T extends typeof ApiResource>(model: T) {
  327. const repository = this.getRepository(model)
  328. repository.flush()
  329. }
  330. /**
  331. * Is the model instance a new one, or does it already exist in the data source (=API)
  332. *
  333. * This is a convenient way of testing a model instance you did not already fetch, else prefer the use of the
  334. * isNew() method of ApiResource
  335. *
  336. * @param model
  337. * @param id
  338. */
  339. public isNewInstance<T extends typeof ApiResource>(
  340. model: T,
  341. id: number | string,
  342. ): boolean {
  343. const repository = this.getRepository(model)
  344. const item = repository.find(id)
  345. if (!item || typeof item === 'undefined') {
  346. // TODO: est-ce qu'il ne faudrait pas lever une erreur ici plutôt?
  347. console.error(model.entity + '/' + id + ' does not exist!')
  348. return false
  349. }
  350. return item.isNew()
  351. }
  352. /**
  353. * Save the state of the model instance in the store, so this state could be restored later
  354. *
  355. * @param model
  356. * @param instance
  357. * @private
  358. */
  359. protected saveInitialState<T extends typeof ApiResource>(
  360. model: T,
  361. instance: InstanceType<T>,
  362. ) {
  363. const repository = this.getRepository(model)
  364. // Clone and prefix id
  365. const clone = _.cloneDeep(instance)
  366. clone.id = this.CLONE_PREFIX + clone.id
  367. repository.save(clone)
  368. }
  369. /**
  370. * Return the saved state of the model instance from the store
  371. *
  372. * @param model
  373. * @param id
  374. * @private
  375. */
  376. protected getInitialStateOf<T extends typeof ApiResource>(
  377. model: T,
  378. id: string | number,
  379. ): InstanceType<T> | null {
  380. const repository = this.getRepository(model)
  381. // Find the clone by id
  382. const instance = repository.find(this.CLONE_PREFIX + id)
  383. if (instance === null) {
  384. return null
  385. }
  386. // Restore the initial id
  387. instance.id = id
  388. return instance
  389. }
  390. /**
  391. * Delete the temporary model instance from the repo after it was persisted via the api, replaced by the instance
  392. * that has been returned by the api with is definitive id.
  393. *
  394. * @param model
  395. * @param tempInstanceId
  396. * @private
  397. */
  398. protected removeTempAfterPersist<T extends typeof ApiResource>(
  399. model: T,
  400. tempInstanceId: number | string,
  401. ) {
  402. const repository = this.getRepository(model)
  403. const instance = repository.find(tempInstanceId)
  404. if (!instance || typeof instance === 'undefined') {
  405. // TODO: il vaudrait peut-être mieux lever une erreur ici?
  406. console.error(model.entity + '/' + tempInstanceId + ' does not exist!')
  407. return
  408. }
  409. if (!instance.isNew()) {
  410. throw new Error('Error: Can not remove a non-temporary model instance')
  411. }
  412. repository.destroy(tempInstanceId)
  413. repository.destroy(this.CLONE_PREFIX + tempInstanceId)
  414. }
  415. protected async makeProfileHash(): Promise<string> {
  416. const mask = this._getProfileMask()
  417. return await ObjectUtils.hash(mask)
  418. }
  419. /**
  420. * Validate the entity, and throw an error if it's not correctly defined.
  421. * @param instance
  422. * @protected
  423. */
  424. protected validateEntity(instance: unknown): void {
  425. if (Object.prototype.hasOwnProperty.call(instance, 'id')) {
  426. // @ts-expect-error At this point, we're sure there is an id property
  427. const id = instance.id
  428. if (
  429. !(typeof id === 'number') &&
  430. !(typeof id === 'string' && id.startsWith('tmp'))
  431. ) {
  432. // The id is a pinia orm Uid, the entity has been created using the `new` keyword (not supported for now)
  433. throw new Error(
  434. 'Definition error for the entity, did you use the entityManager.newInstance(...) method?\n' +
  435. JSON.stringify(instance),
  436. )
  437. }
  438. }
  439. }
  440. }
  441. export default EntityManager