From 931611608cf06218fa1bf369a7ef123b2425c8fb Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Mon, 29 Aug 2022 12:44:43 +0200 Subject: [PATCH 1/5] feat(pinia-orm): add caching for same get requests --- packages/pinia-orm/src/model/Model.ts | 12 +++ packages/pinia-orm/src/query/Cache.ts | 101 ++++++++++++++++++ packages/pinia-orm/src/query/Query.ts | 51 ++++++++- .../pinia-orm/src/repository/Repository.ts | 13 ++- .../save_has_many_relation.spec.ts | 16 +++ 5 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 packages/pinia-orm/src/query/Cache.ts diff --git a/packages/pinia-orm/src/model/Model.ts b/packages/pinia-orm/src/model/Model.ts index 2d25ddbde..7227390b4 100644 --- a/packages/pinia-orm/src/model/Model.ts +++ b/packages/pinia-orm/src/model/Model.ts @@ -62,6 +62,11 @@ export class Model { */ static entity: string + /** + * Defines caching behaviour + */ + static caching = true + /** * The reference to the base entity name if the class extends a base entity. */ @@ -508,6 +513,13 @@ export class Model { return this.$self().entity } + /** + * Get the cache config of this model. + */ + $cache(): boolean { + return this.$self().caching + } + /** * Get the model config. */ diff --git a/packages/pinia-orm/src/query/Cache.ts b/packages/pinia-orm/src/query/Cache.ts new file mode 100644 index 000000000..ec0180120 --- /dev/null +++ b/packages/pinia-orm/src/query/Cache.ts @@ -0,0 +1,101 @@ +export interface FetchParams { + key: string + params?: any + callback(): any + expiresInSeconds?: number +} + +export interface KeyParams { + key: string + params?: any +} + +export interface StoredData { + key: string + data: T + expiration: number +} + +export interface IStorageCache { + fetch(params: FetchParams): Promise | T +} + +// By default data will expire in 5 minutes. +const DEFAULT_EXPIRATION_SECONDS = 5 * 60 + +export class Cache implements IStorageCache { + constructor(private cache = new Map()) {} + + // The fetch method, before calling callback, will check if there is cached data. + // If cached data is not available, it will call callback, store the data in memory + // and return it. If cached data is available, it won't call callback and it will + // just return the cached values. + fetch({ + key, + params = null, + callback, + expiresInSeconds = DEFAULT_EXPIRATION_SECONDS, + }: FetchParams): T { + const cacheKey = this.generateKey({ key, params }) + const data = this.get(cacheKey) + const expiration = this.computeExpirationTime(expiresInSeconds) + + return data || this.set({ key: cacheKey, data: callback(), expiration }) + } + + clear(): void { + this.cache = new Map() + } + + size(): number { + return this.cache.size + } + + private computeExpirationTime(expiresInSeconds: number): number { + return new Date().getTime() + expiresInSeconds * 1000 + } + + // This method returns a base64 string containing a combination of a key and parameters + // creating a unique identifier for a specific key and specific parameters. This is + // useful in case the callback returns different values based on parameters. + private generateKey({ key, params }: KeyParams): string { + const keyValues = params ? { key, params } : { key } + const stringifiedKey = JSON.stringify(keyValues) + + // This check allows to generate base64 strings depending on the current environment. + // If the window object exists, we can assume this code is running in a browser. + if (typeof process === 'undefined') { + return btoa(stringifiedKey) + } + else { + const bufferObj = Buffer.from(stringifiedKey, 'utf8') + const base64String = bufferObj.toString('base64') + + return base64String + } + } + + // Store the data in memory and attach to the object expiration containing the + // expiration time. + private set({ key, data, expiration }: StoredData): T { + this.cache.set(key, { data, expiration }) + + return data + } + + // Will get specific data from the Map object based on a key and return null if + // the data has expired. + private get(key: string): T | null { + if (this.cache.has(key)) { + const { data, expiration } = this.cache.get(key) as StoredData + + return this.hasExpired(expiration) ? null : data + } + + return null + } + + private hasExpired(expiration: number): boolean { + return expiration < new Date().getTime() + } +} diff --git a/packages/pinia-orm/src/query/Query.ts b/packages/pinia-orm/src/query/Query.ts index 42d03026c..0bc520b8f 100644 --- a/packages/pinia-orm/src/query/Query.ts +++ b/packages/pinia-orm/src/query/Query.ts @@ -14,6 +14,7 @@ import { MorphTo } from '../model/attributes/relations/MorphTo' import type { Model, ModelFields, ModelOptions } from '../model/Model' import { Interpreter } from '../interpreter/Interpreter' import { useDataStore } from '../composables/useDataStore' +import type { Cache } from '../query/Cache' import type { EagerLoad, EagerLoadConstraint, @@ -63,38 +64,55 @@ export class Query { */ protected skip = 0 + /** + * Fields that should be visible. + */ protected visible = ['*'] + /** + * Fields that should be hidden. + */ protected hidden: string[] = [] + /** + * The cache object. + */ + protected cache: Cache + /** * The relationships that should be eager loaded. */ protected eagerLoad: EagerLoad = {} + /** + * The pinia store. + */ protected pinia?: Pinia + protected fromCache = true + /** * Create a new query instance. */ - constructor(database: Database, model: M, pinia?: Pinia) { + constructor(database: Database, model: M, cache: Cache, pinia?: Pinia) { this.database = database this.model = model this.pinia = pinia + this.cache = cache } /** * Create a new query instance for the given model. */ newQuery(model: string): Query { - return new Query(this.database, this.database.getModel(model), this.pinia) + return new Query(this.database, this.database.getModel(model), this.cache, this.pinia) } /** * Create a new query instance with constraints for the given model. */ newQueryWithConstraints(model: string): Query { - const newQuery = new Query(this.database, this.database.getModel(model), this.pinia) + const newQuery = new Query(this.database, this.database.getModel(model), this.cache, this.pinia) // Copy query constraints newQuery.eagerLoad = { ...this.eagerLoad } @@ -110,7 +128,7 @@ export class Query { * Create a new query instance from the given relation. */ newQueryForRelation(relation: Relation): Query { - return new Query(this.database, relation.getRelated(), this.pinia) + return new Query(this.database, relation.getRelated(), this.cache, this.pinia) } /** @@ -324,6 +342,12 @@ export class Query { }) } + useCache(useCache = true): this { + this.fromCache = useCache + + return this + } + /** * Get where closure for relations */ @@ -358,6 +382,25 @@ export class Query { */ get(triggerHook?: boolean): T extends 'group' ? GroupedCollection : Collection get(triggerHook = true): Collection | GroupedCollection { + return !this.fromCache + ? this.internalGet(triggerHook) + : this.cache.fetch | GroupedCollection>({ + key: this.model.$entity(), + params: { + where: this.wheres, + groups: this.groups, + orders: this.orders, + eagerLoads: this.eagerLoad, + skip: this.skip, + take: this.take, + hidden: this.hidden, + visible: this.visible, + }, + callback: () => this.internalGet(triggerHook), + }) + } + + private internalGet(triggerHook: boolean): Collection | GroupedCollection { if (this.model.$entity() !== this.model.$baseEntity()) this.where(this.model.$typeKey(), this.model.$fields()[this.model.$typeKey()].make()) diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index f905d1393..7a0c5cd63 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -16,6 +16,7 @@ import type { } from '../query/Options' import { useRepo } from '../composables/useRepo' import { useDataStore } from '../composables/useDataStore' +import { Cache } from '../query/Cache' export class Repository { /** @@ -37,6 +38,8 @@ export class Repository { protected pinia?: Pinia + protected queryCache: Cache + /** * The model object to be used for the custom repository. */ @@ -48,6 +51,7 @@ export class Repository { constructor(database: Database, pinia?: Pinia) { this.database = database this.pinia = pinia + this.queryCache = new Cache() } /** @@ -108,7 +112,14 @@ export class Repository { * Create a new Query instance. */ query(): Query { - return new Query(this.database, this.getModel(), this.pinia) + return new Query(this.database, this.getModel(), this.queryCache, this.pinia) + } + + /** + * Create a new Query instance. + */ + cache(): Cache { + return this.queryCache } /** diff --git a/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts b/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts index 17f44ae50..3beb45488 100644 --- a/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts +++ b/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts @@ -38,5 +38,21 @@ describe('performance/save_has_many_relation', () => { console.time('time') userRepo.save(users) console.timeEnd('time') + console.log('Get Speed test for 10k saved items and 5 queries') + console.time('get(): with cache') + for (let i = 1; i <= 5; i++) { + console.time(`time query ${i}`) + userRepo.with('posts').get() + console.timeEnd(`time query ${i}`) + } + console.timeEnd('get(): with cache') + + console.time('get(): without cache') + for (let i = 1; i <= 5; i++) { + console.time(`time query without ${i}`) + userRepo.with('posts').useCache(false).get() + console.timeEnd(`time query without ${i}`) + } + console.timeEnd('get(): without cache') }) }) From 96b077c494a1b0538c4a70c3ff60940f9ed631cd Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Tue, 6 Sep 2022 21:06:38 +0200 Subject: [PATCH 2/5] feat(pinia-orm): add caching classes & improve config --- .../{query/Cache.ts => cache/SimpleCache.ts} | 2 +- packages/pinia-orm/src/cache/WeakCache.ts | 73 +++++++++++++++++ .../pinia-orm/src/composables/useDataStore.ts | 4 +- packages/pinia-orm/src/data/SharedCache.ts | 4 + packages/pinia-orm/src/model/Model.ts | 14 ++-- packages/pinia-orm/src/query/Query.ts | 82 +++++++++++-------- .../pinia-orm/src/repository/Repository.ts | 13 ++- packages/pinia-orm/src/store/Config.ts | 16 ++++ packages/pinia-orm/src/store/Store.ts | 30 +++---- packages/pinia-orm/src/support/Utils.ts | 12 +++ packages/pinia-orm/src/types/index.ts | 5 ++ packages/pinia-orm/tests/helpers.ts | 10 +-- .../save_has_many_relation.spec.ts | 38 +++++---- packages/pinia-orm/tests/setup.ts | 2 +- 14 files changed, 218 insertions(+), 87 deletions(-) rename packages/pinia-orm/src/{query/Cache.ts => cache/SimpleCache.ts} (98%) create mode 100644 packages/pinia-orm/src/cache/WeakCache.ts create mode 100644 packages/pinia-orm/src/data/SharedCache.ts create mode 100644 packages/pinia-orm/src/store/Config.ts diff --git a/packages/pinia-orm/src/query/Cache.ts b/packages/pinia-orm/src/cache/SimpleCache.ts similarity index 98% rename from packages/pinia-orm/src/query/Cache.ts rename to packages/pinia-orm/src/cache/SimpleCache.ts index ec0180120..4190a04f7 100644 --- a/packages/pinia-orm/src/query/Cache.ts +++ b/packages/pinia-orm/src/cache/SimpleCache.ts @@ -23,7 +23,7 @@ export interface IStorageCache { // By default data will expire in 5 minutes. const DEFAULT_EXPIRATION_SECONDS = 5 * 60 -export class Cache implements IStorageCache { +export class SimpleCache implements IStorageCache { constructor(private cache = new Map()) {} // The fetch method, before calling callback, will check if there is cached data. diff --git a/packages/pinia-orm/src/cache/WeakCache.ts b/packages/pinia-orm/src/cache/WeakCache.ts new file mode 100644 index 000000000..cce4659d4 --- /dev/null +++ b/packages/pinia-orm/src/cache/WeakCache.ts @@ -0,0 +1,73 @@ +export class WeakCache implements Map { + // @ts-expect-error dont know + readonly [Symbol.toStringTag]: string + + #map = new Map>() + + has(key: K) { + return !!(this.#map.has(key) && this.#map.get(key)?.deref()) + } + + get(key: K): V { + const weakRef = this.#map.get(key) + if (!weakRef) + // @ts-expect-error object has no undefined + return undefined + + const value = weakRef.deref() + if (value) + return value + + // If it cant be dereference, remove the key + this.#map.delete(key) + // @ts-expect-error object has no undefined + return undefined + } + + set(key: K, value: V) { + this.#map.set(key, new WeakRef(value)) + return this + } + + get size(): number { + return this.#map.size + } + + clear(): void { + this.#map.clear() + } + + delete(key: K): boolean { + this.#map.delete(key) + return false + } + + forEach(cb: (value: V, key: K, map: Map) => void): void { + for (const [key, value] of this) cb(value, key, this) + } + + * [Symbol.iterator](): IterableIterator<[K, V]> { + for (const [key, weakRef] of this.#map) { + const ref = weakRef.deref() + + // If it cant be dereference, remove the key + if (!ref) { + this.#map.delete(key) + continue + } + yield [key, ref] + } + } + + * entries(): IterableIterator<[K, V]> { + for (const [key, value] of this) yield [key, value] + } + + * keys(): IterableIterator { + for (const [key] of this) yield key + } + + * values(): IterableIterator { + for (const [, value] of this) yield value + } +} diff --git a/packages/pinia-orm/src/composables/useDataStore.ts b/packages/pinia-orm/src/composables/useDataStore.ts index 0449c32e4..e8bab4954 100644 --- a/packages/pinia-orm/src/composables/useDataStore.ts +++ b/packages/pinia-orm/src/composables/useDataStore.ts @@ -1,6 +1,5 @@ import { defineStore } from 'pinia' import type { Model } from '../model/Model' -import type { FilledInstallOptions } from '../store/Store' import { useStoreActions } from './useStoreActions' export function useDataStore( @@ -8,7 +7,7 @@ export function useDataStore( options: Record | null = null, ) { return defineStore(id, { - state: (): DataStoreState => ({ data: {}, config: {} as FilledInstallOptions }), + state: (): DataStoreState => ({ data: {} }), actions: useStoreActions(), ...options, }) @@ -16,7 +15,6 @@ export function useDataStore( export interface DataStoreState { data: Record - config: FilledInstallOptions } export type DataStore = ReturnType diff --git a/packages/pinia-orm/src/data/SharedCache.ts b/packages/pinia-orm/src/data/SharedCache.ts new file mode 100644 index 000000000..34451ccc9 --- /dev/null +++ b/packages/pinia-orm/src/data/SharedCache.ts @@ -0,0 +1,4 @@ +import { WeakCache } from '../cache/WeakCache' +import type { Model } from '../model/Model' + +export const cache = new WeakCache() diff --git a/packages/pinia-orm/src/model/Model.ts b/packages/pinia-orm/src/model/Model.ts index 7227390b4..9f6b8c32d 100644 --- a/packages/pinia-orm/src/model/Model.ts +++ b/packages/pinia-orm/src/model/Model.ts @@ -4,6 +4,7 @@ import type { Collection, Element, Item } from '../data/Data' import type { MutatorFunctions, Mutators } from '../types' import type { DataStore, DataStoreState } from '../composables/useDataStore' import type { ModelConfigOptions } from '../store/Store' +import { config } from '../store/Config' import type { Attribute } from './attributes/Attribute' import { Attr } from './attributes/types/Attr' import { String as Str } from './attributes/types/String' @@ -625,11 +626,12 @@ export class Model { */ $fill(attributes: Element = {}, options: ModelOptions = {}): this { const operation = options.operation ?? 'get' - const config = { - ...options.config, + + const modelConfig = { + ...config.model, ...this.$config(), } - config.withMeta && (this.$self().schemas[this.$entity()][this.$self().metaKey] = this.$self().attr({})) + modelConfig.withMeta && (this.$self().schemas[this.$entity()][this.$self().metaKey] = this.$self().attr({})) const fields = this.$fields() const fillRelation = options.relations ?? true @@ -670,7 +672,7 @@ export class Model { this[key] = this[key] ?? keyValue } - config.withMeta && operation === 'set' && this.$fillMeta(options.action) + modelConfig.withMeta && operation === 'set' && this.$fillMeta(options.action) return this } @@ -702,8 +704,8 @@ export class Model { } protected isFieldVisible(key: string, modelHidden: string[], modelVisible: string[], options: ModelOptions): boolean { - const hidden = modelHidden.length > 0 ? modelHidden : options.config?.hidden ?? [] - const visible = [...(modelVisible.length > 0 ? modelVisible : options.config?.visible ?? ['*']), String(this.$primaryKey())] + const hidden = modelHidden.length > 0 ? modelHidden : config.model.hidden ?? [] + const visible = [...(modelVisible.length > 0 ? modelVisible : config.model.visible ?? ['*']), String(this.$primaryKey())] const optionsVisible = options.visible ?? [] const optionsHidden = options.hidden ?? [] if (((hidden.includes('*') || hidden.includes(key)) && !optionsVisible.includes(key)) || optionsHidden.includes(key)) diff --git a/packages/pinia-orm/src/query/Query.ts b/packages/pinia-orm/src/query/Query.ts index 0bc520b8f..b4607e301 100644 --- a/packages/pinia-orm/src/query/Query.ts +++ b/packages/pinia-orm/src/query/Query.ts @@ -1,6 +1,6 @@ import type { Pinia } from 'pinia' import { - assert, compareWithOperator, + assert, compareWithOperator, generateKey, groupBy, isArray, isEmpty, @@ -14,7 +14,8 @@ import { MorphTo } from '../model/attributes/relations/MorphTo' import type { Model, ModelFields, ModelOptions } from '../model/Model' import { Interpreter } from '../interpreter/Interpreter' import { useDataStore } from '../composables/useDataStore' -import type { Cache } from '../query/Cache' +import type { WeakCache } from '../cache/WeakCache' +import type { CacheConfig } from '../types' import type { EagerLoad, EagerLoadConstraint, @@ -77,7 +78,7 @@ export class Query { /** * The cache object. */ - protected cache: Cache + protected cache?: WeakCache | GroupedCollection> | undefined /** * The relationships that should be eager loaded. @@ -89,12 +90,14 @@ export class Query { */ protected pinia?: Pinia - protected fromCache = true + protected fromCache = false + + protected cacheConfig: CacheConfig = {} /** * Create a new query instance. */ - constructor(database: Database, model: M, cache: Cache, pinia?: Pinia) { + constructor(database: Database, model: M, cache: WeakCache | GroupedCollection> | undefined, pinia?: Pinia) { this.database = database this.model = model this.pinia = pinia @@ -105,14 +108,14 @@ export class Query { * Create a new query instance for the given model. */ newQuery(model: string): Query { - return new Query(this.database, this.database.getModel(model), this.cache, this.pinia) + return new Query(this.database, this.database.getModel(model), this.cache, this.pinia).useCache(this.cacheConfig.key, this.cacheConfig.params) } /** * Create a new query instance with constraints for the given model. */ newQueryWithConstraints(model: string): Query { - const newQuery = new Query(this.database, this.database.getModel(model), this.cache, this.pinia) + const newQuery = new Query(this.database, this.database.getModel(model), this.cache, this.pinia).useCache(this.cacheConfig.key, this.cacheConfig.params) // Copy query constraints newQuery.eagerLoad = { ...this.eagerLoad } @@ -128,7 +131,7 @@ export class Query { * Create a new query instance from the given relation. */ newQueryForRelation(relation: Relation): Query { - return new Query(this.database, relation.getRelated(), this.cache, this.pinia) + return new Query(this.database, relation.getRelated(), this.cache, this.pinia).useCache(this.cacheConfig.key, this.cacheConfig.params) } /** @@ -146,7 +149,10 @@ export class Query { if (name && typeof store[name] === 'function') store[name](payload) - return store.$state + if (this.cache && ['get', 'all', 'insert', 'flush', 'delete', 'update', 'destroy'].includes(name)) + this.cache.clear() + + return store.$state.data } /** @@ -342,8 +348,12 @@ export class Query { }) } - useCache(useCache = true): this { - this.fromCache = useCache + useCache(key?: string, params?: Record): this { + this.fromCache = true + this.cacheConfig = { + key, + params, + } return this } @@ -368,11 +378,11 @@ export class Query { * method will not process any query chain. It'll always retrieve all models. */ all(): Collection { - const { data, config } = this.commit('all') + const data = this.commit('all') const collection = [] as Collection - for (const id in data) collection.push(this.hydrate(data[id], { visible: this.visible, hidden: this.hidden, config: config.model })) + for (const id in data) collection.push(this.hydrate(data[id], { visible: this.visible, hidden: this.hidden })) return collection } @@ -382,22 +392,27 @@ export class Query { */ get(triggerHook?: boolean): T extends 'group' ? GroupedCollection : Collection get(triggerHook = true): Collection | GroupedCollection { - return !this.fromCache - ? this.internalGet(triggerHook) - : this.cache.fetch | GroupedCollection>({ - key: this.model.$entity(), - params: { - where: this.wheres, - groups: this.groups, - orders: this.orders, - eagerLoads: this.eagerLoad, - skip: this.skip, - take: this.take, - hidden: this.hidden, - visible: this.visible, - }, - callback: () => this.internalGet(triggerHook), - }) + if (!this.fromCache || !this.cache) + return this.internalGet(triggerHook) + + const key = generateKey(this.model.$entity(), { + where: this.wheres, + groups: this.groups, + orders: this.orders, + eagerLoads: this.eagerLoad, + skip: this.skip, + take: this.take, + hidden: this.hidden, + visible: this.visible, + }) + const result = this.cache.get(key) + + if (result) + return result + + const queryResult = this.internalGet(triggerHook) + this.cache.set(key, queryResult) + return queryResult } private internalGet(triggerHook: boolean): Collection | GroupedCollection { @@ -591,7 +606,7 @@ export class Query { reviveOne(schema: Element): Item { const id = this.model.$getIndexId(schema) - const item = this.commit('get').data[id] ?? null + const item = this.commit('get')[id] ?? null if (!item) return null @@ -700,7 +715,6 @@ export class Query { query.saveElements(elements) } - return this.revive(data) as M | M[] } @@ -709,15 +723,15 @@ export class Query { */ saveElements(elements: Elements): void { const newData = {} as Elements - const { data: currentData, config } = this.commit('all') + const currentData = this.commit('all') const afterSavingHooks = [] for (const id in elements) { const record = elements[id] const existing = currentData[id] const model = existing - ? this.hydrate({ ...existing, ...record }, { operation: 'set', action: 'update', config: config.model }) - : this.hydrate(record, { operation: 'set', action: 'save', config: config.model }) + ? this.hydrate({ ...existing, ...record }, { operation: 'set', action: 'update' }) + : this.hydrate(record, { operation: 'set', action: 'save' }) const isSaving = model.$self().saving(model) const isUpdatingOrCreating = existing ? model.$self().updating(model) : model.$self().creating(model) diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index 7a0c5cd63..94c5c0d4b 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -16,7 +16,9 @@ import type { } from '../query/Options' import { useRepo } from '../composables/useRepo' import { useDataStore } from '../composables/useDataStore' -import { Cache } from '../query/Cache' +import { cache } from '../data/SharedCache' +import type { WeakCache } from '../cache/WeakCache' +import { config } from '../store/Config' export class Repository { /** @@ -38,7 +40,7 @@ export class Repository { protected pinia?: Pinia - protected queryCache: Cache + queryCache?: WeakCache /** * The model object to be used for the custom repository. @@ -51,13 +53,16 @@ export class Repository { constructor(database: Database, pinia?: Pinia) { this.database = database this.pinia = pinia - this.queryCache = new Cache() } /** * Initialize the repository by setting the model instance. */ initialize(model?: ModelConstructor): this { + if (config.cache) + // eslint-disable-next-line new-cap + this.queryCache = (config.cache.shared ? cache : new config.cache.provider()) as WeakCache + // If there's a model passed in, just use that and return immediately. if (model) { this.model = model.newRawInstance() @@ -118,7 +123,7 @@ export class Repository { /** * Create a new Query instance. */ - cache(): Cache { + cache(): WeakCache | undefined { return this.queryCache } diff --git a/packages/pinia-orm/src/store/Config.ts b/packages/pinia-orm/src/store/Config.ts new file mode 100644 index 000000000..0cd001c98 --- /dev/null +++ b/packages/pinia-orm/src/store/Config.ts @@ -0,0 +1,16 @@ +import { WeakCache } from '../cache/WeakCache' +import type { FilledInstallOptions } from './Store' + +export const CONFIG_DEFAULTS = { + model: { + withMeta: false, + hidden: ['_meta'], + visible: ['*'], + }, + cache: { + shared: true, + provider: WeakCache, + }, +} + +export const config: FilledInstallOptions = { ...CONFIG_DEFAULTS } diff --git a/packages/pinia-orm/src/store/Store.ts b/packages/pinia-orm/src/store/Store.ts index 0dffdcd3b..5062a61a3 100644 --- a/packages/pinia-orm/src/store/Store.ts +++ b/packages/pinia-orm/src/store/Store.ts @@ -1,4 +1,7 @@ -import type { PiniaPlugin, PiniaPluginContext } from 'pinia' +import type { PiniaPlugin } from 'pinia' +import type { WeakCache } from '../cache/WeakCache' +import type { Model } from '../model/Model' +import { CONFIG_DEFAULTS, config } from './Config' export interface ModelConfigOptions { withMeta?: boolean @@ -6,8 +9,14 @@ export interface ModelConfigOptions { visible?: string[] } +export interface CacheConfigOptions { + shared?: boolean + provider: typeof WeakCache +} + export interface InstallOptions { model?: ModelConfigOptions + cache?: CacheConfigOptions | false } export type FilledInstallOptions = Required @@ -16,20 +25,7 @@ export type FilledInstallOptions = Required * Install Pinia ORM to the store. */ export function createORM(options?: InstallOptions): PiniaPlugin { - return (context: PiniaPluginContext) => { - context.store.$state.config = createOptions(options) - } -} - -/** - * Create options by merging the given user-provided options. - */ -export function createOptions(options: InstallOptions = {}): FilledInstallOptions { - return { - model: { - withMeta: options.model?.withMeta ?? false, - hidden: options.model?.hidden ?? ['_meta'], - visible: options.model?.visible ?? ['*'], - }, - } + config.model = { ...CONFIG_DEFAULTS.model, ...options?.model } + config.cache = options?.cache === false ? options?.cache : { ...CONFIG_DEFAULTS.cache, ...options?.cache } + return () => {} } diff --git a/packages/pinia-orm/src/support/Utils.ts b/packages/pinia-orm/src/support/Utils.ts index 155977602..f484cfa7c 100644 --- a/packages/pinia-orm/src/support/Utils.ts +++ b/packages/pinia-orm/src/support/Utils.ts @@ -216,6 +216,18 @@ export function generateId(size: number, urlAlphabet: string) { return id } +/** + * Get a unique string for an key with object params + */ +export function generateKey(key: string, params?: any): string { + const keyValues = params ? { key, params } : { key } + const stringifiedKey = JSON.stringify(keyValues) + + // This check allows to generate base64 strings depending on the current environment. + // If the window object exists, we can assume this code is running in a browser. + return typeof process === 'undefined' ? btoa(stringifiedKey) : Buffer.from(stringifiedKey, 'utf8').toString('base64') +} + /** * Get a value based on a dot-notation key. */ diff --git a/packages/pinia-orm/src/types/index.ts b/packages/pinia-orm/src/types/index.ts index 726faf254..7ada6549d 100644 --- a/packages/pinia-orm/src/types/index.ts +++ b/packages/pinia-orm/src/types/index.ts @@ -12,3 +12,8 @@ export interface MutatorFunctions { export interface Mutators { [name: string]: MutatorFunctions | Mutator } + +export interface CacheConfig { + key?: string + params?: Record +} diff --git a/packages/pinia-orm/tests/helpers.ts b/packages/pinia-orm/tests/helpers.ts index d8ff696c0..c38afcdd0 100644 --- a/packages/pinia-orm/tests/helpers.ts +++ b/packages/pinia-orm/tests/helpers.ts @@ -9,7 +9,7 @@ import { expect, vi } from 'vitest' import { createApp } from 'vue-demi' import type { Collection, Elements, InstallOptions, Model } from '../src' import * as Utils from '../src/support/Utils' -import { createORM, createOptions } from '../src' +import { createORM } from '../src' interface Entities { [name: string]: Elements @@ -23,12 +23,12 @@ export function createPiniaORM(options?: InstallOptions) { setActivePinia(pinia) } -export function createState(entities: Entities, config?: InstallOptions): any { +export function createState(entities: Entities): any { const state = {} as any for (const entity in entities) { if (!state[entity]) - state[entity] = { data: {}, config: createOptions(config) } + state[entity] = { data: {} } state[entity].data = entities[entity] } @@ -42,8 +42,8 @@ export function fillState(entities: Entities): void { getActivePinia().state.value = createState(entities) } -export function assertState(entities: Entities, config?: InstallOptions): void { - expect(getActivePinia()?.state.value).toEqual(createState(entities, config)) +export function assertState(entities: Entities): void { + expect(getActivePinia()?.state.value).toEqual(createState(entities)) } export function assertModel( diff --git a/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts b/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts index 3beb45488..056a73d50 100644 --- a/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts +++ b/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts @@ -38,21 +38,27 @@ describe('performance/save_has_many_relation', () => { console.time('time') userRepo.save(users) console.timeEnd('time') - console.log('Get Speed test for 10k saved items and 5 queries') - console.time('get(): with cache') - for (let i = 1; i <= 5; i++) { - console.time(`time query ${i}`) - userRepo.with('posts').get() - console.timeEnd(`time query ${i}`) - } - console.timeEnd('get(): with cache') - - console.time('get(): without cache') - for (let i = 1; i <= 5; i++) { - console.time(`time query without ${i}`) - userRepo.with('posts').useCache(false).get() - console.timeEnd(`time query without ${i}`) - } - console.timeEnd('get(): without cache') + // console.log('Get Speed test for 10k saved items and 5 queries') + // console.time('get(): with cache') + // for (let i = 1; i <= 5; i++) { + // console.time(`time query ${i}`) + // userRepo.with('posts').get() + // console.timeEnd(`time query ${i}`) + // } + // console.timeEnd('get(): with cache') + // console.log(userRepo.cache().size) + // console.log(useRepo(User).cache().size) + // console.log(useRepo(Post).cache().size) + // + // console.time('get(): without cache') + // for (let i = 1; i <= 5; i++) { + // console.time(`time query without ${i}`) + // userRepo.with('posts').useCache(false).get() + // console.timeEnd(`time query without ${i}`) + // } + // console.timeEnd('get(): without cache') + // console.log(userRepo.cache().size) + // console.log(useRepo(User).cache().size) + // console.log(useRepo(Post).cache().size) }) }) diff --git a/packages/pinia-orm/tests/setup.ts b/packages/pinia-orm/tests/setup.ts index 479e04a12..d35efa9d1 100644 --- a/packages/pinia-orm/tests/setup.ts +++ b/packages/pinia-orm/tests/setup.ts @@ -32,7 +32,7 @@ beforeAll(() => { beforeEach(() => { const app = createApp({}) const pinia = createPinia() - pinia.use(createORM()) + pinia.use(createORM({ cache: false })) app.use(pinia) setActivePinia(pinia) Model.clearBootedModels() From c116543f51908737704a5da104557f2a95c23507 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Wed, 14 Sep 2022 16:56:35 +0200 Subject: [PATCH 3/5] refactor(pinia-orm): add tests & making caching false by default --- .../SharedWeakCache.ts} | 2 +- packages/pinia-orm/src/cache/SimpleCache.ts | 24 +----- packages/pinia-orm/src/model/Model.ts | 12 --- packages/pinia-orm/src/query/Query.ts | 25 ++++--- .../pinia-orm/src/repository/Repository.ts | 17 ++++- packages/pinia-orm/src/store/Store.ts | 6 +- .../feature/repository/retrieves_find.spec.ts | 32 ++++++++ .../save_has_many_relation.spec.ts | 46 ++++++------ packages/pinia-orm/tests/setup.ts | 2 +- .../pinia-orm/tests/unit/PiniaORM.spec.ts | 71 ++++++++++++++++++ .../tests/unit/cache/Weakcache.spec.ts | 75 +++++++++++++++++++ .../unit/model/Model_Casts_Array.spec.ts | 2 + .../unit/model/Model_Casts_Custom.spec.ts | 17 +++++ 13 files changed, 258 insertions(+), 73 deletions(-) rename packages/pinia-orm/src/{data/SharedCache.ts => cache/SharedWeakCache.ts} (67%) create mode 100644 packages/pinia-orm/tests/unit/cache/Weakcache.spec.ts diff --git a/packages/pinia-orm/src/data/SharedCache.ts b/packages/pinia-orm/src/cache/SharedWeakCache.ts similarity index 67% rename from packages/pinia-orm/src/data/SharedCache.ts rename to packages/pinia-orm/src/cache/SharedWeakCache.ts index 34451ccc9..c8a87286b 100644 --- a/packages/pinia-orm/src/data/SharedCache.ts +++ b/packages/pinia-orm/src/cache/SharedWeakCache.ts @@ -1,4 +1,4 @@ -import { WeakCache } from '../cache/WeakCache' import type { Model } from '../model/Model' +import { WeakCache } from './WeakCache' export const cache = new WeakCache() diff --git a/packages/pinia-orm/src/cache/SimpleCache.ts b/packages/pinia-orm/src/cache/SimpleCache.ts index 4190a04f7..abedc8c35 100644 --- a/packages/pinia-orm/src/cache/SimpleCache.ts +++ b/packages/pinia-orm/src/cache/SimpleCache.ts @@ -1,3 +1,5 @@ +import { generateKey } from '../../src/support/Utils' + export interface FetchParams { key: string params?: any @@ -36,7 +38,7 @@ export class SimpleCache implements IStorageCache { callback, expiresInSeconds = DEFAULT_EXPIRATION_SECONDS, }: FetchParams): T { - const cacheKey = this.generateKey({ key, params }) + const cacheKey = generateKey(key, params) const data = this.get(cacheKey) const expiration = this.computeExpirationTime(expiresInSeconds) @@ -55,26 +57,6 @@ export class SimpleCache implements IStorageCache { return new Date().getTime() + expiresInSeconds * 1000 } - // This method returns a base64 string containing a combination of a key and parameters - // creating a unique identifier for a specific key and specific parameters. This is - // useful in case the callback returns different values based on parameters. - private generateKey({ key, params }: KeyParams): string { - const keyValues = params ? { key, params } : { key } - const stringifiedKey = JSON.stringify(keyValues) - - // This check allows to generate base64 strings depending on the current environment. - // If the window object exists, we can assume this code is running in a browser. - if (typeof process === 'undefined') { - return btoa(stringifiedKey) - } - else { - const bufferObj = Buffer.from(stringifiedKey, 'utf8') - const base64String = bufferObj.toString('base64') - - return base64String - } - } - // Store the data in memory and attach to the object expiration containing the // expiration time. private set({ key, data, expiration }: StoredData): T { diff --git a/packages/pinia-orm/src/model/Model.ts b/packages/pinia-orm/src/model/Model.ts index 9f6b8c32d..d6b750210 100644 --- a/packages/pinia-orm/src/model/Model.ts +++ b/packages/pinia-orm/src/model/Model.ts @@ -63,11 +63,6 @@ export class Model { */ static entity: string - /** - * Defines caching behaviour - */ - static caching = true - /** * The reference to the base entity name if the class extends a base entity. */ @@ -514,13 +509,6 @@ export class Model { return this.$self().entity } - /** - * Get the cache config of this model. - */ - $cache(): boolean { - return this.$self().caching - } - /** * Get the model config. */ diff --git a/packages/pinia-orm/src/query/Query.ts b/packages/pinia-orm/src/query/Query.ts index b4607e301..724482526 100644 --- a/packages/pinia-orm/src/query/Query.ts +++ b/packages/pinia-orm/src/query/Query.ts @@ -348,6 +348,9 @@ export class Query { }) } + /** + * Define to use the cache for a query + */ useCache(key?: string, params?: Record): this { this.fromCache = true this.cacheConfig = { @@ -395,16 +398,18 @@ export class Query { if (!this.fromCache || !this.cache) return this.internalGet(triggerHook) - const key = generateKey(this.model.$entity(), { - where: this.wheres, - groups: this.groups, - orders: this.orders, - eagerLoads: this.eagerLoad, - skip: this.skip, - take: this.take, - hidden: this.hidden, - visible: this.visible, - }) + const key = this.cacheConfig.key + ? this.cacheConfig.key + JSON.stringify(this.cacheConfig.params) + : generateKey(this.model.$entity(), { + where: this.wheres, + groups: this.groups, + orders: this.orders, + eagerLoads: this.eagerLoad, + skip: this.skip, + take: this.take, + hidden: this.hidden, + visible: this.visible, + }) const result = this.cache.get(key) if (result) diff --git a/packages/pinia-orm/src/repository/Repository.ts b/packages/pinia-orm/src/repository/Repository.ts index 94c5c0d4b..0526c9b37 100644 --- a/packages/pinia-orm/src/repository/Repository.ts +++ b/packages/pinia-orm/src/repository/Repository.ts @@ -16,7 +16,7 @@ import type { } from '../query/Options' import { useRepo } from '../composables/useRepo' import { useDataStore } from '../composables/useDataStore' -import { cache } from '../data/SharedCache' +import { cache } from '../cache/SharedWeakCache' import type { WeakCache } from '../cache/WeakCache' import { config } from '../store/Config' @@ -38,8 +38,14 @@ export class Repository { */ protected model!: M + /** + * The pinia instance + */ protected pinia?: Pinia + /** + * The cache instance + */ queryCache?: WeakCache /** @@ -59,7 +65,7 @@ export class Repository { * Initialize the repository by setting the model instance. */ initialize(model?: ModelConstructor): this { - if (config.cache) + if (config.cache && config.cache !== true) // eslint-disable-next-line new-cap this.queryCache = (config.cache.shared ? cache : new config.cache.provider()) as WeakCache @@ -273,6 +279,13 @@ export class Repository { return this.query().withAllRecursive(depth) } + /** + * Define to use the cache for a query + */ + useCache(key?: string, params?: Record): Query { + return this.query().useCache(key, params) + } + /** * Get all models from the store. */ diff --git a/packages/pinia-orm/src/store/Store.ts b/packages/pinia-orm/src/store/Store.ts index 5062a61a3..186c9e189 100644 --- a/packages/pinia-orm/src/store/Store.ts +++ b/packages/pinia-orm/src/store/Store.ts @@ -11,12 +11,12 @@ export interface ModelConfigOptions { export interface CacheConfigOptions { shared?: boolean - provider: typeof WeakCache + provider?: typeof WeakCache } export interface InstallOptions { model?: ModelConfigOptions - cache?: CacheConfigOptions | false + cache?: CacheConfigOptions | boolean } export type FilledInstallOptions = Required @@ -26,6 +26,6 @@ export type FilledInstallOptions = Required */ export function createORM(options?: InstallOptions): PiniaPlugin { config.model = { ...CONFIG_DEFAULTS.model, ...options?.model } - config.cache = options?.cache === false ? options?.cache : { ...CONFIG_DEFAULTS.cache, ...options?.cache } + config.cache = options?.cache === false ? options?.cache : { ...CONFIG_DEFAULTS.cache, ...(options?.cache !== true && options?.cache) } return () => {} } diff --git a/packages/pinia-orm/tests/feature/repository/retrieves_find.spec.ts b/packages/pinia-orm/tests/feature/repository/retrieves_find.spec.ts index e19aaf189..8440eeab6 100644 --- a/packages/pinia-orm/tests/feature/repository/retrieves_find.spec.ts +++ b/packages/pinia-orm/tests/feature/repository/retrieves_find.spec.ts @@ -70,4 +70,36 @@ describe('feature/repository/retrieves_find', () => { { id: 3, name: 'Johnny Doe' }, ]) }) + + it('can find records by ids with cache usage', () => { + const userRepo = useRepo(User) + + fillState({ + users: { + 1: { id: 1, name: 'John Doe' }, + 2: { id: 2, name: 'Jane Doe' }, + 3: { id: 3, name: 'Johnny Doe' }, + }, + }) + + const users = userRepo.useCache().find([1, 3]) + + expect(users.length).toBe(2) + assertInstanceOf(users, User) + assertModels(users, [ + { id: 1, name: 'John Doe' }, + { id: 3, name: 'Johnny Doe' }, + ]) + + const users2 = userRepo.useCache('users', { + ids: [1, 3], + }).find([1, 3]) + + expect(users2.length).toBe(2) + assertInstanceOf(users2, User) + assertModels(users2, [ + { id: 1, name: 'John Doe' }, + { id: 3, name: 'Johnny Doe' }, + ]) + }) }) diff --git a/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts b/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts index 056a73d50..d232cf425 100644 --- a/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts +++ b/packages/pinia-orm/tests/performance/save_has_many_relation.spec.ts @@ -1,4 +1,4 @@ -import { describe, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { Model, useRepo } from '../../src' import { HasMany, Num, Str } from '../../src/decorators' @@ -38,27 +38,27 @@ describe('performance/save_has_many_relation', () => { console.time('time') userRepo.save(users) console.timeEnd('time') - // console.log('Get Speed test for 10k saved items and 5 queries') - // console.time('get(): with cache') - // for (let i = 1; i <= 5; i++) { - // console.time(`time query ${i}`) - // userRepo.with('posts').get() - // console.timeEnd(`time query ${i}`) - // } - // console.timeEnd('get(): with cache') - // console.log(userRepo.cache().size) - // console.log(useRepo(User).cache().size) - // console.log(useRepo(Post).cache().size) - // - // console.time('get(): without cache') - // for (let i = 1; i <= 5; i++) { - // console.time(`time query without ${i}`) - // userRepo.with('posts').useCache(false).get() - // console.timeEnd(`time query without ${i}`) - // } - // console.timeEnd('get(): without cache') - // console.log(userRepo.cache().size) - // console.log(useRepo(User).cache().size) - // console.log(useRepo(Post).cache().size) + console.log('Get Speed test for 10k saved items and 5 queries') + console.time('get(): with cache') + const timeStart = performance.now() + for (let i = 1; i <= 5; i++) { + console.time(`time query ${i}`) + userRepo.useCache().with('posts').get() + console.timeEnd(`time query ${i}`) + } + const useCacheTime = performance.now() + console.timeEnd('get(): with cache') + console.time('get(): without cache') + for (let i = 1; i <= 5; i++) { + console.time(`time query without ${i}`) + userRepo.with('posts').get() + console.timeEnd(`time query without ${i}`) + } + const useWtihoutCacheTime = performance.now() + console.timeEnd('get(): without cache') + console.log(`Time with Cache ${useCacheTime - timeStart}, without: ${useWtihoutCacheTime - useCacheTime}`) + + expect(useCacheTime - timeStart).toBeLessThan(useWtihoutCacheTime - useCacheTime) + expect(userRepo.cache()?.size).toBe(1) }) }) diff --git a/packages/pinia-orm/tests/setup.ts b/packages/pinia-orm/tests/setup.ts index d35efa9d1..479e04a12 100644 --- a/packages/pinia-orm/tests/setup.ts +++ b/packages/pinia-orm/tests/setup.ts @@ -32,7 +32,7 @@ beforeAll(() => { beforeEach(() => { const app = createApp({}) const pinia = createPinia() - pinia.use(createORM({ cache: false })) + pinia.use(createORM()) app.use(pinia) setActivePinia(pinia) Model.clearBootedModels() diff --git a/packages/pinia-orm/tests/unit/PiniaORM.spec.ts b/packages/pinia-orm/tests/unit/PiniaORM.spec.ts index 8bcd2823f..6813588be 100644 --- a/packages/pinia-orm/tests/unit/PiniaORM.spec.ts +++ b/packages/pinia-orm/tests/unit/PiniaORM.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { Model, useRepo } from '../../src' import { Attr, Str } from '../../src/decorators' import { createPiniaORM } from '../helpers' +import { WeakCache } from '../../src/cache/WeakCache' describe('unit/PiniaORM', () => { class User extends Model { @@ -62,4 +63,74 @@ describe('unit/PiniaORM', () => { expect(userRepo.find(1)?.username).toBe(undefined) expect(userRepo.find(1)?.name).toBe('John') }) + + it('pass config "cache false"', () => { + Model.clearRegistries() + class User extends Model { + static entity = 'users' + + @Attr(0) declare id: number + @Str('') declare name: string + @Str('') declare username: string + } + + createPiniaORM({ cache: false }) + + const userRepo = useRepo(User) + userRepo.save({ + id: 1, + name: 'John', + username: 'JD', + }) + + expect(userRepo.cache()).toBe(undefined) + }) + + it('pass config "cache true"', () => { + Model.clearRegistries() + class User extends Model { + static entity = 'users' + + @Attr(0) declare id: number + @Str('') declare name: string + @Str('') declare username: string + } + + createPiniaORM({ cache: true }) + + const userRepo = useRepo(User) + userRepo.save({ + id: 1, + name: 'John', + username: 'JD', + }) + + expect(userRepo.cache()).toBeInstanceOf(WeakCache) + }) + + it('pass config "cache.shared false"', () => { + Model.clearRegistries() + class User extends Model { + static entity = 'users' + + @Attr(0) declare id: number + @Str('') declare name: string + @Str('') declare username: string + } + + createPiniaORM({ + cache: { + shared: false, + }, + }) + + const userRepo = useRepo(User) + userRepo.save({ + id: 1, + name: 'John', + username: 'JD', + }) + + expect(userRepo.cache()).toBeInstanceOf(WeakCache) + }) }) diff --git a/packages/pinia-orm/tests/unit/cache/Weakcache.spec.ts b/packages/pinia-orm/tests/unit/cache/Weakcache.spec.ts new file mode 100644 index 000000000..c37c4d5cb --- /dev/null +++ b/packages/pinia-orm/tests/unit/cache/Weakcache.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import { WeakCache } from '../../../src/cache/WeakCache' + +describe('unit/support/Weakcache', () => { + const data = [ + { + id: 1, + name: 'Test', + }, + { + id: 2, + name: 'Test2', + }, + ] + const cache = new WeakCache() + + it('can cache data', () => { + cache.set('key1', data) + expect(cache.get('key1')).toEqual(data) + }) + + it('can use size getter', () => { + cache.set('key2', data) + expect(cache.get('key1')).toEqual(data) + expect(cache.get('key2')).toEqual(data) + expect(cache.get('key3')).toEqual(undefined) + expect(cache.size).toEqual(2) + }) + + it('can use foreach function', () => { + cache.forEach((value, key, map) => { + expect(map).toBeInstanceOf(WeakCache) + // @ts-expect-error saving only string keys + expect(key.includes('key')).toBeTruthy() + expect(value).toEqual(data) + }) + }) + + it('can iterate values', () => { + const values = cache.values() + expect(values.next().value).toEqual(data) + expect(values.next().value).toEqual(data) + expect(values.next().value).toBeUndefined() + }) + + it('can iterate keys', () => { + const values = cache.keys() + expect(values.next().value).toEqual('key1') + expect(values.next().value).toEqual('key2') + expect(values.next().value).toBeUndefined() + }) + + it('can iterate entries', () => { + const values = cache.entries() + expect(values.next().value).toEqual(['key1', data]) + expect(values.next().value).toEqual(['key2', data]) + expect(values.next().value).toBeUndefined() + }) + + it('can delete items', () => { + cache.delete('key2') + expect(cache.size).toEqual(1) + }) + + it('can use has for search', () => { + expect(cache.has('key1')).toBeTruthy() + expect(cache.has('key2')).toBeFalsy() + }) + + it('can clear all', () => { + cache.clear() + expect(cache.size).toEqual(0) + }) +}) diff --git a/packages/pinia-orm/tests/unit/model/Model_Casts_Array.spec.ts b/packages/pinia-orm/tests/unit/model/Model_Casts_Array.spec.ts index e3a2f9004..f279e0f86 100644 --- a/packages/pinia-orm/tests/unit/model/Model_Casts_Array.spec.ts +++ b/packages/pinia-orm/tests/unit/model/Model_Casts_Array.spec.ts @@ -28,6 +28,8 @@ describe('unit/model/Model_Casts_Array', () => { age: 30, car: null, }) + + expect(new User({ meta: false }, { operation: 'get' }).meta).toStrictEqual(false) }) it('should cast with decorator', () => { diff --git a/packages/pinia-orm/tests/unit/model/Model_Casts_Custom.spec.ts b/packages/pinia-orm/tests/unit/model/Model_Casts_Custom.spec.ts index 7b71b0d6a..6169041c9 100644 --- a/packages/pinia-orm/tests/unit/model/Model_Casts_Custom.spec.ts +++ b/packages/pinia-orm/tests/unit/model/Model_Casts_Custom.spec.ts @@ -7,6 +7,23 @@ describe('unit/model/Model_Casts_Custom', () => { beforeEach(() => { Model.clearRegistries() }) + + it('should cast with default cast class', () => { + class User extends Model { + static entity = 'users' + + @Attr('{}') name!: string + + static casts() { + return { + name: CastAttribute, + } + } + } + + expect(new User({ name: 'John' }).name).toBe('John') + }) + it('should cast', () => { class CustomCast extends CastAttribute { get(value?: any): any { From 94a7b989aab02d005822e102162366601a30ef43 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Wed, 14 Sep 2022 17:59:10 +0200 Subject: [PATCH 4/5] refactor(pinia-orm): fix types --- packages/pinia-orm/src/store/Store.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/pinia-orm/src/store/Store.ts b/packages/pinia-orm/src/store/Store.ts index 186c9e189..bc2363e91 100644 --- a/packages/pinia-orm/src/store/Store.ts +++ b/packages/pinia-orm/src/store/Store.ts @@ -19,13 +19,16 @@ export interface InstallOptions { cache?: CacheConfigOptions | boolean } -export type FilledInstallOptions = Required +export interface FilledInstallOptions { + model: Required + cache: Required +} /** * Install Pinia ORM to the store. */ export function createORM(options?: InstallOptions): PiniaPlugin { config.model = { ...CONFIG_DEFAULTS.model, ...options?.model } - config.cache = options?.cache === false ? options?.cache : { ...CONFIG_DEFAULTS.cache, ...(options?.cache !== true && options?.cache) } + config.cache = options?.cache === false ? false : { ...CONFIG_DEFAULTS.cache, ...(options?.cache !== true && options?.cache) } return () => {} } From 39c509ced8b03f9dcfc8d61d2ee24200c0768075 Mon Sep 17 00:00:00 2001 From: Gregor Becker Date: Thu, 15 Sep 2022 10:05:30 +0200 Subject: [PATCH 5/5] docs(pinia-orm): add cache --- .../1.guide/4.repository/2.retrieving-data.md | 45 +++++++++++++++++++ docs/content/2.api/3.query/use-cache.md | 26 +++++++++++ docs/content/2.api/4.repository/cache.md | 20 +++++++++ docs/content/2.api/5.configuration.md | 13 ++++++ packages/pinia-orm/src/cache/SimpleCache.ts | 43 ++---------------- 5 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 docs/content/2.api/3.query/use-cache.md create mode 100644 docs/content/2.api/4.repository/cache.md diff --git a/docs/content/1.guide/4.repository/2.retrieving-data.md b/docs/content/1.guide/4.repository/2.retrieving-data.md index 692f32f28..c0a544fbb 100644 --- a/docs/content/1.guide/4.repository/2.retrieving-data.md +++ b/docs/content/1.guide/4.repository/2.retrieving-data.md @@ -163,6 +163,51 @@ const users = useRepo(User) const users = useRepo(User).groupBy('name', 'age').get() ``` +## Caching + +The `useCache` method allows you to cache the results if your store requests are more frequent. +The initial retrieve query will be a bit smaller but all next are very fast. + + +For caching a custom [Weakref](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakRef) provider is used. That way garbage collection is included. + + +```js +// Generate a cache with a auto generated key. +const users = useRepo(User).useCache().get() + +// Generate a cache with a manual key (recommanded). +const users = useRepo(User).useCache('key').get() + +// Generate a cache with a manual key and dynamic params (recommanded). +const users = useRepo(User).useCache('key', { id: idProperty }).get() +``` + +You can access the current cache instance with the `cache` method + +```js +// Get the repos cache instance +const cache = useRepo(User).cache() + +// Getting the size of the current cache +useRepo(User).cache().size + +// Checking if a specific key exist +useRepo(User).cache().has('key') +``` + +As a default configuration the cache is shared between the repos. If you don't want it +to be shared you can set it in the config. + +```js +// Setting the cache not to be shared for every repo +createORM({ + cache: { + shared: false, + }, +}) +``` + ## Limit & Offset diff --git a/docs/content/2.api/3.query/use-cache.md b/docs/content/2.api/3.query/use-cache.md new file mode 100644 index 000000000..ccc67db74 --- /dev/null +++ b/docs/content/2.api/3.query/use-cache.md @@ -0,0 +1,26 @@ +--- +title: 'useCache()' +description: 'Cache the query result.' +--- + +## Usage + +````ts +import { useRepo } from 'pinia-orm' +import User from './models/User' + +// Generate a cache with a auto generated key. +useRepo(User).useCache().get() + +// Generate a cache with a manual key (recommanded). +useRepo(User).useCache('key').get() + +// Generate a cache with a manual key and dynamic params (recommanded). +useRepo(User).useCache('key', { id: idProperty }).get() +```` + +## Typescript Declarations + +````ts +function useCache(key?: string, params?: Record): Query +```` diff --git a/docs/content/2.api/4.repository/cache.md b/docs/content/2.api/4.repository/cache.md new file mode 100644 index 000000000..2ad9aabc1 --- /dev/null +++ b/docs/content/2.api/4.repository/cache.md @@ -0,0 +1,20 @@ +--- +title: 'cache' +description: 'Returns the cache instance of the repository' +--- + +## Usage + +````js +import { useRepo } from 'pinia-orm' +import User from './models/User' + +// Returns the cache instance +useRepo(User).cache() +```` + +## Typescript Declarations + +````ts +function cache(): WeakCache +```` diff --git a/docs/content/2.api/5.configuration.md b/docs/content/2.api/5.configuration.md index 7baf0722c..f0ef641e7 100644 --- a/docs/content/2.api/5.configuration.md +++ b/docs/content/2.api/5.configuration.md @@ -12,6 +12,13 @@ icon: heroicons-outline:adjustments | `visible` | `[*]` | Sets default visible fields for every model | | `hidden` | `[]` | Sets default hidden fields for every model | +## `cache` + +| Option | Default | Description | +|------------|:-----------:|:----------------------------------------------------------| +| `provider` | `Weakcache` | Defines which cache provider should be used | +| `shared` | `true` | Activates the cache to be shared between all repositories | + ## Typescript Declarations ````ts @@ -21,8 +28,14 @@ export interface ModelConfigOptions { visible?: string[] } +export interface CacheConfigOptions { + shared?: boolean + provider?: typeof WeakCache +} + export interface InstallOptions { model?: ModelConfigOptions + cache?: CacheConfigOptions | boolean } const options: InstallOptions ```` diff --git a/packages/pinia-orm/src/cache/SimpleCache.ts b/packages/pinia-orm/src/cache/SimpleCache.ts index abedc8c35..9161544c4 100644 --- a/packages/pinia-orm/src/cache/SimpleCache.ts +++ b/packages/pinia-orm/src/cache/SimpleCache.ts @@ -1,50 +1,15 @@ -import { generateKey } from '../../src/support/Utils' - -export interface FetchParams { - key: string - params?: any - callback(): any - expiresInSeconds?: number -} - -export interface KeyParams { - key: string - params?: any -} - export interface StoredData { key: string data: T expiration: number } -export interface IStorageCache { - fetch(params: FetchParams): Promise | T -} - // By default data will expire in 5 minutes. const DEFAULT_EXPIRATION_SECONDS = 5 * 60 -export class SimpleCache implements IStorageCache { +export class SimpleCache { constructor(private cache = new Map()) {} - // The fetch method, before calling callback, will check if there is cached data. - // If cached data is not available, it will call callback, store the data in memory - // and return it. If cached data is available, it won't call callback and it will - // just return the cached values. - fetch({ - key, - params = null, - callback, - expiresInSeconds = DEFAULT_EXPIRATION_SECONDS, - }: FetchParams): T { - const cacheKey = generateKey(key, params) - const data = this.get(cacheKey) - const expiration = this.computeExpirationTime(expiresInSeconds) - - return data || this.set({ key: cacheKey, data: callback(), expiration }) - } - clear(): void { this.cache = new Map() } @@ -59,15 +24,15 @@ export class SimpleCache implements IStorageCache { // Store the data in memory and attach to the object expiration containing the // expiration time. - private set({ key, data, expiration }: StoredData): T { - this.cache.set(key, { data, expiration }) + set({ key, data, expiration = DEFAULT_EXPIRATION_SECONDS }: StoredData): T { + this.cache.set(key, { data, expiration: this.computeExpirationTime(expiration) }) return data } // Will get specific data from the Map object based on a key and return null if // the data has expired. - private get(key: string): T | null { + get(key: string): T | null { if (this.cache.has(key)) { const { data, expiration } = this.cache.get(key) as StoredData