diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index a0ee9e1e..fca72264 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -122,6 +122,16 @@ module.exports.getAllData = async (req, res) => { ); const baseFilter = req.rlsFilter && typeof req.rlsFilter === 'object' ? req.rlsFilter : {}; + // Handle count=true query parameter +if (req.query.count === 'true') { + const countEngine = new QueryEngine(Model.find(), req.query); +const mongoFilter = countEngine._buildMongoQuery(true); +const mergedFilter = Object.keys(baseFilter).length > 0 + ? { $and: [mongoFilter, baseFilter] } + : mongoFilter; + const count = await Model.countDocuments(mergedFilter); + return res.status(200).json({ success: true, data: { count }, message: "Count fetched successfully." }); +} const features = new QueryEngine(Model.find(), req.query) .filter(); diff --git a/packages/common/src/utils/queryEngine.js b/packages/common/src/utils/queryEngine.js index a5234578..ade5d025 100644 --- a/packages/common/src/utils/queryEngine.js +++ b/packages/common/src/utils/queryEngine.js @@ -72,10 +72,43 @@ class QueryEngine { return this; } +<<<<<<< HEAD async count() { // Clone the query to avoid affecting the original query's skip/limit return await this.query.model.countDocuments(this.query.getQuery()); } +======= + _buildMongoQuery(excludeCount = false) { + const queryObj = { ...this.queryString }; + const excludedFields = ['page', 'sort', 'limit', 'fields', 'populate', 'expand']; + if (excludeCount) excludedFields.push('count'); + excludedFields.forEach(el => delete queryObj[el]); + const mongoQuery = {}; + for (const key in queryObj) { + if (key.endsWith('_gt')) { + const field = key.replace(/_gt$/, ''); + mongoQuery[field] = { ...mongoQuery[field], $gt: queryObj[key] }; + } else if (key.endsWith('_gte')) { + const field = key.replace(/_gte$/, ''); + mongoQuery[field] = { ...mongoQuery[field], $gte: queryObj[key] }; + } else if (key.endsWith('_lt')) { + const field = key.replace(/_lt$/, ''); + mongoQuery[field] = { ...mongoQuery[field], $lt: queryObj[key] }; + } else if (key.endsWith('_lte')) { + const field = key.replace(/_lte$/, ''); + mongoQuery[field] = { ...mongoQuery[field], $lte: queryObj[key] }; + } else { + mongoQuery[key] = queryObj[key]; + } + } + return mongoQuery; +} + +count() { + this.query = this.query.model.countDocuments(this._buildMongoQuery(true)); + return this; +} +>>>>>>> 3fb84f3e (feat: implement native client.db.count() support) } module.exports = QueryEngine; diff --git a/sdks/urbackend-sdk/package.json b/sdks/urbackend-sdk/package.json index 4827573c..00f6619e 100644 --- a/sdks/urbackend-sdk/package.json +++ b/sdks/urbackend-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@urbackend/sdk", - "version": "0.2.6", + "version": "0.2.7", "description": "Official TypeScript SDK for urBackend BaaS", "type": "module", "files": [ diff --git a/sdks/urbackend-sdk/src/modules/database.ts b/sdks/urbackend-sdk/src/modules/database.ts index b3720810..8ecf72d6 100644 --- a/sdks/urbackend-sdk/src/modules/database.ts +++ b/sdks/urbackend-sdk/src/modules/database.ts @@ -22,6 +22,16 @@ export class DatabaseModule { } } + /** + * Count documents in a collection with optional filters + */ +public async count(collection: string, params: Omit = {}): Promise { + const queryString = this.buildQueryString({ ...params, count: 'true' }); + const path = `/api/data/${collection}${queryString}`; + const result = await this.client.request<{ count: number }>('GET', path); + return result.count; +} + /** * Fetch a single document by its ID */ diff --git a/sdks/urbackend-sdk/tests/database.test.ts b/sdks/urbackend-sdk/tests/database.test.ts index ddc749d5..8372dfae 100644 --- a/sdks/urbackend-sdk/tests/database.test.ts +++ b/sdks/urbackend-sdk/tests/database.test.ts @@ -132,3 +132,49 @@ test('delete returns { deleted: true } and handles token', async () => { }), ); }); + +test('count returns total number of documents in a collection', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ success: true, data: { count: 42 } }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await client.db.count('products'); + + expect(result).toBe(42); + const url = fetchMock.mock.calls[0][0] as string; + const searchParams = new URL(url).searchParams; + expect(searchParams.get('count')).toBe('true'); +}); + +test('count with filters builds correct query string', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ success: true, data: { count: 7 } }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await client.db.count('products', { filter: { status: 'active' } }); + + expect(result).toBe(7); + const url = fetchMock.mock.calls[0][0] as string; + const searchParams = new URL(url).searchParams; + expect(searchParams.get('count')).toBe('true'); + expect(searchParams.get('status')).toBe('active'); +}); + +test('count returns 0 when no documents match', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ success: true, data: { count: 0 } }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await client.db.count('products', { filter: { status: 'deleted' } }); + + expect(result).toBe(0); +});