diff --git a/packages/connect-query/src/call-unary-method.test.ts b/packages/connect-query/src/call-unary-method.test.ts index ac3f9a07..a2c600ab 100644 --- a/packages/connect-query/src/call-unary-method.test.ts +++ b/packages/connect-query/src/call-unary-method.test.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { create } from "@bufbuild/protobuf"; import type { QueryFunctionContext } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; @@ -21,30 +22,35 @@ import { callUnaryMethod } from "./call-unary-method.js"; import type { ConnectQueryKey } from "./connect-query-key.js"; import { createConnectQueryKey } from "./connect-query-key.js"; import { defaultOptions } from "./default-options.js"; -import { ElizaService } from "./gen/eliza_pb.js"; +import type { SayRequest } from "./gen/eliza_pb.js"; +import { ElizaService, SayRequestSchema } from "./gen/eliza_pb.js"; import { mockEliza, wrapper } from "./test/test-utils.js"; describe("callUnaryMethod", () => { it("can be used with useQueries", async () => { + const transport = mockEliza({ + sentence: "Response 1", + }); const { result } = renderHook( () => { + const input: SayRequest = create(SayRequestSchema, { + sentence: "query 1", + }); const [query1] = useQueries({ queries: [ { - queryKey: createConnectQueryKey(ElizaService.method.say, { - sentence: "query 1", + queryKey: createConnectQueryKey({ + method: ElizaService.method.say, + input, + transport, }), queryFn: async ({ - queryKey, signal, }: QueryFunctionContext) => { - const transport = mockEliza({ - sentence: "Response 1", - }); const res = await callUnaryMethod( transport, ElizaService.method.say, - queryKey[2], + input, { signal, }, diff --git a/packages/connect-query/src/connect-query-key.test.ts b/packages/connect-query/src/connect-query-key.test.ts index 014bf865..ccd10522 100644 --- a/packages/connect-query/src/connect-query-key.test.ts +++ b/packages/connect-query/src/connect-query-key.test.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { create } from "@bufbuild/protobuf"; import { skipToken } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; @@ -19,45 +20,57 @@ import { createConnectQueryKey } from "./connect-query-key.js"; import { ElizaService, SayRequestSchema } from "./gen/eliza_pb.js"; import { createMessageKey } from "./message-key.js"; -describe("makeQueryKey", () => { - const methodDescriptor = { - input: SayRequestSchema, - name: "name", - parent: ElizaService, - }; - +describe("createConnectQueryKey", () => { it("makes a query key with input", () => { - const key = createConnectQueryKey(methodDescriptor, { - sentence: "someValue", + const key = createConnectQueryKey({ + method: ElizaService.method.say, + input: create(SayRequestSchema, { sentence: "hi" }), }); expect(key).toStrictEqual([ - ElizaService.typeName, - "name", - createMessageKey(SayRequestSchema, { sentence: "someValue" }), + "connect-query", + { + serviceName: ElizaService.typeName, + methodName: ElizaService.method.say.name, + cardinality: "finite", + input: createMessageKey(SayRequestSchema, { sentence: "hi" }), + }, ]); }); - it("allows empty inputs", () => { - const key = createConnectQueryKey(methodDescriptor); + it("allows input: undefined", () => { + const key = createConnectQueryKey({ + method: ElizaService.method.say, + input: undefined, + }); expect(key).toStrictEqual([ - ElizaService.typeName, - "name", - createMessageKey(methodDescriptor.input, {}), + "connect-query", + { + serviceName: ElizaService.typeName, + methodName: ElizaService.method.say.name, + cardinality: "finite", + }, ]); }); - it("makes a query key with a skipToken", () => { - const key = createConnectQueryKey(methodDescriptor, skipToken); + it("allows to omit input", () => { + const key = createConnectQueryKey({ + method: ElizaService.method.say, + }); expect(key).toStrictEqual([ - ElizaService.typeName, - "name", - createMessageKey(methodDescriptor.input, {}), + "connect-query", + { + serviceName: ElizaService.typeName, + methodName: ElizaService.method.say.name, + cardinality: "finite", + }, ]); }); - it("generates identical keys when input is empty or the default is explicitly sent", () => { - const key1 = createConnectQueryKey(methodDescriptor, {}); - const key2 = createConnectQueryKey(methodDescriptor, { sentence: "" }); - expect(key1).toStrictEqual(key2); + it("skipToken sets input: 'skipped'", () => { + const key = createConnectQueryKey({ + method: ElizaService.method.say, + input: skipToken, + }); + expect(key[1].input).toBe("skipped"); }); }); diff --git a/packages/connect-query/src/connect-query-key.ts b/packages/connect-query/src/connect-query-key.ts index 8efb5d35..7c4ebcb1 100644 --- a/packages/connect-query/src/connect-query-key.ts +++ b/packages/connect-query/src/connect-query-key.ts @@ -12,16 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { DescMessage, MessageInitShape } from "@bufbuild/protobuf"; +import type { + DescMethod, + DescService, + MessageInitShape, +} from "@bufbuild/protobuf"; +import type { Transport } from "@connectrpc/connect"; import type { SkipToken } from "@tanstack/react-query"; -import { skipToken } from "@tanstack/react-query"; import { createMessageKey } from "./message-key.js"; -import type { MethodUnaryDescriptor } from "./method-unary-descriptor.js"; +import { createTransportKey } from "./transport-key.js"; /** * TanStack Query requires query keys in order to decide when the query should automatically update. * + * + * TODO + * * `QueryKey`s in TanStack Query are usually arbitrary, but Connect-Query uses the approach of creating a query key that begins with the least specific information: the service's `typeName`, followed by the method name, and ending with the most specific information to identify a particular request: the input message itself. * * For example, for a query key might look like this: @@ -34,11 +41,73 @@ import type { MethodUnaryDescriptor } from "./method-unary-descriptor.js"; * ] */ export type ConnectQueryKey = [ - serviceTypeName: string, - methodName: string, - input: Record, + "connect-query", + { + /** + * A key for a Transport reference, created with createTransportKey(). + */ + transport?: string; + /** + * The name of the service, e.g. connectrpc.eliza.v1.ElizaService + */ + serviceName: string; + /** + * The name of the method, e.g. Say. + */ + methodName?: string; + /** + * Whether this is an infinite query, or a regular one. + */ + cardinality?: "infinite" | "finite"; + /** + * A key for the request message, created with createMessageKey(), + * or "skipped". + */ + input?: Record | "skipped"; + }, ]; +type KeyParams = Desc extends DescMethod + ? { + /** + * Set `serviceName` and `methodName` in the key. + */ + method: Desc; + /** + * Set `input` in the key: + * - If a SkipToken is provided, `input` is "skipped". + * - If an init shape is provided, `input` is set to a message key. + * - If omitted or undefined, `input` is not set in the key. + */ + input?: MessageInitShape | SkipToken | undefined; + /** + * Set `transport` in the key. + */ + transport?: Transport; + /** + * Set `cardinality` in the key - "finite" by default. + */ + cardinality?: "finite" | "infinite" | "any"; + /** + * If omit the field with this name from the key for infinite queries. + */ + pageParamKey?: keyof MessageInitShape; + } + : { + /** + * Set `serviceName` in the key, and omit `methodName`. + */ + service: Desc; + /** + * Set `transport` in the key. + */ + transport?: Transport; + /** + * Set `cardinality` in the key - "finite" by default. + */ + cardinality?: "finite" | "infinite" | "any"; + }; + /** * TanStack Query requires query keys in order to decide when the query should automatically update. * @@ -46,39 +115,46 @@ export type ConnectQueryKey = [ * * @see ConnectQueryKey for information on the components of Connect-Query's keys. */ -export function createConnectQueryKey< - I extends DescMessage, - O extends DescMessage, ->( - schema: Pick, "input" | "parent" | "name">, - input?: SkipToken | MessageInitShape | undefined, +export function createConnectQueryKey( + params: KeyParams, ): ConnectQueryKey { - const key = - input === skipToken || input === undefined - ? createMessageKey(schema.input, {} as MessageInitShape) - : createMessageKey(schema.input, input); - return [schema.parent.typeName, schema.name, key]; -} - -/** - * Similar to @see ConnectQueryKey, but for infinite queries. - */ -export type ConnectInfiniteQueryKey = [ - serviceTypeName: string, - methodName: string, - input: Record, - "infinite", -]; - -/** - * Similar to @see createConnectQueryKey, but for infinite queries. - */ -export function createConnectInfiniteQueryKey< - I extends DescMessage, - O extends DescMessage, ->( - schema: Pick, "input" | "parent" | "name">, - input?: SkipToken | MessageInitShape | undefined, -): ConnectInfiniteQueryKey { - return [...createConnectQueryKey(schema, input), "infinite"]; + const props: ConnectQueryKey[1] = + "method" in params + ? { + serviceName: params.method.parent.typeName, + methodName: params.method.name, + } + : { + serviceName: params.service.typeName, + }; + if (params.transport !== undefined) { + props.transport = createTransportKey(params.transport); + } + // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- "Cases not matched: undefined" 🤷 + switch (params.cardinality) { + case undefined: + case "finite": + props.cardinality = "finite"; + break; + case "infinite": + props.cardinality = "infinite"; + break; + case "any": + break; + } + if ("method" in params && typeof params.input == "symbol") { + props.input = "skipped"; + } + if ("method" in params) { + if (typeof params.input == "symbol") { + props.input = "skipped"; + } else if (params.input !== undefined) { + props.input = createMessageKey( + params.method.input, + params.input, + params.pageParamKey, + ); + } + } + return ["connect-query", props]; } diff --git a/packages/connect-query/src/create-infinite-query-options.ts b/packages/connect-query/src/create-infinite-query-options.ts index d679affc..5bb8e30f 100644 --- a/packages/connect-query/src/create-infinite-query-options.ts +++ b/packages/connect-query/src/create-infinite-query-options.ts @@ -29,8 +29,8 @@ import { skipToken } from "@tanstack/react-query"; import { callUnaryMethod } from "./call-unary-method.js"; import { - type ConnectInfiniteQueryKey, - createConnectInfiniteQueryKey, + type ConnectQueryKey, + createConnectQueryKey, } from "./connect-query-key.js"; import type { MethodUnaryDescriptor } from "./method-unary-descriptor.js"; import { createStructuralSharing } from "./structural-sharing.js"; @@ -69,7 +69,7 @@ function createUnaryInfiniteQueryFn< }, ): QueryFunction< MessageShape, - ConnectInfiniteQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] > { return async (context) => { @@ -108,11 +108,11 @@ export function createInfiniteQueryOptions< O, ParamKey >["getNextPageParam"]; - queryKey: ConnectInfiniteQueryKey; + queryKey: ConnectQueryKey; queryFn: | QueryFunction< MessageShape, - ConnectInfiniteQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] > | SkipToken; @@ -120,15 +120,12 @@ export function createInfiniteQueryOptions< initialPageParam: MessageInitShape[ParamKey]; queryKeyHashFn: (queryKey: QueryKey) => string; } { - const queryKey = createConnectInfiniteQueryKey( - schema, - input === skipToken - ? undefined - : { - ...input, - [pageParamKey]: undefined, - }, - ); + const queryKey = createConnectQueryKey({ + cardinality: "infinite", + method: schema, + transport, + input, + }); const structuralSharing = createStructuralSharing(schema.output); const queryFn = input === skipToken diff --git a/packages/connect-query/src/create-query-options.test.ts b/packages/connect-query/src/create-query-options.test.ts index 981d0471..0316c9b8 100644 --- a/packages/connect-query/src/create-query-options.test.ts +++ b/packages/connect-query/src/create-query-options.test.ts @@ -25,7 +25,7 @@ const sayMethodDescriptor = ElizaService.method.say; const mockedElizaTransport = mockEliza(); -describe("createUseQueryOptions", () => { +describe.only("createQueryOptions", () => { it("honors skipToken", () => { const opt = createQueryOptions(sayMethodDescriptor, skipToken, { transport: mockedElizaTransport, @@ -33,7 +33,11 @@ describe("createUseQueryOptions", () => { expect(opt.queryFn).toBe(skipToken); }); it("sets queryKey", () => { - const want = createConnectQueryKey(sayMethodDescriptor, { sentence: "hi" }); + const want = createConnectQueryKey({ + method: sayMethodDescriptor, + input: { sentence: "hi" }, + transport: mockedElizaTransport, + }); const opt = createQueryOptions( sayMethodDescriptor, { sentence: "hi" }, diff --git a/packages/connect-query/src/create-query-options.ts b/packages/connect-query/src/create-query-options.ts index 110aac53..34b44d83 100644 --- a/packages/connect-query/src/create-query-options.ts +++ b/packages/connect-query/src/create-query-options.ts @@ -17,6 +17,7 @@ import type { MessageInitShape, MessageShape, } from "@bufbuild/protobuf"; +import { create } from "@bufbuild/protobuf"; import type { Transport } from "@connectrpc/connect"; import type { QueryFunction, @@ -67,7 +68,11 @@ export function createQueryOptions< >; queryKeyHashFn: (queryKey: QueryKey) => string; } { - const queryKey = createConnectQueryKey(schema, input); + const queryKey = createConnectQueryKey({ + method: schema, + input: input ?? create(schema.input), + transport, + }); const structuralSharing = createStructuralSharing(schema.output); const queryFn = input === skipToken diff --git a/packages/connect-query/src/index.ts b/packages/connect-query/src/index.ts index aecaa2ae..61187be5 100644 --- a/packages/connect-query/src/index.ts +++ b/packages/connect-query/src/index.ts @@ -12,14 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -export type { - ConnectQueryKey, - ConnectInfiniteQueryKey, -} from "./connect-query-key.js"; -export { - createConnectQueryKey, - createConnectInfiniteQueryKey, -} from "./connect-query-key.js"; +export type { ConnectQueryKey } from "./connect-query-key.js"; +export { createConnectQueryKey } from "./connect-query-key.js"; export { createProtobufSafeUpdater } from "./utils.js"; export { useTransport, TransportProvider } from "./use-transport.js"; export { diff --git a/packages/connect-query/src/message-key.test.ts b/packages/connect-query/src/message-key.test.ts index 2a1fbc1f..e7609ee0 100644 --- a/packages/connect-query/src/message-key.test.ts +++ b/packages/connect-query/src/message-key.test.ts @@ -32,6 +32,17 @@ describe("message key", () => { const key = createMessageKey(schema, message); expect(key).toStrictEqual({}); }); + it("omits the pageParamKey", () => { + const schema = Proto3MessageSchema; + const message = create(schema, { + int32Field: 123, + stringField: "abc", + }); + const key = createMessageKey(schema, message, "int32Field"); + expect(key).toStrictEqual({ + stringField: "abc", + }); + }); it("converts as expected", () => { const key = createMessageKey(Proto3MessageSchema, { int64Field: 123n, diff --git a/packages/connect-query/src/message-key.ts b/packages/connect-query/src/message-key.ts index 1d729fdb..7a59e9f6 100644 --- a/packages/connect-query/src/message-key.ts +++ b/packages/connect-query/src/message-key.ts @@ -32,13 +32,22 @@ import { base64Encode } from "@bufbuild/protobuf/wire"; * - BigInt values are converted to a string. * - Properties are sorted by Protobuf source order. * - Map keys are sorted with Array.sort. + * + * If pageParamKey is provided, omit the field with this name from the key. */ -export function createMessageKey( +export function createMessageKey< + Desc extends DescMessage, + PageParamKey extends keyof MessageInitShape, +>( schema: Desc, value: MessageInitShape, + pageParamKey?: PageParamKey, ): Record { // eslint-disable-next-line @typescript-eslint/no-use-before-define -- circular reference - return messageKey(reflect(schema, create(schema, value))); + return messageKey( + reflect(schema, create(schema, value)), + pageParamKey?.toString(), + ); } function scalarKey(value: unknown): unknown { @@ -62,7 +71,7 @@ function listKey(list: ReflectList): unknown[] { } if (listKind == "message") { // eslint-disable-next-line @typescript-eslint/no-use-before-define -- circular reference - return (arr as ReflectMessage[]).map(messageKey); + return (arr as ReflectMessage[]).map((m) => messageKey(m)); } return arr; } @@ -88,12 +97,18 @@ function mapKey(map: ReflectMap): Record { }, {}); } -function messageKey(message: ReflectMessage): Record { +function messageKey( + message: ReflectMessage, + pageParamKey?: string, +): Record { const result: Record = {}; for (const f of message.sortedFields) { if (!message.isSet(f)) { continue; } + if (f.localName === pageParamKey) { + continue; + } switch (f.fieldKind) { case "scalar": result[f.localName] = scalarKey(message.get(f)); diff --git a/packages/connect-query/src/use-infinite-query.test.ts b/packages/connect-query/src/use-infinite-query.test.ts index 1ab96b81..a7af5c20 100644 --- a/packages/connect-query/src/use-infinite-query.test.ts +++ b/packages/connect-query/src/use-infinite-query.test.ts @@ -17,10 +17,7 @@ import { QueryCache, skipToken } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { - createConnectInfiniteQueryKey, - createConnectQueryKey, -} from "./connect-query-key.js"; +import { createConnectQueryKey } from "./connect-query-key.js"; import { defaultOptions } from "./default-options.js"; import { ListResponseSchema, ListService } from "./gen/list_pb.js"; import { mockPaginatedTransport, wrapper } from "./test/test-utils.js"; @@ -107,6 +104,10 @@ describe("useInfiniteQuery", () => { }); it("can be provided a custom transport", async () => { + const customTransport = mockPaginatedTransport({ + items: ["Intercepted!"], + page: 0n, + }); const { result } = renderHook( () => { return useInfiniteQuery( @@ -117,10 +118,7 @@ describe("useInfiniteQuery", () => { { getNextPageParam: (lastPage) => lastPage.page + 1n, pageParamKey: "page", - transport: mockPaginatedTransport({ - items: ["Intercepted!"], - page: 0n, - }), + transport: customTransport, }, ); }, @@ -196,7 +194,13 @@ describe("useInfiniteQuery", () => { expect(cache).toHaveLength(1); expect(cache[0].queryKey).toEqual( - createConnectInfiniteQueryKey(methodDescriptor, {}), + createConnectQueryKey({ + method: methodDescriptor, + transport: mockedPaginatedTransport, + cardinality: "infinite", + pageParamKey: "page", + input: {}, + }), ); await waitFor(() => { @@ -277,7 +281,57 @@ describe("useInfiniteQuery", () => { expect(onSuccessSpy).toHaveBeenCalledTimes(1); await queryClient.invalidateQueries({ - queryKey: createConnectQueryKey(methodDescriptor), + queryKey: createConnectQueryKey({ + method: methodDescriptor, + transport: mockedPaginatedTransport, + cardinality: "any", + pageParamKey: "page", + input: { + page: 0n, + }, + }), + }); + + expect(onSuccessSpy).toHaveBeenCalledTimes(2); + }); + + it("cache can be invalidated with a non-exact key", async () => { + const onSuccessSpy = vi.fn(); + const spiedQueryCache = new QueryCache({ + onSuccess: onSuccessSpy, + }); + const { queryClient, ...remainingWrapper } = wrapper( + { + defaultOptions, + queryCache: spiedQueryCache, + }, + mockedPaginatedTransport, + ); + const { result } = renderHook(() => { + return useInfiniteQuery( + methodDescriptor, + { + page: 0n, + }, + { + getNextPageParam: (lastPage) => lastPage.page + 1n, + pageParamKey: "page", + }, + ); + }, remainingWrapper); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(onSuccessSpy).toHaveBeenCalledTimes(1); + + await queryClient.invalidateQueries({ + exact: false, + queryKey: createConnectQueryKey({ + method: methodDescriptor, + cardinality: "infinite", + }), }); expect(onSuccessSpy).toHaveBeenCalledTimes(2); diff --git a/packages/connect-query/src/use-infinite-query.ts b/packages/connect-query/src/use-infinite-query.ts index a850809f..44b4b9e4 100644 --- a/packages/connect-query/src/use-infinite-query.ts +++ b/packages/connect-query/src/use-infinite-query.ts @@ -31,7 +31,7 @@ import { useSuspenseInfiniteQuery as tsUseSuspenseInfiniteQuery, } from "@tanstack/react-query"; -import type { ConnectInfiniteQueryKey } from "./connect-query-key.js"; +import type { ConnectQueryKey } from "./connect-query-key.js"; import type { ConnectInfiniteQueryOptions } from "./create-infinite-query-options.js"; import { createInfiniteQueryOptions } from "./create-infinite-query-options.js"; import type { MethodUnaryDescriptor } from "./method-unary-descriptor.js"; @@ -50,7 +50,7 @@ export type UseInfiniteQueryOptions< ConnectError, InfiniteData>, MessageShape, - ConnectInfiniteQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] >, "getNextPageParam" | "initialPageParam" | "queryFn" | "queryKey" @@ -104,7 +104,7 @@ export type UseSuspenseInfiniteQueryOptions< ConnectError, InfiniteData>, MessageShape, - ConnectInfiniteQueryKey, + ConnectQueryKey, MessageInitShape[ParamKey] >, "getNextPageParam" | "initialPageParam" | "queryFn" | "queryKey" diff --git a/packages/connect-query/src/use-query.test.ts b/packages/connect-query/src/use-query.test.ts index 2e553d0a..df8f65ae 100644 --- a/packages/connect-query/src/use-query.test.ts +++ b/packages/connect-query/src/use-query.test.ts @@ -58,15 +58,16 @@ describe("useQuery", () => { }); it("can be provided a custom transport", async () => { + const transport = mockEliza({ + sentence: "Intercepted!", + }); const { result } = renderHook( () => { return useQuery( sayMethodDescriptor, {}, { - transport: mockEliza({ - sentence: "Intercepted!", - }), + transport, }, ); },