entityManager.ts 12 KB

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