diff --git a/packages/react/src/firestore/index.ts b/packages/react/src/firestore/index.ts index 05c6531..099a82e 100644 --- a/packages/react/src/firestore/index.ts +++ b/packages/react/src/firestore/index.ts @@ -7,6 +7,8 @@ // useWriteBatchCommitMutation (WriteBatch) export { useDocumentQuery } from "./useDocumentQuery"; export { useCollectionQuery } from "./useCollectionQuery"; +// useGetCountFromServerQuery +export { useGetAggregateFromServerQuery } from "./useGetAggregateFromServerQuery"; export { useGetCountFromServerQuery } from "./useGetCountFromServerQuery"; // useGetAggregateFromServerQuery // useNamedQuery diff --git a/packages/react/src/firestore/useGetAggregateFromServerQuery.test.tsx b/packages/react/src/firestore/useGetAggregateFromServerQuery.test.tsx new file mode 100644 index 0000000..7171ba4 --- /dev/null +++ b/packages/react/src/firestore/useGetAggregateFromServerQuery.test.tsx @@ -0,0 +1,154 @@ +import React, { type ReactNode } from "react"; +import { describe, expect, test, beforeEach } from "vitest"; +import { useGetAggregateFromServerQuery } from "./useGetAggregateFromServerQuery"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + collection, + addDoc, + query, + where, + sum, + average, + count, +} from "firebase/firestore"; +import { + expectFirestoreError, + firestore, + wipeFirestore, +} from "~/testing-utils"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe("useGetAggregateFromServerQuery", () => { + beforeEach(async () => await wipeFirestore()); + + test("returns correct count for empty collection", async () => { + const collectionRef = collection(firestore, "tests"); + + const { result } = renderHook( + () => + useGetAggregateFromServerQuery( + collectionRef, + { countOfDocs: count() }, + { queryKey: ["aggregate", "empty"] } + ), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.data().countOfDocs).toBe(0); + }); + + test("returns correct aggregate values for non-empty collection", async () => { + const collectionRef = collection(firestore, "tests"); + + await addDoc(collectionRef, { value: 10 }); + await addDoc(collectionRef, { value: 20 }); + await addDoc(collectionRef, { value: 30 }); + + const { result } = renderHook( + () => + useGetAggregateFromServerQuery( + collectionRef, + { + countOfDocs: count(), + averageValue: average("value"), + totalValue: sum("value"), + }, + { queryKey: ["aggregate", "non-empty"] } + ), + { wrapper } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.data().averageValue).toBe(20); + expect(result.current.data?.data().totalValue).toBe(60); + expect(result.current.data?.data().countOfDocs).toBe(3); + }); + + test("handles complex queries", async () => { + const collectionRef = collection(firestore, "tests"); + + await addDoc(collectionRef, { category: "A", books: 10 }); + await addDoc(collectionRef, { category: "B", books: 20 }); + await addDoc(collectionRef, { category: "A", books: 30 }); + await addDoc(collectionRef, { category: "C", books: 40 }); + + const complexQuery = query(collectionRef, where("category", "==", "A")); + + const { result } = renderHook( + () => + useGetAggregateFromServerQuery( + complexQuery, + { + countOfDocs: count(), + averageNumberOfBooks: average("books"), + totalNumberOfBooks: sum("books"), + }, + { queryKey: ["aggregate", "complex"] } + ), + { + wrapper, + } + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.data().averageNumberOfBooks).toBe(20); + expect(result.current.data?.data().totalNumberOfBooks).toBe(40); + expect(result.current.data?.data().countOfDocs).toBe(2); + }); + + test("handles restricted collection appropriately", async () => { + const collectionRef = collection(firestore, "restrictedCollection"); + + const { result } = renderHook( + () => + useGetAggregateFromServerQuery( + collectionRef, + { count: count() }, + { queryKey: ["aggregate", "restricted"] } + ), + { wrapper } + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + + expectFirestoreError(result.current.error, "permission-denied"); + }); + + test("returns pending state initially", async () => { + const collectionRef = collection(firestore, "tests"); + + await addDoc(collectionRef, { value: 10 }); + + const { result } = renderHook( + () => + useGetAggregateFromServerQuery( + collectionRef, + { count: count() }, + { queryKey: ["aggregate", "pending"] } + ), + { wrapper } + ); + + expect(result.current.isPending).toBe(true); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.data().count).toBe(1); + }); +}); diff --git a/packages/react/src/firestore/useGetAggregateFromServerQuery.ts b/packages/react/src/firestore/useGetAggregateFromServerQuery.ts new file mode 100644 index 0000000..49cadb9 --- /dev/null +++ b/packages/react/src/firestore/useGetAggregateFromServerQuery.ts @@ -0,0 +1,35 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { + type Query, + type FirestoreError, + getAggregateFromServer, + type AggregateSpec, + type DocumentData, + type AggregateQuerySnapshot, +} from "firebase/firestore"; + +type FirestoreUseQueryOptions = Omit< + UseQueryOptions, + "queryFn" +>; + +export function useGetAggregateFromServerQuery< + T extends AggregateSpec, + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData +>( + query: Query, + aggregateSpec: T, + options: FirestoreUseQueryOptions< + AggregateQuerySnapshot, + FirestoreError + > +) { + return useQuery< + AggregateQuerySnapshot, + FirestoreError + >({ + ...options, + queryFn: () => getAggregateFromServer(query, aggregateSpec), + }); +}