Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add useGetAggregateFromServerQuery hook #106

Merged
2 changes: 1 addition & 1 deletion packages/react/src/firestore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
export { useDocumentQuery } from "./useDocumentQuery";
// useDocumentsQuery <-- Name? useQuery? Bit generic.
// useGetCountFromServerQuery
// useGetAggregateFromServerQuery
export { useGetAggregateFromServerQuery } from "./useGetAggregateFromServerQuery";
// useNamedQuery
156 changes: 156 additions & 0 deletions packages/react/src/firestore/useGetAggregateFromServerQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

describe("useGetAggregateFromServerQuery", () => {
beforeEach(async () => await wipeFirestore());

test("returns correct count for empty collection", async () => {
const collectionRef = collection(firestore, "tests");

const { result } = renderHook(
() =>
useGetAggregateFromServerQuery(collectionRef, {
queryKey: ["aggregate", "empty"],
firestore: { aggregateSpec: { count: count() } },
}),
{ wrapper }
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.count).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, {
queryKey: ["aggregate", "non-empty"],
firestore: {
aggregateSpec: {
count: count(),
sum: sum("value"),
avg: average("value"),
},
},
}),
{ wrapper }
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.count).toBe(3);
expect(result.current.data?.sum).toBe(60);
expect(result.current.data?.avg).toBe(20);
});

test("handles complex queries", async () => {
const collectionRef = collection(firestore, "tests");

await addDoc(collectionRef, { category: "A", value: 10 });
await addDoc(collectionRef, { category: "B", value: 20 });
await addDoc(collectionRef, { category: "A", value: 30 });
await addDoc(collectionRef, { category: "C", value: 40 });

const complexQuery = query(collectionRef, where("category", "==", "A"));

const { result } = renderHook(
() =>
useGetAggregateFromServerQuery(complexQuery, {
queryKey: ["aggregate", "complex"],
firestore: {
aggregateSpec: {
count: count(),
sum: sum("value"),
avg: average("value"),
},
},
}),
{ wrapper }
);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.count).toBe(2);
expect(result.current.data?.sum).toBe(40);
expect(result.current.data?.avg).toBe(20);
});

test("handles restricted collections appropriately", async () => {
const restrictedCollectionRef = collection(
firestore,
"restrictedCollection"
);

const { result } = renderHook(
() =>
useGetAggregateFromServerQuery(restrictedCollectionRef, {
queryKey: ["aggregate", "restricted"],
firestore: { aggregateSpec: { count: count() } },
}),
{ 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, {
queryKey: ["aggregate", "pending"],
firestore: { aggregateSpec: { count: count() } },
}),
{ wrapper }
);

// Initially isPending should be true
expect(result.current.isPending).toBe(true);

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.count).toBe(1);
});
});
41 changes: 41 additions & 0 deletions packages/react/src/firestore/useGetAggregateFromServerQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import {
type Query,
type FirestoreError,
getAggregateFromServer,
type AggregateSpec,
type AggregateField,
} from "firebase/firestore";

type FirestoreUseQueryOptions<TData = unknown, TError = Error> = Omit<
UseQueryOptions<TData, TError>,
"queryFn"
> & {
firestore: {
aggregateSpec: AggregateSpec;
};
};

type AggregateResult<T extends AggregateSpec> = {
[K in keyof T]: AggregateField<T[K]> extends AggregateField<infer R>
? R
: never;
};

export function useGetAggregateFromServerQuery<T extends AggregateSpec>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should change the API spec here, since the aggregates are a requirement, so:

useGetAggregateFromServerQuery(query, {
    countOfDocs: count(),
    totalHours: sum('hours'),
    averageScore: average('score')
});

Let's keep any optional parts in the firestore key and make sure they are all optional values?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the clarity here. absolutely, going to adjust here and keep all required parts at the top level and optional parts maintaining them as optional in the firestore key object

query: Query,
options: FirestoreUseQueryOptions<AggregateResult<T>, FirestoreError>
) {
const { firestore, ...queryOptions } = options;

return useQuery<AggregateResult<T>, FirestoreError>({
...queryOptions,
queryFn: async () => {
const snapshot = await getAggregateFromServer(
query,
firestore.aggregateSpec
);
return snapshot.data() as AggregateResult<T>;
},
});
}
Loading