Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ export const PostRepository = Firestore.makeRepository(PostModel, {
);
```

### Query a collection group

Every repository has a `group` view that runs the same query methods across
every collection with the same ID at any depth (`posts/{postId}/comments`,
`users/{userId}/comments`). `pathField` fills in each document's full path.

```typescript
export const CommentRepository = (postId: string) =>
Firestore.makeRepository(CommentModel, {
collectionPath: `posts/${postId}/comments`,
idField: 'id',
pathField: 'path',
spanPrefix: 'CommentRepository',
});

// repo.query(...) — comments on this post
// repo.group.query(...) — comments on every post
```

### Writes at a known ID

`add` lets Firestore pick the ID; `set` writes at an ID the caller already
Expand Down
63 changes: 61 additions & 2 deletions packages/admin/src/lib/firestore/firestore-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,12 @@ const makeFakeDb = () => {

const tx = {
get: async (refOrQuery: { path: string; doc?: unknown }) => {
// Collection refs (queries) have a doc factory, document refs do not.
if (typeof refOrQuery.doc === 'function') {
// Collection refs (queries) have a doc factory, document refs do not;
// collection group refs are marked by their fake path prefix.
if (
typeof refOrQuery.doc === 'function' ||
refOrQuery.path.startsWith('group:')
) {
state.txOps.push(['query', refOrQuery.path]);
return {
docs: [fakeSnapshot(`${refOrQuery.path}/1`, { title: 'tx' })],
Expand Down Expand Up @@ -113,9 +117,23 @@ const makeFakeDb = () => {
};
};

const fakeCollectionGroup = (id: string): Record<string, unknown> => ({
path: `group:${id}`,
get: async () => {
state.directOps.push(['queryGroup', id]);
return {
docs: [
fakeSnapshot(`posts/p1/${id}/1`, { title: 'nested' }),
fakeSnapshot(`users/u1/${id}/2`, { title: 'nested' }),
],
};
},
});

const db = {
doc: fakeDocRef,
collection: fakeCollection,
collectionGroup: fakeCollectionGroup,
recursiveDelete: async (ref: { path: string }) => {
state.directOps.push(['recursiveDelete', ref.path]);
},
Expand Down Expand Up @@ -201,6 +219,17 @@ describe('FirestoreService (admin)', () => {
expect(results[0][1]).toEqual({ title: 'tx' });
});

it('routes collection group queries through transaction.get', async () => {
const { db, state } = makeFakeDb();
const results = await run(
db,
withService((fs) => fs.withTransaction(fs.queryGroup('comments', []))),
);

expect(state.txOps).toEqual([['query', 'group:comments']]);
expect(results).toHaveLength(1);
});

it('returns the result of the effect', async () => {
const { db } = makeFakeDb();
const result = await run(
Expand Down Expand Up @@ -415,5 +444,35 @@ describe('FirestoreService (admin)', () => {
expect(state.txOps).toEqual([]);
expect(state.batchOps).toEqual([]);
});

it('fails typed on a malformed collection group ID, effect and stream', async () => {
const { db } = makeFakeDb();
const fromEffect = await run(
db,
withService((fs) => Effect.flip(fs.queryGroup('a/b', []))),
);
const fromStream = await run(
db,
withService((fs) =>
Effect.flip(Stream.runCollect(fs.streamQueryGroup('a/b', []))),
),
);
expect(fromEffect.code).toBe('invalid-argument');
expect(fromStream.code).toBe('invalid-argument');
});

it('queries a collection group across parents', async () => {
const { db, state } = makeFakeDb();
const results = await run(
db,
withService((fs) => fs.queryGroup('comments', [])),
);

expect(state.directOps).toEqual([['queryGroup', 'comments']]);
expect(results.map(([ref]) => ref.path)).toEqual([
'posts/p1/comments/1',
'users/u1/comments/2',
]);
});
});
});
85 changes: 66 additions & 19 deletions packages/admin/src/lib/firestore/firestore-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,21 @@ import {
FirestoreError,
FirestoreService,
makeSnapshotPacker,
validateCollectionId,
} from 'effect-firebase';
import type { App as FirebaseAdminApp } from 'firebase-admin/app';
import type { Snapshot } from 'effect-firebase';
import { UnknownError } from 'effect/Cause';
import {
getFirestore,
type Firestore,
type Query,
type Transaction,
type WriteBatch,
} from 'firebase-admin/firestore';
import { App } from '../app.js';
import { firestoreDecode, makeConverter } from './converter.js';
import { buildQuery } from './query-builder.js';
import { buildCollectionGroupQuery, buildQuery } from './query-builder.js';

const packSnapshot = makeSnapshotPacker(firestoreDecode);

Expand Down Expand Up @@ -193,15 +195,14 @@ const make = (db: Firestore) => {
),
);

const streamQuery = (
collectionPath: string,
constraints: Parameters<typeof buildQuery>[2],
const streamQueryOf = (
makeQuery: () => Query,
options?: Parameters<typeof packSnapshot>[1],
) =>
Stream.callback<ReadonlyArray<Snapshot>, FirestoreError>((queue) =>
Effect.acquireRelease(
Effect.sync(() => {
const query = buildQuery(db, collectionPath, constraints);
const query = makeQuery();
return query.onSnapshot(
(snapshot) => {
const snapshots = Arr.filterMap(snapshot.docs, (doc) =>
Expand All @@ -226,6 +227,39 @@ const make = (db: Firestore) => {
),
);

// The SDK throws synchronously on a malformed collection ID; validating
// up front turns that into a typed failure for both the effect and the
// stream, matching the mock.
const checkCollectionId = (
collectionId: string,
): Effect.Effect<void, FirestoreError> => {
const invalid = validateCollectionId(collectionId);
return invalid === undefined
? Effect.void
: Effect.fail(
new FirestoreError({
code: 'invalid-argument',
name: 'FirestoreError',
message: invalid,
}),
);
};

const runQuery = (makeQuery: () => Query) =>
Effect.gen(function* () {
const tx = yield* CurrentTransaction;
const snapshot = yield* Effect.tryPromise({
try: () => {
const query = makeQuery();
return Option.isSome(tx) ? tx.value.get(query) : query.get();
},
catch: (error) => mapError(error),
});
return Arr.filterMap(snapshot.docs, (doc) =>
Result.fromOption(packSnapshot(doc), () => void 0),
);
});

return FirestoreService.of({
get: (path, options) =>
Effect.gen(function* () {
Expand Down Expand Up @@ -317,19 +351,15 @@ const make = (db: Firestore) => {
),
),
query: (collectionPath, constraints) =>
Effect.gen(function* () {
const tx = yield* CurrentTransaction;
const snapshot = yield* Effect.tryPromise({
try: () => {
const query = buildQuery(db, collectionPath, constraints);
return Option.isSome(tx) ? tx.value.get(query) : query.get();
},
catch: (error) => mapError(error),
});
return Arr.filterMap(snapshot.docs, (doc) =>
Result.fromOption(packSnapshot(doc), () => void 0),
);
}),
runQuery(() => buildQuery(db, collectionPath, constraints)),
queryGroup: (collectionId, constraints) =>
checkCollectionId(collectionId).pipe(
Effect.flatMap(() =>
runQuery(() =>
buildCollectionGroupQuery(db, collectionId, constraints),
),
),
),
streamDoc: (path, options) =>
Stream.unwrap(
assertNoTransaction('streamDoc').pipe(
Expand All @@ -339,7 +369,24 @@ const make = (db: Firestore) => {
streamQuery: (collectionPath, constraints, options) =>
Stream.unwrap(
assertNoTransaction('streamQuery').pipe(
Effect.map(() => streamQuery(collectionPath, constraints, options)),
Effect.map(() =>
streamQueryOf(
() => buildQuery(db, collectionPath, constraints),
options,
),
),
),
),
streamQueryGroup: (collectionId, constraints, options) =>
Stream.unwrap(
assertNoTransaction('streamQueryGroup').pipe(
Effect.andThen(checkCollectionId(collectionId)),
Effect.map(() =>
streamQueryOf(
() => buildCollectionGroupQuery(db, collectionId, constraints),
options,
),
),
),
),
withTransaction: <A, E, R>(self: Effect.Effect<A, E, R>) =>
Expand Down
32 changes: 26 additions & 6 deletions packages/admin/src/lib/firestore/query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,20 +108,18 @@ const isCompositeFilter = (
constraint._tag === 'And' || constraint._tag === 'Or';

/**
* Build a Firebase Admin SDK query from a collection path and constraints.
* Apply constraints to a base query (a collection or collection group).
*/
export const buildQuery = (
const applyConstraints = (
db: Firestore,
collectionPath: string,
base: Query,
constraints: ReadonlyArray<QueryConstraint>,
): Query => {
const collectionRef: CollectionReference = db.collection(collectionPath);

// Separate composite filters from other constraints
const compositeFilters = constraints.filter(isCompositeFilter);
const otherConstraints = constraints.filter((c) => !isCompositeFilter(c));

let query: Query = collectionRef;
let query: Query = base;

// Apply composite filters first (there should be at most one top-level composite)
for (const filter of compositeFilters) {
Expand All @@ -135,3 +133,25 @@ export const buildQuery = (

return query;
};

/**
* Build a Firebase Admin SDK query from a collection path and constraints.
*/
export const buildQuery = (
db: Firestore,
collectionPath: string,
constraints: ReadonlyArray<QueryConstraint>,
): Query => {
const collectionRef: CollectionReference = db.collection(collectionPath);
return applyConstraints(db, collectionRef, constraints);
};

/**
* Build a Firebase Admin SDK collection group query from a collection ID and
* constraints.
*/
export const buildCollectionGroupQuery = (
db: Firestore,
collectionId: string,
constraints: ReadonlyArray<QueryConstraint>,
): Query => applyConstraints(db, db.collectionGroup(collectionId), constraints);
36 changes: 35 additions & 1 deletion packages/client/src/lib/firestore/firestore-service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { Cause, Data, Effect, Exit, Result } from 'effect';
import { Cause, Data, Effect, Exit, Result, Stream } from 'effect';
import { FirestoreService } from 'effect-firebase';
import type { Firestore } from 'firebase/firestore';

Expand Down Expand Up @@ -107,6 +107,7 @@ vi.mock('firebase/firestore', async (importOriginal) => {
? h.fakeDocRef(path)
: h.fakeDocRef(`${dbOrCollection.path}/generated-id`),
collection: (_db: unknown, path: string) => h.fakeCollection(path),
collectionGroup: (_db: unknown, id: string) => ({ path: `group:${id}` }),
query: (ref: unknown) => ref,
getDoc: async (ref: { path: string }) => {
h.state.directOps.push(['get', ref.path]);
Expand Down Expand Up @@ -250,6 +251,17 @@ describe('FirestoreService (client)', () => {
expect(Cause.hasDies(exit.cause)).toBe(true);
}
});

it('dies when querying a collection group inside a transaction', async () => {
const exit = await runExit(
withService((fs) => fs.withTransaction(fs.queryGroup('comments', []))),
);

expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(Cause.hasDies(exit.cause)).toBe(true);
}
});
});

describe('withBatch', () => {
Expand Down Expand Up @@ -342,5 +354,27 @@ describe('FirestoreService (client)', () => {
expect(h.state.txOps).toEqual([]);
expect(h.state.batchOps).toEqual([]);
});

it('fails typed on a malformed collection group ID, effect and stream', async () => {
const fromEffect = await run(
withService((fs) => Effect.flip(fs.queryGroup('a/b', []))),
);
const fromStream = await run(
withService((fs) =>
Effect.flip(Stream.runCollect(fs.streamQueryGroup('a/b', []))),
),
);
expect(fromEffect.code).toBe('invalid-argument');
expect(fromStream.code).toBe('invalid-argument');
});

it('queries a collection group directly', async () => {
const results = await run(
withService((fs) => fs.queryGroup('comments', [])),
);

expect(h.state.directOps).toEqual([['query', 'group:comments']]);
expect(results).toHaveLength(1);
});
});
});
Loading
Loading