Skip to content

Commit

Permalink
perf(pinia-orm-659): Save hydrated models if not updated (#671)
Browse files Browse the repository at this point in the history
* perf(pinia-orm-659): Save hydrated models if not updated

* refactor(pinia-orm): typo

closes #659
  • Loading branch information
CodeDredd committed Dec 6, 2022
1 parent 59593a3 commit 4cb8bae
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 26 deletions.
2 changes: 2 additions & 0 deletions packages/pinia-orm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,14 @@
},
"devDependencies": {
"@antfu/eslint-config": "^0.31.0",
"@pinia/testing": "^0.0.14",
"@size-limit/preset-small-lib": "^8.1.0",
"@types/prettier": "^2",
"@types/uuid": "^8.3.4",
"@vitest/coverage-c8": "^0.25.3",
"@vitest/ui": "^0.25.3",
"@vue/composition-api": "^1.7.1",
"@vue/test-utils": "^2.2.6",
"c8": "^7.12.0",
"core-js": "^3.26.1",
"eslint": "^8.28.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Collection, Model } from '../../../src'
import type { SortFlags } from '../../support/Utils'
import { useSum } from './useSum'
import { useMax } from './useMax'
import { useMin } from './useMin'
Expand All @@ -7,7 +8,6 @@ import { useKeys } from './useKeys'
import { useGroupBy } from './useGroupBy'
import type { sorting } from './useSortBy'
import { useSortBy } from './useSortBy'
import type { SortFlags } from '@/support/Utils'

export interface UseCollect<M extends Model = Model> {
sum: (field: string) => number
Expand Down
9 changes: 4 additions & 5 deletions packages/pinia-orm/src/composables/useDataStore.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import { defineStore } from 'pinia'
import type { Model } from '../model/Model'
import { useStoreActions } from './useStoreActions'

export function useDataStore<M extends Model = Model>(
export function useDataStore(
id: string,
options: Record<string, any> | null = null,
) {
return defineStore(id, {
state: (): DataStoreState<M> => ({ data: {} }),
state: (): DataStoreState => ({ data: {} }),
actions: useStoreActions(),
...options,
})
}

export interface DataStoreState<M extends Model = Model> {
data: Record<string, M>
export interface DataStoreState {
data: Record<string, Record<string, any>>
}

export type DataStore = ReturnType<typeof import('@/composables')['useDataStore']>
54 changes: 36 additions & 18 deletions packages/pinia-orm/src/query/Query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,28 +98,34 @@ export class Query<M extends Model = Model> {

protected cacheConfig: CacheConfig = {}

/**
* Hydrated models. They are stored to prevent rerendering of child components.
*/
hydratedData: Map<string, M>

/**
* Create a new query instance.
*/
constructor(database: Database, model: M, cache: WeakCache<string, Collection<M> | GroupedCollection<M>> | undefined, pinia?: Pinia) {
constructor(database: Database, model: M, cache: WeakCache<string, Collection<M> | GroupedCollection<M>> | undefined, hydratedData: Map<string, M>, pinia?: Pinia) {
this.database = database
this.model = model
this.pinia = pinia
this.cache = cache
this.hydratedData = hydratedData
}

/**
* 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, new Map(), 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.cache, this.pinia)
const newQuery = new Query(this.database, this.database.getModel(model), this.cache, this.hydratedData, this.pinia)

// Copy query constraints
newQuery.eagerLoad = { ...this.eagerLoad }
Expand All @@ -137,7 +143,7 @@ export class Query<M extends Model = Model> {
* 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, new Map(), this.pinia)
}

/**
Expand All @@ -151,7 +157,7 @@ export class Query<M extends Model = Model> {
* Commit a store action and get the data
*/
protected commit(name: string, payload?: any) {
const store = useDataStore<M>(this.model.$baseEntity(), this.model.$piniaOptions())(this.pinia)
const store = useDataStore(this.model.$baseEntity(), this.model.$piniaOptions())(this.pinia)
if (name && typeof store[name] === 'function')
store[name](payload)

Expand Down Expand Up @@ -625,7 +631,7 @@ export class Query<M extends Model = Model> {
if (!item)
return null

const model = this.hydrate(item)
const model = this.hydrate(item, undefined, true)

this.reviveRelations(model, schema)

Expand Down Expand Up @@ -684,7 +690,7 @@ export class Query<M extends Model = Model> {
* Create and persist model with default values.
*/
new(): M {
const model = this.hydrate({})
const model = this.hydrate({}, undefined, true)

this.commit('insert', this.compile(model))

Expand Down Expand Up @@ -746,8 +752,8 @@ export class Query<M extends Model = Model> {
const record = elements[id]
const existing = currentData[id]
const model = existing
? this.hydrate({ ...existing, ...record }, { operation: 'set', action: 'update' })
: this.hydrate(record, { operation: 'set', action: 'save' })
? this.hydrate({ ...existing, ...record }, { operation: 'set', action: 'update' }, true)
: this.hydrate(record, { operation: 'set', action: 'save' }, true)

const isSaving = model.$self().saving(model, record)
const isUpdatingOrCreating = existing ? model.$self().updating(model, record) : model.$self().creating(model, record)
Expand Down Expand Up @@ -802,7 +808,7 @@ export class Query<M extends Model = Model> {
return []

const newModels = models.map((model) => {
return this.hydrate({ ...model.$getAttributes(), ...record })
return this.hydrate({ ...model.$getAttributes(), ...record }, undefined, true)
})

this.commit('update', this.compile(newModels))
Expand Down Expand Up @@ -948,12 +954,12 @@ export class Query<M extends Model = Model> {
/**
* Instantiate new models with the given record.
*/
protected hydrate(record: Element, options?: ModelOptions): M
protected hydrate(records: Element[], options?: ModelOptions): Collection<M>
protected hydrate(records: Element | Element[], options?: ModelOptions): M | Collection<M> {
protected hydrate(record: Element, options?: ModelOptions, update?: boolean): M
protected hydrate(records: Element[], options?: ModelOptions, update?: boolean): Collection<M>
protected hydrate(records: Element | Element[], options?: ModelOptions, update = false): M | Collection<M> {
return isArray(records)
? records.map(record => this.hydrate(record), options)
: this.checkAndGetSTI(records, { relations: false, ...(options || {}) })
? records.map(record => this.hydrate(record, options, update))
: this.getHydratedModel(records, update, { relations: false, ...(options || {}) })
}

/**
Expand All @@ -972,10 +978,22 @@ export class Query<M extends Model = Model> {
/**
* Instantiate new models by type if set.
*/
protected checkAndGetSTI(record: Element, options?: ModelOptions): M {
const modelByType = this.model.$types()[record[this.model.$typeKey()]]
protected getHydratedModel(record: Element, update = false, options?: ModelOptions): M {
const id = record[this.model.$getKeyName() as string]
const savedHydratedModel = this.hydratedData.get(id)

return (modelByType ? modelByType.newRawInstance() as M : this.model)
const modelByType = this.model.$types()[record[this.model.$typeKey()]]
const hydratedModel = (modelByType ? modelByType.newRawInstance() as M : this.model)
.$newInstance(record, { relations: false, ...(options || {}) })

if (!update
&& savedHydratedModel
&& JSON.stringify(savedHydratedModel) === JSON.stringify(hydratedModel)
)
return savedHydratedModel

this.hydratedData.set(id, hydratedModel)

return hydratedModel
}
}
10 changes: 8 additions & 2 deletions packages/pinia-orm/src/repository/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export class Repository<M extends Model = Model> {
*/
queryCache?: WeakCache<string, M[]>

/**
* Hydrated models. They are stored to prevent rerendering of child components.
*/
hydratedData: Map<string, M>

/**
* The model object to be used for the custom repository.
*/
Expand All @@ -59,6 +64,7 @@ export class Repository<M extends Model = Model> {
constructor(database: Database, pinia?: Pinia) {
this.database = database
this.pinia = pinia
this.hydratedData = new Map()
}

/**
Expand Down Expand Up @@ -107,7 +113,7 @@ export class Repository<M extends Model = Model> {
* Returns the pinia store used with this model
*/
piniaStore() {
return useDataStore<M>(this.model.$entity(), this.model.$piniaOptions())(this.pinia)
return useDataStore(this.model.$entity(), this.model.$piniaOptions())(this.pinia)
}

/**
Expand All @@ -123,7 +129,7 @@ export class Repository<M extends Model = Model> {
* Create a new Query instance.
*/
query(): Query<M> {
return new Query(this.database, this.getModel(), this.queryCache, this.pinia)
return new Query(this.database, this.getModel(), this.queryCache, this.hydratedData, this.pinia)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { computed, defineComponent, nextTick, onUpdated } from 'vue-demi'

import { Model, useRepo } from '../../src'
import { Num, Str } from '../../src/decorators'

describe('performance/prevent_rerender_of_child_components', () => {
class Post extends Model {
static entity = 'posts'

@Num(0) id!: number
@Str('') title!: string
}

const PostComponent = defineComponent({
props: {
post: {
type: Object,
required: true,
},
},
setup() {
onUpdated(() => {
console.log('<PostComponent /> Updated')
})
},
template: `
<div>{{ post.title }}</div>
`,
})

const MainComponent = defineComponent({
components: { PostComponent },
setup() {
const postRepo = useRepo(Post)

const posts = computed(() => postRepo.all())
let counter = 10

const addPost = () => {
postRepo.insert({
id: counter++,
title: `Test ${counter}`,
})
}

return {
posts,
addPost,
}
},
template: `
<div>
<button @click="addPost" > Click me </button>
<post-component v-for="post in posts" :post="post" :key="post.id" />
</div>`,
})

it('it doesnt rerender child', async () => {
expect(MainComponent).toBeTruthy()

const wrapper = mount(MainComponent, {
global: {
plugins: [
createTestingPinia({
stubActions: false,
initialState: {
posts: {
data: {
1: { id: 1, title: 'Test 1' },
},
},
},
}),
],
},
})

const logger = vi.spyOn(console, 'log')

await wrapper.find('button').trigger('click')
await nextTick()
await wrapper.find('button').trigger('click')
await nextTick()
await wrapper.find('button').trigger('click')
await nextTick()

expect(wrapper.html()).toContain('Test 1')
expect(wrapper.html()).toContain('Test 11')
expect(wrapper.html()).toContain('Test 12')
expect(wrapper.html()).toContain('Test 13')

expect(logger).not.toBeCalled()
})
})
1 change: 1 addition & 0 deletions packages/pinia-orm/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default defineConfig({
},
},
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./tests/setup.ts'],
// silent: true,
Expand Down
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 4cb8bae

Please sign in to comment.