diff --git a/apps/api/src/__tests__/Database.spec.ts b/apps/api/src/__tests__/Database.spec.ts new file mode 100644 index 0000000..158accb --- /dev/null +++ b/apps/api/src/__tests__/Database.spec.ts @@ -0,0 +1,70 @@ +import { Database } from '../modules/Database'; +import { QueryOperators } from '@zoneless/shared-types'; + +describe('Database', () => { + let db: Database; + let find: jest.Mock; + + beforeEach(() => { + db = new Database(); + const query = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + lean: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + }; + find = jest.fn().mockReturnValue(query); + jest + .spyOn(db as unknown as { GetModel: () => unknown }, 'GetModel') + .mockReturnValue({ find }); + }); + + describe('Query', () => { + it('should combine range conditions on the same field', async () => { + await db.Query({ + collection: 'Charges', + method: 'READ', + parameters: [ + { key: 'created', operator: QueryOperators['>='], value: 1010 }, + { key: 'created', operator: QueryOperators['<='], value: 1020 }, + { key: 'account', operator: QueryOperators['=='], value: 'acct_1' }, + ], + }); + + expect(find).toHaveBeenCalledWith({ + created: { $gte: 1010, $lte: 1020 }, + account: 'acct_1', + }); + }); + + it('should keep range conditions when paginating with startAfter', async () => { + await db.Query({ + collection: 'Charges', + method: 'READ', + parameters: [ + { key: 'created', operator: QueryOperators['>'], value: 1000 }, + { key: 'created', operator: QueryOperators['<'], value: 1030 }, + ], + orderBy: [{ key: 'created', direction: 'desc' }], + startAfter: 1025, + }); + + expect(find).toHaveBeenLastCalledWith({ + created: { $gt: 1000, $lt: 1025 }, + }); + }); + + it('should replace an equality condition on the same field', async () => { + await db.Query({ + collection: 'Charges', + method: 'READ', + parameters: [ + { key: 'status', operator: QueryOperators['=='], value: 'pending' }, + { key: 'status', operator: QueryOperators['=='], value: 'succeeded' }, + ], + }); + + expect(find).toHaveBeenCalledWith({ status: 'succeeded' }); + }); + }); +}); diff --git a/apps/api/src/modules/Database.ts b/apps/api/src/modules/Database.ts index 7880426..ad594a4 100644 --- a/apps/api/src/modules/Database.ts +++ b/apps/api/src/modules/Database.ts @@ -257,15 +257,19 @@ export class Database { const model = this.GetModel(options.collection); // Build MongoDB filter from parameters - let filter: Record = {}; + const filter: Record = {}; if (options.parameters) { for (const param of options.parameters) { - const paramFilter = this.BuildFilter( + const condition = this.BuildFilter( param.key, param.operator, param.value - ); - filter = { ...filter, ...paramFilter }; + )[param.key]; + const existing = filter[param.key]; + filter[param.key] = + this.IsOperatorObject(existing) && this.IsOperatorObject(condition) + ? { ...existing, ...condition } + : condition; } } @@ -435,6 +439,15 @@ export class Database { } } + private IsOperatorObject(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.keys(value).every((key) => key.startsWith('$')) + ); + } + /** * Pre-create all known collections and their indexes so that concurrent * requests on a fresh database don't race to implicitly create them.