diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..0ecd796 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,62 @@ +const path = require("path"); + +module.exports = { + parser: "@typescript-eslint/parser", + plugins: ["react", "react-hooks"], + + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", + ], + + parserOptions: { + sourceType: "module", + project: path.resolve(__dirname + "/tsconfig.json"), + }, + + env: { + browser: true, + es2022: true, + }, + + overrides: [ + { + files: ["**/__{mocks,tests}__/**/*.{ts,tsx}"], + rules: { + "no-empty": ["error", { allowEmptyCatch: true }], + }, + }, + { + files: ["*.d.ts"], + rules: { + "@typescript-eslint/consistent-type-definitions": "off", + "@typescript-eslint/no-unused-vars": "off", + }, + }, + { + files: ["clients/**/src/graphql/**/*.{ts,tsx}"], + rules: { + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-explicit-any": "off", + }, + }, + ], + + rules: { + "no-implicit-coercion": "error", + "no-param-reassign": "error", + "no-var": "error", + "object-shorthand": "warn", + "prefer-const": "error", + + "no-extra-boolean-cast": "off", + + "react/jsx-boolean-value": ["error", "always"], + + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + + ignorePatterns: [".eslintrc.js"], +}; diff --git a/docs/docs/use-query.md b/docs/docs/use-query.md index c156b64..8e8fb1e 100644 --- a/docs/docs/use-query.md +++ b/docs/docs/use-query.md @@ -11,6 +11,7 @@ sidebar_label: useQuery - `variables`: your query variables - `config` (optional) - `suspense`: use React Suspense (default: `false`) + - `optimize`: adapt query to only require data that's missing from the cache (default: `false`) ### Returns diff --git a/example/components/AccountMembership.tsx b/example/components/AccountMembership.tsx index e10d54d..ef5fdff 100644 --- a/example/components/AccountMembership.tsx +++ b/example/components/AccountMembership.tsx @@ -1,6 +1,8 @@ import { FragmentOf, readFragment } from "gql.tada"; +import { useState } from "react"; import { P, match } from "ts-pattern"; import { graphql } from "../graphql"; +import { AccountMembershipDetail } from "./AccountMembershipDetail"; export const accountMembershipFragment = graphql(` fragment AccountMembership on AccountMembership { @@ -12,7 +14,6 @@ export const accountMembershipFragment = graphql(` } statusInfo { __typename - status ... on AccountMembershipBindingUserErrorStatusInfo { restrictedTo { firstName @@ -36,27 +37,37 @@ type Props = { export const AccountMembership = ({ data }: Props) => { const accountMembership = readFragment(accountMembershipFragment, data); + const [isOpen, setIsOpen] = useState(false); + return ( -
- {accountMembership.id}: - {match(accountMembership) - .with( - { user: { firstName: P.string, lastName: P.string } }, - ({ user: { firstName, lastName } }) => `${firstName} ${lastName}`, - ) - .with( - { - statusInfo: { - restrictedTo: { firstName: P.string, lastName: P.string }, - }, - }, - ({ - statusInfo: { - restrictedTo: { firstName, lastName }, + <> +
setIsOpen(true)}> + {accountMembership.id}: + {match(accountMembership) + .with( + { user: { firstName: P.string, lastName: P.string } }, + ({ user: { firstName, lastName } }) => `${firstName} ${lastName}`, + ) + .with( + { + statusInfo: { + restrictedTo: { firstName: P.string, lastName: P.string }, + }, }, - }) => `${firstName} ${lastName} (restricted to)`, - ) - .otherwise(() => "No user")} -
+ ({ + statusInfo: { + restrictedTo: { firstName, lastName }, + }, + }) => `${firstName} ${lastName} (restricted to)`, + ) + .otherwise(() => "No user")} +
+ {isOpen ? ( + setIsOpen(false)} + /> + ) : null} + ); }; diff --git a/example/components/AccountMembershipDetail.tsx b/example/components/AccountMembershipDetail.tsx new file mode 100644 index 0000000..cf0c9eb --- /dev/null +++ b/example/components/AccountMembershipDetail.tsx @@ -0,0 +1,70 @@ +import { AsyncData, Result } from "@swan-io/boxed"; +import { useState } from "react"; +import { P, match } from "ts-pattern"; +import { ClientError, useQuery } from "../../src"; +import { graphql } from "../graphql"; +import { UserCard } from "./UserCard"; + +export const accountMembershipDetailQuery = graphql(` + query AccountMembershipDetail($accountMembershipId: ID!) { + accountMembership(id: $accountMembershipId) { + id + user { + id + firstName + lastName + } + } + } +`); + +type Props = { + accountMembershipId: string; + onPressClose: () => void; +}; + +export const AccountMembershipDetail = ({ + accountMembershipId, + onPressClose, +}: Props) => { + const [data] = useQuery( + accountMembershipDetailQuery, + { accountMembershipId }, + { optimize: true }, + ); + + const [showDetails, setShowDetails] = useState(false); + + return match(data) + .with(AsyncData.P.NotAsked, () => "Nothing") + .with(AsyncData.P.Loading, () => "Loading") + .with(AsyncData.P.Done(Result.P.Error(P.select())), (error) => { + ClientError.forEach(error, (error) => console.error(error)); + return "Error"; + }) + .with( + AsyncData.P.Done(Result.P.Ok(P.select())), + ({ accountMembership }) => { + if (accountMembership == null) { + return
No membership
; + } + return ( + + +
+ Membership: {accountMembership.id} + + {showDetails ? ( + + ) : ( + + )} +
+
+ ); + }, + ) + .exhaustive(); +}; diff --git a/example/components/App.tsx b/example/components/App.tsx index 84048f0..67e9342 100644 --- a/example/components/App.tsx +++ b/example/components/App.tsx @@ -30,6 +30,7 @@ export const App = () => { "fa3a2485-43bc-461e-b38c-5a9bc3750c7d", ); const [suspense, setSuspense] = useState(false); + const [optimize, setOptimize] = useState(false); const [data, { isLoading, reload, refresh }] = useQuery( transactionListQuery, @@ -37,7 +38,7 @@ export const App = () => { accountMembershipId, after, }, - { suspense }, + { suspense, optimize }, ); const toggleAccountMembership = useCallback(() => { @@ -65,6 +66,14 @@ export const App = () => { /> Suspense + {match(data) diff --git a/example/components/UserCard.tsx b/example/components/UserCard.tsx new file mode 100644 index 0000000..fa7a64f --- /dev/null +++ b/example/components/UserCard.tsx @@ -0,0 +1,80 @@ +import { AsyncData, Result } from "@swan-io/boxed"; +import { P, match } from "ts-pattern"; +import { ClientError, useQuery } from "../../src"; +import { graphql } from "../graphql"; + +export const accountMembershipUserDetailQuery = graphql(` + query AccountMembershipUserDetail($accountMembershipId: ID!) { + accountMembership(id: $accountMembershipId) { + id + user { + id + firstName + lastName + birthDate + mobilePhoneNumber + } + } + } +`); + +type Props = { + accountMembershipId: string; +}; + +const formatter = new Intl.DateTimeFormat(); + +export const UserCard = ({ accountMembershipId }: Props) => { + const [data] = useQuery( + accountMembershipUserDetailQuery, + { + accountMembershipId, + }, + { optimize: true }, + ); + + return match(data) + .with(AsyncData.P.NotAsked, () => "Nothing") + .with(AsyncData.P.Loading, () => "Loading") + .with(AsyncData.P.Done(Result.P.Error(P.select())), (error) => { + ClientError.forEach(error, (error) => console.error(error)); + return "Error"; + }) + .with( + AsyncData.P.Done(Result.P.Ok(P.select())), + ({ accountMembership }) => { + if (accountMembership == null) { + return null; + } + const user = accountMembership.user; + if (user == null) { + return
No user
; + } + return ( +
+ +
+ ); + }, + ) + .exhaustive(); +}; diff --git a/example/graphql.ts b/example/graphql.ts index 6e20c13..3da56b1 100644 --- a/example/graphql.ts +++ b/example/graphql.ts @@ -7,5 +7,7 @@ export const graphql = initGraphQLTada<{ ID: string; Currency: string; AmountValue: string; + Date: string; + PhoneNumber: string; }; }>(); diff --git a/src/cache/cache.ts b/src/cache/cache.ts index 1229961..5cd766b 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -96,6 +96,12 @@ export class ClientCache { }); } + getFromCacheWithoutKey(cacheKey: symbol) { + return this.get(cacheKey).flatMap((entry) => { + return Option.Some(entry.value); + }); + } + get(cacheKey: symbol): Option { if (this.cache.has(cacheKey)) { return Option.Some(this.cache.get(cacheKey) as CacheEntry); diff --git a/src/cache/read.ts b/src/cache/read.ts index 5033eba..733cf58 100644 --- a/src/cache/read.ts +++ b/src/cache/read.ts @@ -1,7 +1,15 @@ -import { DocumentNode, Kind, SelectionSetNode } from "@0no-co/graphql.web"; +import { + DocumentNode, + InlineFragmentNode, + Kind, + OperationDefinitionNode, + SelectionNode, + SelectionSetNode, +} from "@0no-co/graphql.web"; import { Array, Option, Result } from "@swan-io/boxed"; import { match } from "ts-pattern"; import { + addIdIfPreviousSelected, getFieldName, getFieldNameWithArguments, getSelectedKeys, @@ -24,6 +32,15 @@ const getFromCacheOrReturnValue = ( : Option.Some(valueOrKey); }; +const getFromCacheOrReturnValueWithoutKeyFilter = ( + cache: ClientCache, + valueOrKey: unknown, +): Option => { + return typeof valueOrKey === "symbol" + ? cache.getFromCacheWithoutKey(valueOrKey).flatMap(Option.fromNullable) + : Option.Some(valueOrKey); +}; + const STABILITY_CACHE = new WeakMap>(); export const readOperationFromCache = ( @@ -180,3 +197,159 @@ export const readOperationFromCache = ( } }); }; + +export const optimizeQuery = ( + cache: ClientCache, + document: DocumentNode, + variables: Record, +): Option => { + const traverse = ( + selections: SelectionSetNode, + data: Record, + parentSelectedKeys: Set, + ): Option => { + const nextSelections = Array.filterMap( + selections.selections, + (selection) => { + return match(selection) + .with({ kind: Kind.FIELD }, (fieldNode) => { + const fieldNameWithArguments = getFieldNameWithArguments( + fieldNode, + variables, + ); + + if (data == undefined) { + return Option.Some(fieldNode); + } + + const cacheHasKey = hasOwnProperty.call( + data, + fieldNameWithArguments, + ); + + if (!cacheHasKey) { + return Option.Some(fieldNode); + } + + if (parentSelectedKeys.has(fieldNameWithArguments)) { + const valueOrKeyFromCache = data[fieldNameWithArguments]; + + const subFieldSelectedKeys = getSelectedKeys( + fieldNode, + variables, + ); + if (Array.isArray(valueOrKeyFromCache)) { + return valueOrKeyFromCache.reduce((acc, valueOrKey) => { + const value = getFromCacheOrReturnValueWithoutKeyFilter( + cache, + valueOrKey, + ); + + if (value.isNone()) { + return Option.Some(fieldNode); + } + + const originalSelectionSet = fieldNode.selectionSet; + if (originalSelectionSet != null) { + return traverse( + originalSelectionSet, + value.get() as Record, + subFieldSelectedKeys, + ).map((selectionSet) => ({ + ...fieldNode, + selectionSet: addIdIfPreviousSelected( + originalSelectionSet, + selectionSet, + ), + })); + } else { + return acc; + } + }, Option.None()); + } else { + const value = getFromCacheOrReturnValueWithoutKeyFilter( + cache, + valueOrKeyFromCache, + ); + + if (value.isNone()) { + return Option.Some(fieldNode); + } + + const originalSelectionSet = fieldNode.selectionSet; + if (originalSelectionSet != null) { + return traverse( + originalSelectionSet, + value.get() as Record, + subFieldSelectedKeys, + ).map((selectionSet) => ({ + ...fieldNode, + selectionSet: addIdIfPreviousSelected( + originalSelectionSet, + selectionSet, + ), + })); + } else { + return Option.None(); + } + } + } else { + return Option.Some(fieldNode); + } + }) + .with({ kind: Kind.INLINE_FRAGMENT }, (inlineFragmentNode) => { + return traverse( + inlineFragmentNode.selectionSet, + data as Record, + parentSelectedKeys, + ).map( + (selectionSet) => + ({ ...inlineFragmentNode, selectionSet }) as InlineFragmentNode, + ); + }) + .with({ kind: Kind.FRAGMENT_SPREAD }, () => { + return Option.None(); + }) + .exhaustive(); + }, + ); + if (nextSelections.length > 0) { + return Option.Some({ ...selections, selections: nextSelections }); + } else { + return Option.None(); + } + }; + + return Array.findMap(document.definitions, (definition) => + definition.kind === Kind.OPERATION_DEFINITION + ? Option.Some(definition) + : Option.None(), + ) + .flatMap((operation) => + getCacheKeyFromOperationNode(operation).map((cacheKey) => ({ + operation, + cacheKey, + })), + ) + .flatMap(({ operation, cacheKey }) => { + const selectedKeys = getSelectedKeys(operation, variables); + return cache + .getFromCache(cacheKey, selectedKeys) + .map((cache) => ({ cache, operation, selectedKeys })); + }) + .flatMap(({ operation, cache, selectedKeys }) => { + return traverse( + operation.selectionSet, + cache as Record, + selectedKeys, + ).map((selectionSet) => ({ + ...document, + definitions: [ + { + ...operation, + selectionSet, + } as OperationDefinitionNode, + ], + })); + }); +}; diff --git a/src/client.ts b/src/client.ts index 9ed7359..c594bca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,7 +11,7 @@ import { } from "@swan-io/request"; import { P, match } from "ts-pattern"; import { ClientCache } from "./cache/cache"; -import { readOperationFromCache } from "./cache/read"; +import { optimizeQuery, readOperationFromCache } from "./cache/read"; import { writeOperationToCache } from "./cache/write"; import { ClientError, @@ -85,6 +85,8 @@ const defaultMakeRequest: MakeRequest = ({ .mapOkToResult(emptyToError); }; +type RequestOptions = { optimize?: boolean }; + export class Client { url: string; headers: Record; @@ -136,7 +138,8 @@ export class Client { request( document: TypedDocumentNode, variables: Variables, - ) { + { optimize = false }: RequestOptions = {}, + ): Future> { const transformedDocument = this.getTransformedDocument(document); const transformedDocumentsForRequest = this.getTransformedDocumentsForRequest(document); @@ -148,11 +151,30 @@ export class Client { const variablesAsRecord = variables as Record; + const possiblyOptimizedQuery = optimize + ? optimizeQuery(this.cache, transformedDocument, variablesAsRecord).map( + addTypenames, + ) + : Option.Some(transformedDocumentsForRequest); + + if (possiblyOptimizedQuery.isNone()) { + const operationResult = readOperationFromCache( + this.cache, + transformedDocument, + variablesAsRecord, + ); + if (operationResult.isSome()) { + return Future.value(operationResult.get() as Result); + } + } + return this.makeRequest({ url: this.url, headers: this.headers, operationName, - document: transformedDocumentsForRequest, + document: possiblyOptimizedQuery.getWithDefault( + transformedDocumentsForRequest, + ), variables: variablesAsRecord, }) .mapOkToResult(this.parseResponse) @@ -200,14 +222,16 @@ export class Client { query( document: TypedDocumentNode, variables: Variables, + requestOptions?: RequestOptions, ) { - return this.request(document, variables); + return this.request(document, variables, requestOptions); } commitMutation( document: TypedDocumentNode, variables: Variables, + requestOptions?: RequestOptions, ) { - return this.request(document, variables); + return this.request(document, variables, requestOptions); } } diff --git a/src/graphql/ast.ts b/src/graphql/ast.ts index 42534df..5c257b2 100644 --- a/src/graphql/ast.ts +++ b/src/graphql/ast.ts @@ -251,3 +251,42 @@ export const getExecutableOperationName = (document: DocumentNode) => { } }); }; + +const getIdFieldNode = (selection: SelectionNode): Option => { + return match(selection) + .with({ kind: Kind.FIELD }, (fieldNode) => + fieldNode.name.value === "id" ? Option.Some(fieldNode) : Option.None(), + ) + .with({ kind: Kind.INLINE_FRAGMENT }, (inlineFragmentNode) => { + return Array.findMap( + inlineFragmentNode.selectionSet.selections, + getIdFieldNode, + ); + }) + .otherwise(() => Option.None()); +}; + +export const addIdIfPreviousSelected = ( + oldSelectionSet: SelectionSetNode, + newSelectionSet: SelectionSetNode, +): SelectionSetNode => { + const idSelection = Array.findMap(oldSelectionSet.selections, getIdFieldNode); + const idSelectionInNew = Array.findMap( + newSelectionSet.selections, + getIdFieldNode, + ); + + if (idSelectionInNew.isSome()) { + return newSelectionSet; + } + + return idSelection + .map((selection) => ({ + ...newSelectionSet, + selections: [ + selection, + ...newSelectionSet.selections, + ] as readonly SelectionNode[], + })) + .getWithDefault(newSelectionSet); +}; diff --git a/src/react/useQuery.ts b/src/react/useQuery.ts index 3141878..92abd5d 100644 --- a/src/react/useQuery.ts +++ b/src/react/useQuery.ts @@ -15,6 +15,7 @@ import { ClientContext } from "./ClientContext"; export type QueryConfig = { suspense?: boolean; + optimize?: boolean; }; export type Query = readonly [ @@ -39,7 +40,7 @@ const usePreviousValue = (value: T): T => { export const useQuery = ( query: TypedDocumentNode, variables: Variables, - { suspense = false }: QueryConfig = {}, + { suspense = false, optimize = false }: QueryConfig = {}, ): Query => { const client = useContext(ClientContext); @@ -80,9 +81,9 @@ export const useQuery = ( isSuspenseFirstFetch.current = false; return; } - const request = client.query(stableQuery, stableVariables); + const request = client.query(stableQuery, stableVariables, { optimize }); return () => request.cancel(); - }, [client, suspense, stableQuery, stableVariables]); + }, [client, suspense, optimize, stableQuery, stableVariables]); const [isRefreshing, setIsRefreshing] = useState(false); const refresh = useCallback(() => { @@ -108,7 +109,7 @@ export const useQuery = ( : asyncData; if (suspense && asyncDataToExpose.isLoading()) { - throw client.query(stableQuery, stableVariables).toPromise(); + throw client.query(stableQuery, stableVariables, { optimize }).toPromise(); } return [asyncDataToExpose, { isLoading, refresh, reload }]; diff --git a/test/__snapshots__/cache.test.ts.snap b/test/__snapshots__/cache.test.ts.snap index 1d03157..f03b7ee 100644 --- a/test/__snapshots__/cache.test.ts.snap +++ b/test/__snapshots__/cache.test.ts.snap @@ -426,3 +426,7 @@ Map { }, } `; + +exports[`Write & read in cache 4`] = `"query App($id: ID!) {accountMembership(id: $id) {id user {id ... on User {birthDate mobilePhoneNumber}}}}"`; + +exports[`Write & read in cache 5`] = `"query App($id: ID!) {accountMembership(id: $id) {id user {id ... on User {birthDate mobilePhoneNumber}}} accountMemberships(first: 2) {edges {node {id createdAt account {bankDetails}}}}}"`; diff --git a/test/cache.test.ts b/test/cache.test.ts index 34aae39..56d198c 100644 --- a/test/cache.test.ts +++ b/test/cache.test.ts @@ -1,15 +1,18 @@ import { Option, Result } from "@swan-io/boxed"; import { expect, test } from "vitest"; import { ClientCache } from "../src/cache/cache"; -import { readOperationFromCache } from "../src/cache/read"; +import { optimizeQuery, readOperationFromCache } from "../src/cache/read"; import { writeOperationToCache } from "../src/cache/write"; import { addTypenames, inlineFragments } from "../src/graphql/ast"; +import { print } from "../src/graphql/print"; import { appQuery, + appQueryWithExtraArrayInfo, bindAccountMembershipMutation, bindMembershipMutationRejectionResponse, bindMembershipMutationSuccessResponse, getAppQueryResponse, + otherAppQuery, } from "./data"; test("Write & read in cache", () => { @@ -50,6 +53,11 @@ test("Write & read in cache", () => { addTypenames(bindAccountMembershipMutation), ); + const preparedOtherAppQuery = inlineFragments(addTypenames(otherAppQuery)); + const preparedAppQueryWithExtraArrayInfo = inlineFragments( + addTypenames(appQueryWithExtraArrayInfo), + ); + writeOperationToCache( cache, preparedBindAccountMembershipMutation, @@ -152,4 +160,16 @@ test("Write & read in cache", () => { } else { expect(true).toBe(false); } + + expect( + optimizeQuery(cache, preparedOtherAppQuery, { id: "1" }) + .map(print) + .getWithDefault("no delta"), + ).toMatchSnapshot(); + + expect( + optimizeQuery(cache, preparedAppQueryWithExtraArrayInfo, { id: "1" }) + .map(print) + .getWithDefault("no delta"), + ).toMatchSnapshot(); }); diff --git a/test/data.ts b/test/data.ts index 2074b10..7703cb1 100644 --- a/test/data.ts +++ b/test/data.ts @@ -22,6 +22,19 @@ const UserInfo = graphql( [IdentificationLevels], ); +const CompleteUserInfo = graphql( + ` + fragment CompleteUserInfo on User { + id + firstName + lastName + birthDate + mobilePhoneNumber + } + `, + [IdentificationLevels], +); + export const appQuery = graphql( ` query App($id: ID!) { @@ -60,6 +73,85 @@ export const appQuery = graphql( [UserInfo], ); +export const otherAppQuery = graphql( + ` + query App($id: ID!) { + accountMembership(id: $id) { + id + user { + id + ...CompleteUserInfo + } + } + accountMemberships(first: 2) { + edges { + node { + id + account { + name + } + membershipUser: user { + id + lastName + } + } + } + } + supportingDocumentCollection(id: "e8d38e87-9862-47ef-b749-212ed566b955") { + __typename + supportingDocuments { + __typename + id + createdAt + } + id + } + } + `, + [CompleteUserInfo], +); + +export const appQueryWithExtraArrayInfo = graphql( + ` + query App($id: ID!) { + accountMembership(id: $id) { + id + user { + id + ...CompleteUserInfo + } + } + accountMemberships(first: 2) { + edges { + node { + id + createdAt + account { + name + bankDetails + } + membershipUser: user { + id + lastName + firstName + } + } + } + } + supportingDocumentCollection(id: "e8d38e87-9862-47ef-b749-212ed566b955") { + __typename + supportingDocuments { + __typename + id + createdAt + } + id + } + } + `, + [CompleteUserInfo], +); + export const getAppQueryResponse = ({ user2LastName, user1IdentificationLevels,