From dee3979f20f85c87ba8b719e6d1d38e24f6691e6 Mon Sep 17 00:00:00 2001 From: klis87 Date: Thu, 2 Nov 2023 23:00:13 +0100 Subject: [PATCH 1/9] Add getObjectById prototype --- packages/normy/src/create-normalizer.spec.ts | 122 +++++++++++++++++++ packages/normy/src/create-normalizer.ts | 35 ++++++ 2 files changed, 157 insertions(+) diff --git a/packages/normy/src/create-normalizer.spec.ts b/packages/normy/src/create-normalizer.spec.ts index 636b5b5..5daaef8 100644 --- a/packages/normy/src/create-normalizer.spec.ts +++ b/packages/normy/src/create-normalizer.spec.ts @@ -526,4 +526,126 @@ describe('createNormalizer', () => { }); }); }); + + describe.only('getObjectById', () => { + it('gets object without dependencies', () => { + const normalizer = createNormalizer( + {}, + { + queries: { + query: { + data: '@@1', + dependencies: ['@@1'], + usedKeys: { '': ['id', 'name'] }, + }, + }, + objects: { '@@1': { id: '1', name: 'name' } }, + dependentQueries: { '@@1': ['query'] }, + }, + ); + + expect(normalizer.getObjectById('1')).toEqual({ id: '1', name: 'name' }); + }); + + it('returns undefined if object not found', () => { + const normalizer = createNormalizer( + {}, + { + queries: { + query: { + data: '@@1', + dependencies: ['@@1'], + usedKeys: { '': ['id', 'name'] }, + }, + }, + objects: { '@@1': { id: '1', name: 'name' } }, + dependentQueries: { '@@1': ['query'] }, + }, + ); + + expect(normalizer.getObjectById('2')).toBe(undefined); + }); + + it('gets object with dependencies', () => { + const normalizer = createNormalizer(); + normalizer.setQuery('query', { + id: '1', + name: 'name', + nested: { + id: '2', + key: 'value', + }, + }); + + expect(normalizer.getObjectById('1')).toEqual({ + id: '1', + name: 'name', + nested: { + id: '2', + key: 'value', + }, + }); + }); + + it('fails for self dependencies', () => { + const normalizer = createNormalizer(); + normalizer.setQuery('query', { + id: '1', + name: 'name', + self: { + id: '1', + name: 'name', + surname: 'surname', + }, + }); + + expect(normalizer.getObjectById('1')).toBe(undefined); + }); + + it('allows defining data structure', () => { + const normalizer = createNormalizer(); + normalizer.setQuery('query', { + id: '1', + name: 'name', + nested: { + id: '2', + key: 'value', + }, + }); + + expect(normalizer.getObjectById('1', { id: '0', name: 'x' })).toEqual({ + id: '1', + name: 'name', + }); + }); + + it('works with self dependencies with defined data structure', () => { + const normalizer = createNormalizer(); + normalizer.setQuery('query', { + id: '1', + name: 'name', + self: { + id: '1', + name: 'name', + surname: 'surname', + }, + }); + + expect( + normalizer.getObjectById('1', { + id: '0', + self: { + id: '0', + name: 'name', + }, + }), + ).toEqual({ + id: '1', + self: { + id: '1', + name: 'name', + }, + }); + }); + }); }); diff --git a/packages/normy/src/create-normalizer.ts b/packages/normy/src/create-normalizer.ts index 65e71e4..5acc2d4 100644 --- a/packages/normy/src/create-normalizer.ts +++ b/packages/normy/src/create-normalizer.ts @@ -107,6 +107,40 @@ export const createNormalizer = ( })); }; + const getObjectById = ( + id: string, + exampleObject?: T, + ): T | undefined => { + const object = normalizedData.objects[`@@${id}`]; + + if (!object) { + return undefined; + } + + let usedKeys = {}; + + if (exampleObject) { + const [, , keys] = normalize(exampleObject, config); + usedKeys = keys; + } + + try { + const response = denormalize(object, normalizedData.objects, usedKeys); + return response as T; + } catch (error) { + if (error instanceof RangeError) { + warning( + true, + 'Recursive dependency detected. Pass example object as second argument to getObjectById.', + ); + + return undefined; + } + + throw error; + } + }; + return { getNormalizedData: () => normalizedData, clearNormalizedData: () => { @@ -115,5 +149,6 @@ export const createNormalizer = ( setQuery, removeQuery, getQueriesToUpdate, + getObjectById, }; }; From ff6f564156ea62af638a0908897696de8af9bea2 Mon Sep 17 00:00:00 2001 From: klis87 Date: Fri, 3 Nov 2023 22:33:40 +0100 Subject: [PATCH 2/9] Add getQueryFragment --- packages/normy/src/create-normalizer.spec.ts | 60 ++++++++++++++++++++ packages/normy/src/create-normalizer.ts | 20 +++---- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/normy/src/create-normalizer.spec.ts b/packages/normy/src/create-normalizer.spec.ts index 5daaef8..e93dc6a 100644 --- a/packages/normy/src/create-normalizer.spec.ts +++ b/packages/normy/src/create-normalizer.spec.ts @@ -648,4 +648,64 @@ describe('createNormalizer', () => { }); }); }); + + describe.only('getQueryFragment', () => { + it('gets fragment with two objects', () => { + const normalizer = createNormalizer(); + normalizer.setQuery('query', { + id: '1', + name: 'name', + }); + normalizer.setQuery('query2', { + id: '2', + name: 'name2', + }); + + expect(normalizer.getQueryFragment(['@@1', '@@2'])).toEqual([ + { + id: '1', + name: 'name', + }, + { + id: '2', + name: 'name2', + }, + ]); + }); + + it('gets undefined for a missing object', () => { + const normalizer = createNormalizer(); + normalizer.setQuery('query', { + id: '1', + name: 'name', + }); + + expect(normalizer.getQueryFragment(['@@1', '@@2'])).toEqual([ + { + id: '1', + name: 'name', + }, + undefined, + ]); + }); + + it('allows defining data structure', () => { + const normalizer = createNormalizer(); + normalizer.setQuery('query', { + id: '1', + name: 'name', + surname: 'surname', + }); + + expect( + normalizer.getQueryFragment(['@@1', '@@2'], [{ id: '0', name: '' }]), + ).toEqual([ + { + id: '1', + name: 'name', + }, + undefined, + ]); + }); + }); }); diff --git a/packages/normy/src/create-normalizer.ts b/packages/normy/src/create-normalizer.ts index 5acc2d4..81f4211 100644 --- a/packages/normy/src/create-normalizer.ts +++ b/packages/normy/src/create-normalizer.ts @@ -107,16 +107,10 @@ export const createNormalizer = ( })); }; - const getObjectById = ( - id: string, + const getQueryFragment = ( + fragment: Data, exampleObject?: T, ): T | undefined => { - const object = normalizedData.objects[`@@${id}`]; - - if (!object) { - return undefined; - } - let usedKeys = {}; if (exampleObject) { @@ -125,13 +119,13 @@ export const createNormalizer = ( } try { - const response = denormalize(object, normalizedData.objects, usedKeys); + const response = denormalize(fragment, normalizedData.objects, usedKeys); return response as T; } catch (error) { if (error instanceof RangeError) { warning( true, - 'Recursive dependency detected. Pass example object as second argument to getObjectById.', + 'Recursive dependency detected. Pass example object as second argument to getQueryFragment.', ); return undefined; @@ -141,6 +135,11 @@ export const createNormalizer = ( } }; + const getObjectById = ( + id: string, + exampleObject?: T, + ): T | undefined => getQueryFragment(`@@${id}`, exampleObject); + return { getNormalizedData: () => normalizedData, clearNormalizedData: () => { @@ -150,5 +149,6 @@ export const createNormalizer = ( removeQuery, getQueriesToUpdate, getObjectById, + getQueryFragment, }; }; From d96b1784293ffe0f7201ccb176b986c6f1cf5aa2 Mon Sep 17 00:00:00 2001 From: klis87 Date: Thu, 9 Nov 2023 23:54:59 +0100 Subject: [PATCH 3/9] Update readme --- packages/normy-react-query/README.md | 140 +++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/packages/normy-react-query/README.md b/packages/normy-react-query/README.md index d4b99e2..2cbc3a6 100644 --- a/packages/normy-react-query/README.md +++ b/packages/normy-react-query/README.md @@ -25,6 +25,7 @@ - [Disabling of normalization per query and mutation](#disabling-of-normalization-per-query-and-mutation-arrow_up) - [Optimistic updates](#optimistic-updates-arrow_up) - [useQueryNormalizer and manual updates](#useQueryNormalizer-and-manual-updates-arrow_up) +- [getObjectById and getQueryFragment](#getObjectById-and-getQueryFragment-arrow_up) - [Garbage collection](#garbage-collection-arrow_up) - [Clearing and unsubscribing from updates](#clearing-and-unsubscribing-from-updates-arrow_up) - [Examples](#examples-arrow_up) @@ -292,6 +293,145 @@ const SomeComponent = () => { What it will do is updating normalized store, as well as finding all queries which contain user with `id` equal `'1'` and updating them with `name: 'Updated name'`. +## getObjectById and getQueryFragment [:arrow_up:](#table-of-content) + +Sometimes it is useful to get an object from normalized store by id. You do not even need to know in which +query/queries this object could be, all you need is an id. For example, you might want to get it just to display it. +An even more interesting example is that you could use it as `initialData` or `placeholderData` for another `useQuery`, +so that you could render some data before even query is fetched: + +```jsx +import { useQueryNormalizer } from '@normy/react-query'; + +const BookDetail = ({ bookId }) => { + const queryNormalizer = useQueryNormalizer(); + const bookPlaceholder = queryNormalizer.getObjectById(bookId); + const query = useQuery({ + queryKey: ['books', bookId], + placeholderData: bookPlaceholder, + ...otherOptions, + }); + + // +}; +``` + +In above example, imagine you want to display a component with a book detail. You might already have this book +fetched from a book list query, so you would like to show something to your user before detail book query is even fetched. It is not even a problem that `bookPlaceholder` could have not complete data, for example you could have +`name` but not `description`. `placeholderData` is perfect for this, and instead of showing just a spinner, +you could also already show `name` for faster user experience. + +And what if book with this id does not exist? No harm done, `getObjectById` will just return `undefined`, so the user +will just wait for detail query to be finished as normally. + +### getObjectById and recursive relationships + +Because `getObjectById` denormalizes an object with an id, you might get some issues with recursive relationships. +Take below object: + +```js +const user = { + id: '1', + name: 'X', + bestFriend: { + id: '2', + name: 'Y', + bestFriend: { + id: '1', + name: 'X', + }, + }, +}; +``` + +Typically `normy` saves data structure for each query automatically, so that query normalization and denormalization +gives exactly the same results, even for above case. But `getObjectById` is different, as a given object could be +present in multiple queries, with different attributes. + +With above example, you will end up with infinite recursion error and `getObjectById` will just return `undefined`. +You will also see a warning in the console, to use a second argument for this case, which tells `getObjectById` +what structure is should have, for example: + +```js +const user = queryNormalizer.getObjectById('1', { + id: '', + name: '', + bestFriend: { id: '', name: '' }, +}); +``` + +In above case, `user` would be: + +```js +const user = { + id: '1', + name: 'X', + bestFriend: { + id: '2', + name: 'Y', + }, +}; +``` + +Notice that 2nd argument - data structure you pass - contains empty strings. Why? Because it does not matter +what primitive values you will use there, only data type is important. + +And now, for typescript users there is a gift - when you provide data structure as 2nd argument, `getObjectById` +response will be properly typed, so in our user example `user` will have type: + +```ts +type User = { + id: string; + name: string; + bestFriend: { id: string; name: string }; +}; +``` + +So, passing optional 2nd argument has the following use cases: + +- controlling structure of returned object, for example you might be interested only in `{ name: '' }` +- preventing infinite recursions for relationships like friends +- having automatic Typescript type + +### getQueryFragment + +`getQueryFragment` is a more powerful version of `getObjectById`, actually `getObjectById` uses `getQueryFragment` +under the hood. Basically `getQueryFragment` allows you to get multiple objects in any data structure you need, +for example: + +```js +const users = getQueryFragment(['@@1', '@@2']); +const usersAndBook = getQueryFragment({ users: ['@@1', '@@2'], book: ['@@3'] }); +``` + +If any object does not exist, it will be `undefined`. For example, assuming user with id `1` exists and `2` does not, +`users` will be: + +```js +[ + { + id: '1', + name: 'Name 1', + }, + undefined, +]; +``` + +Like for `getObjectById`, you can also pass data structure, for example: + +```js +const usersAndBook = getQueryFragment( + { users: ['@@1', '@@2'], book: ['@@3'] }, + { + users: [{ id: '', name: '' }], + book: { id: '', name: '', author: '' }, + }, +); +``` + +Notice that to define an array type, you just need to pass one item, even though we want to have two users. +This is because we care only about data structure + ## Garbage collection [:arrow_up:](#table-of-content) `normy` know how to clean after itself. When a query is removed from the store, `normy` will do the same, removing all redundant From 706f428d2ccfaa4f1be70f08f74412a6d78c11e6 Mon Sep 17 00:00:00 2001 From: klis87 Date: Fri, 10 Nov 2023 23:12:43 +0100 Subject: [PATCH 4/9] Add getId helper --- packages/normy-react-query/README.md | 17 +++++++++++++---- packages/normy-react-query/src/index.ts | 2 ++ packages/normy/src/create-normalizer.spec.ts | 10 +++++++--- packages/normy/src/get-id.ts | 1 + packages/normy/src/index.ts | 1 + 5 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 packages/normy/src/get-id.ts diff --git a/packages/normy-react-query/README.md b/packages/normy-react-query/README.md index 2cbc3a6..fcfae93 100644 --- a/packages/normy-react-query/README.md +++ b/packages/normy-react-query/README.md @@ -400,11 +400,18 @@ under the hood. Basically `getQueryFragment` allows you to get multiple objects for example: ```js -const users = getQueryFragment(['@@1', '@@2']); -const usersAndBook = getQueryFragment({ users: ['@@1', '@@2'], book: ['@@3'] }); +import { getId } from '@normy/react-query'; + +const users = getQueryFragment([getId('1'), getId('2')]); +const usersAndBook = getQueryFragment({ + users: [getId('1'), getId('2')], + book: getId('3'), +}); ``` -If any object does not exist, it will be `undefined`. For example, assuming user with id `1` exists and `2` does not, +Notice we need to use `getId` helper, which transform `id` you pass into its internal format. + +Anyway. if any object does not exist, it will be `undefined`. For example, assuming user with id `1` exists and `2` does not, `users` will be: ```js @@ -420,8 +427,10 @@ If any object does not exist, it will be `undefined`. For example, assuming user Like for `getObjectById`, you can also pass data structure, for example: ```js +import { getId } from '@normy/react-query'; + const usersAndBook = getQueryFragment( - { users: ['@@1', '@@2'], book: ['@@3'] }, + { users: [getId('1'), getId('2')], book: getId('3') }, { users: [{ id: '', name: '' }], book: { id: '', name: '', author: '' }, diff --git a/packages/normy-react-query/src/index.ts b/packages/normy-react-query/src/index.ts index bacae43..4529592 100644 --- a/packages/normy-react-query/src/index.ts +++ b/packages/normy-react-query/src/index.ts @@ -1,3 +1,5 @@ +export { getId } from '@normy/core'; + export { createQueryNormalizer } from './create-query-normalizer'; export { QueryNormalizerProvider, diff --git a/packages/normy/src/create-normalizer.spec.ts b/packages/normy/src/create-normalizer.spec.ts index e93dc6a..4a75aa0 100644 --- a/packages/normy/src/create-normalizer.spec.ts +++ b/packages/normy/src/create-normalizer.spec.ts @@ -1,4 +1,5 @@ import { createNormalizer } from './create-normalizer'; +import { getId } from './get-id'; describe('createNormalizer', () => { describe('setQuery', () => { @@ -661,7 +662,7 @@ describe('createNormalizer', () => { name: 'name2', }); - expect(normalizer.getQueryFragment(['@@1', '@@2'])).toEqual([ + expect(normalizer.getQueryFragment([getId('1'), getId('2')])).toEqual([ { id: '1', name: 'name', @@ -680,7 +681,7 @@ describe('createNormalizer', () => { name: 'name', }); - expect(normalizer.getQueryFragment(['@@1', '@@2'])).toEqual([ + expect(normalizer.getQueryFragment([getId('1'), getId('2')])).toEqual([ { id: '1', name: 'name', @@ -698,7 +699,10 @@ describe('createNormalizer', () => { }); expect( - normalizer.getQueryFragment(['@@1', '@@2'], [{ id: '0', name: '' }]), + normalizer.getQueryFragment( + [getId('1'), getId('2')], + [{ id: '0', name: '' }], + ), ).toEqual([ { id: '1', diff --git a/packages/normy/src/get-id.ts b/packages/normy/src/get-id.ts new file mode 100644 index 0000000..eb102dc --- /dev/null +++ b/packages/normy/src/get-id.ts @@ -0,0 +1 @@ +export const getId = (id: string) => `@@${id}`; diff --git a/packages/normy/src/index.ts b/packages/normy/src/index.ts index 8f3861d..98ce593 100644 --- a/packages/normy/src/index.ts +++ b/packages/normy/src/index.ts @@ -1,2 +1,3 @@ export type { NormalizerConfig, Data } from './types'; export { createNormalizer } from './create-normalizer'; +export { getId } from './get-id'; From 9db7f5c85046a8411cced4c0525082aae1a52099 Mon Sep 17 00:00:00 2001 From: klis87 Date: Fri, 10 Nov 2023 23:58:33 +0100 Subject: [PATCH 5/9] Allow empty string ids as normalizable object --- packages/normy/src/create-normalizer.spec.ts | 10 +++++----- packages/normy/src/normalize.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/normy/src/create-normalizer.spec.ts b/packages/normy/src/create-normalizer.spec.ts index 4a75aa0..cb823ce 100644 --- a/packages/normy/src/create-normalizer.spec.ts +++ b/packages/normy/src/create-normalizer.spec.ts @@ -614,7 +614,7 @@ describe('createNormalizer', () => { }, }); - expect(normalizer.getObjectById('1', { id: '0', name: 'x' })).toEqual({ + expect(normalizer.getObjectById('1', { id: '', name: '' })).toEqual({ id: '1', name: 'name', }); @@ -634,10 +634,10 @@ describe('createNormalizer', () => { expect( normalizer.getObjectById('1', { - id: '0', + id: '', self: { - id: '0', - name: 'name', + id: '', + name: '', }, }), ).toEqual({ @@ -701,7 +701,7 @@ describe('createNormalizer', () => { expect( normalizer.getQueryFragment( [getId('1'), getId('2')], - [{ id: '0', name: '' }], + [{ id: '', name: '' }], ), ).toEqual([ { diff --git a/packages/normy/src/normalize.ts b/packages/normy/src/normalize.ts index f56cb08..240a99a 100644 --- a/packages/normy/src/normalize.ts +++ b/packages/normy/src/normalize.ts @@ -22,7 +22,7 @@ const stipFromDeps = ( if (data !== null && typeof data === 'object' && !(data instanceof Date)) { const objectKey = config.getNormalizationObjectKey(data); - if (objectKey && root) { + if (objectKey !== undefined && root) { return `@@${objectKey}`; } @@ -58,7 +58,7 @@ export const getDependencies = ( } if (data !== null && typeof data === 'object' && !(data instanceof Date)) { - if (config.getNormalizationObjectKey(data)) { + if (config.getNormalizationObjectKey(data) !== undefined) { usedKeys[path] = Object.keys(data); } @@ -68,7 +68,7 @@ export const getDependencies = ( ...prev, ...getDependencies(v, config, usedKeys, `${path}.${k}`)[0], ], - config.getNormalizationObjectKey(data) ? [data] : [], + config.getNormalizationObjectKey(data) !== undefined ? [data] : [], ), usedKeys, ]; From 80e52d66f138c6d5b5937b92c00c784d3061577b Mon Sep 17 00:00:00 2001 From: klis87 Date: Fri, 10 Nov 2023 23:58:52 +0100 Subject: [PATCH 6/9] Pass getObjectById and getQueryFragment in createQueryNormalizer --- packages/normy-react-query/src/create-query-normalizer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/normy-react-query/src/create-query-normalizer.ts b/packages/normy-react-query/src/create-query-normalizer.ts index a99635f..0579abc 100644 --- a/packages/normy-react-query/src/create-query-normalizer.ts +++ b/packages/normy-react-query/src/create-query-normalizer.ts @@ -112,5 +112,7 @@ export const createQueryNormalizer = ( unsubscribeQueryCache = null; unsubscribeMutationCache = null; }, + getObjectById: normalizer.getObjectById, + getQueryFragment: normalizer.getQueryFragment, }; }; From 1e7290c1a13264ed4f37ef6cfaabf5d6072b9a6d Mon Sep 17 00:00:00 2001 From: klis87 Date: Sat, 11 Nov 2023 22:50:12 +0100 Subject: [PATCH 7/9] Remove only from tests --- packages/normy/src/create-normalizer.spec.ts | 4 ++-- packages/normy/src/create-normalizer.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/normy/src/create-normalizer.spec.ts b/packages/normy/src/create-normalizer.spec.ts index cb823ce..fb4bedb 100644 --- a/packages/normy/src/create-normalizer.spec.ts +++ b/packages/normy/src/create-normalizer.spec.ts @@ -528,7 +528,7 @@ describe('createNormalizer', () => { }); }); - describe.only('getObjectById', () => { + describe('getObjectById', () => { it('gets object without dependencies', () => { const normalizer = createNormalizer( {}, @@ -650,7 +650,7 @@ describe('createNormalizer', () => { }); }); - describe.only('getQueryFragment', () => { + describe('getQueryFragment', () => { it('gets fragment with two objects', () => { const normalizer = createNormalizer(); normalizer.setQuery('query', { diff --git a/packages/normy/src/create-normalizer.ts b/packages/normy/src/create-normalizer.ts index 81f4211..0feba07 100644 --- a/packages/normy/src/create-normalizer.ts +++ b/packages/normy/src/create-normalizer.ts @@ -125,7 +125,7 @@ export const createNormalizer = ( if (error instanceof RangeError) { warning( true, - 'Recursive dependency detected. Pass example object as second argument to getQueryFragment.', + 'Recursive dependency detected. Pass example object as second argument.', ); return undefined; From e774211e809cb0e4558283752780238aaba08fe8 Mon Sep 17 00:00:00 2001 From: klis87 Date: Mon, 13 Nov 2023 11:21:37 +0100 Subject: [PATCH 8/9] Update readme --- packages/normy-react-query/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/normy-react-query/README.md b/packages/normy-react-query/README.md index fcfae93..54c8785 100644 --- a/packages/normy-react-query/README.md +++ b/packages/normy-react-query/README.md @@ -389,7 +389,7 @@ type User = { So, passing optional 2nd argument has the following use cases: -- controlling structure of returned object, for example you might be interested only in `{ name: '' }` +- controlling structure of returned object, for example you might be interested only in `{ id: '', name: '' }` - preventing infinite recursions for relationships like friends - having automatic Typescript type From 2c7b28a63991d36b8f9a33443c6e8e9857c085d9 Mon Sep 17 00:00:00 2001 From: klis87 Date: Mon, 13 Nov 2023 11:38:24 +0100 Subject: [PATCH 9/9] Fix readme typo. --- packages/normy-react-query/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/normy-react-query/README.md b/packages/normy-react-query/README.md index 54c8785..aa8a255 100644 --- a/packages/normy-react-query/README.md +++ b/packages/normy-react-query/README.md @@ -439,7 +439,7 @@ const usersAndBook = getQueryFragment( ``` Notice that to define an array type, you just need to pass one item, even though we want to have two users. -This is because we care only about data structure +This is because we care only about data structure. ## Garbage collection [:arrow_up:](#table-of-content)