entityManager.ts 12 KB

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