Skip to content

Commit 08f90b6

Browse files
Merge pull request #88 from geturbackend/feat/populate-option
fix: harden populate robustness, clamp paginate limit, fix docs and test coverage
2 parents f19ad57 + c2eb981 commit 08f90b6

7 files changed

Lines changed: 151 additions & 20 deletions

File tree

‎apps/public-api/src/__tests__/data.controller.read.test.js‎

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const mockFind = jest.fn();
44
const mockAnd = jest.fn();
55
const mockFindOne = jest.fn();
66
const mockQueryLean = jest.fn().mockResolvedValue([]);
7+
const mockPopulate = jest.fn();
8+
const mockEnginePopulate = jest.fn();
79

810
// mockAnd returns an object with .lean() so features.query.lean() works after .and()
911
mockAnd.mockReturnValue({ lean: mockQueryLean });
@@ -13,6 +15,7 @@ const mockQueryEngine = jest.fn((query) => {
1315
query,
1416
filter() { return engine; },
1517
sort() { return engine; },
18+
populate() { mockEnginePopulate(...arguments); return engine; },
1619
paginate() { return engine; },
1720
};
1821
return engine;
@@ -25,11 +28,25 @@ jest.mock('@urbackend/common', () => ({
2528
getCompiledModel: jest.fn(() => ({
2629
find: (...args) => {
2730
mockFind(...args);
28-
return { and: mockAnd, lean: mockQueryLean };
31+
return {
32+
and: mockAnd,
33+
populate: (...pArgs) => {
34+
mockPopulate(...pArgs);
35+
return { lean: mockQueryLean };
36+
},
37+
lean: mockQueryLean
38+
};
2939
},
3040
findOne: (...args) => {
3141
mockFindOne(...args);
32-
return { lean: jest.fn().mockResolvedValue({ _id: 'doc_1' }) };
42+
const chainable = {
43+
populate: (...pArgs) => {
44+
mockPopulate(...pArgs);
45+
return chainable;
46+
},
47+
lean: jest.fn().mockResolvedValue({ _id: 'doc_1' }),
48+
};
49+
return chainable;
3350
},
3451
})),
3552
QueryEngine: mockQueryEngine,
@@ -74,6 +91,7 @@ function makeRes() {
7491
describe('data.controller read RLS filters', () => {
7592
beforeEach(() => {
7693
jest.clearAllMocks();
94+
mockEnginePopulate.mockClear();
7795
});
7896

7997
test('getAllData applies rlsFilter to find()', async () => {
@@ -101,4 +119,50 @@ describe('data.controller read RLS filters', () => {
101119
});
102120
expect(res.json).toHaveBeenCalled();
103121
});
122+
123+
test('getAllData calls engine.populate() when populate param is provided', async () => {
124+
const req = makeReq({ query: { populate: 'author,comments' } });
125+
const res = makeRes();
126+
127+
await getAllData(req, res);
128+
129+
expect(mockQueryEngine).toHaveBeenCalled();
130+
expect(mockEnginePopulate).toHaveBeenCalled();
131+
});
132+
133+
test('getAllData does not forward populate/expand to Mongo filter', async () => {
134+
const req = makeReq({ query: { populate: 'author', expand: 'category', title: 'hello' } });
135+
const res = makeRes();
136+
137+
await getAllData(req, res);
138+
139+
// mockFind is called with the raw Mongoose query (no args for Model.find())
140+
// The real filter exclusion is tested via the QueryEngine directly,
141+
// but we confirm find() was invoked and the request did not error out.
142+
expect(mockFind).toHaveBeenCalledWith();
143+
expect(res.json).toHaveBeenCalled();
144+
expect(res.status).not.toHaveBeenCalledWith(500);
145+
});
146+
147+
test('getSingleDoc calls populate on the query', async () => {
148+
const req = makeReq({ query: { populate: 'author' } });
149+
const res = makeRes();
150+
151+
await getSingleDoc(req, res);
152+
153+
expect(mockPopulate).toHaveBeenCalledWith('author');
154+
expect(res.json).toHaveBeenCalled();
155+
});
156+
157+
test('getSingleDoc handles array-format populate param without crashing', async () => {
158+
const req = makeReq({ query: { populate: ['author', 'category'] } });
159+
const res = makeRes();
160+
161+
await getSingleDoc(req, res);
162+
163+
expect(mockPopulate).toHaveBeenCalledWith('author');
164+
expect(mockPopulate).toHaveBeenCalledWith('category');
165+
expect(res.json).toHaveBeenCalled();
166+
expect(res.status).not.toHaveBeenCalledWith(500);
167+
});
104168
});

‎apps/public-api/src/__tests__/mail.controller.test.js‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ jest.mock('@urbackend/common', () => {
2020
incr: jest.fn(),
2121
expire: jest.fn(),
2222
decr: jest.fn(),
23+
eval: jest.fn(),
2324
};
2425

2526
return {
@@ -66,14 +67,13 @@ describe('mail.controller', () => {
6667
const req = makeReq();
6768
const res = makeRes();
6869

69-
mockProjectConfig({ _id: 'proj_1', resendApiKey: {} });
70+
mockProjectConfig({ _id: 'proj_1', resendApiKey: { encrypted: '...' } });
7071
decrypt.mockReturnValue('byok-key');
71-
redis.incr.mockResolvedValue(1);
72+
redis.eval.mockResolvedValue(1);
7273

7374
await mailController.sendMail(req, res);
7475

75-
expect(redis.incr).toHaveBeenCalledTimes(1);
76-
expect(redis.expire).toHaveBeenCalledTimes(1);
76+
expect(redis.eval).toHaveBeenCalledTimes(1);
7777
expect(res.status).toHaveBeenCalledWith(200);
7878
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
7979
success: true,
@@ -87,7 +87,7 @@ describe('mail.controller', () => {
8787

8888
mockProjectConfig({ _id: 'proj_1', resendApiKey: null });
8989
decrypt.mockReturnValue(null);
90-
redis.incr.mockResolvedValue(2);
90+
redis.eval.mockResolvedValue(2);
9191

9292
await mailController.sendMail(req, res);
9393

@@ -103,14 +103,15 @@ describe('mail.controller', () => {
103103

104104
mockProjectConfig({ _id: 'proj_1', resendApiKey: null });
105105
decrypt.mockReturnValue(null);
106-
redis.incr.mockResolvedValue(101);
106+
redis.eval.mockResolvedValue(101);
107107

108108
await mailController.sendMail(req, res);
109109

110110
expect(redis.decr).toHaveBeenCalledTimes(1);
111111
expect(res.status).toHaveBeenCalledWith(429);
112112
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
113-
error: 'Monthly mail limit exceeded.',
113+
success: false,
114+
message: 'Monthly mail limit exceeded.',
114115
}));
115116
});
116117
});

‎apps/public-api/src/controllers/data.controller.js‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ module.exports.getAllData = async (req, res) => {
122122

123123
features
124124
.sort()
125+
.populate()
125126
.paginate();
126127

127128
const data = await features.query.lean();
@@ -158,7 +159,19 @@ module.exports.getSingleDoc = async (req, res) => {
158159
);
159160

160161
const baseFilter = req.rlsFilter && typeof req.rlsFilter === 'object' ? req.rlsFilter : {};
161-
const doc = await Model.findOne({ $and: [{ _id: id }, baseFilter] }).lean();
162+
let query = Model.findOne({ $and: [{ _id: id }, baseFilter] });
163+
164+
// Handle population for single doc
165+
const rawPopulateParam = req.query.populate || req.query.expand;
166+
if (rawPopulateParam) {
167+
const populateParam = Array.isArray(rawPopulateParam) ? rawPopulateParam.join(',') : String(rawPopulateParam);
168+
const fields = populateParam.split(',').map(f => f.trim()).filter(Boolean);
169+
fields.forEach(f => {
170+
query = query.populate(f);
171+
});
172+
}
173+
174+
const doc = await query.lean();
162175
if (!doc) return res.status(404).json({ error: "Document not found." });
163176

164177
res.json(doc);

‎docs/api-reference.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@
2424
| **Storage** | `POST` | `/api/storage/upload` | Upload a file |
2525
| **Storage** | `DELETE` | `/api/storage/file` | Delete a file by path |
2626

27+
## Data Query Parameters
28+
29+
| Parameter | Example | Description |
30+
| :--- | :--- | :--- |
31+
| `populate` | `?populate=author` | Expand Reference fields into objects |
32+
| `expand` | `?expand=author` | Alias for `populate` |
33+
| `sort` | `?sort=field:asc` | Sort order (`asc`, `desc`, `1`, `-1`) |
34+
| `limit` | `?limit=50` | Limit results (default 100) |
35+
| `page` | `?page=1` | Pagination page number |
36+
| `field_gt` | `?price_gt=100` | Greater than filter |
37+
| `field_lt` | `?price_lt=50` | Less than filter |
38+
2739
## Common Headers
2840

2941
| Header | Required | Purpose |

‎docs/database.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,33 @@ curl "https://api.ub.bitbros.in/api/data/posts/64fd1234abcd5678ef901234" ^
6161
-H "x-api-key: pk_live_xxx"
6262
```
6363

64+
## 2.1 Query Parameters
65+
66+
You can refine your `GET` requests using the following query parameters:
67+
68+
| Parameter | Type | Example | Description |
69+
| :--- | :--- | :--- | :--- |
70+
| `populate` | `String` | `?populate=author,category` | Expand `Ref` fields into full objects. |
71+
| `expand` | `String` | `?expand=author` | Alias for `populate`. |
72+
| `sort` | `String` | `?sort=createdAt:desc` | Sort results by field and order. |
73+
| `limit` | `Number` | `?limit=25` | Number of documents to return (default: 100, max: 100). |
74+
| `page` | `Number` | `?page=2` | 1-indexed page number for pagination. |
75+
76+
### Relational Data (Population) 🔗
77+
78+
If you have fields defined as `Ref` (singular) or `Array of Ref` in your schema, urBackend can automatically join those documents for you.
79+
80+
By default, urBackend returns raw IDs for references. Use `?populate=` to get the full nested object.
81+
82+
```bash
83+
# Get posts with full author and category objects
84+
curl "https://api.ub.bitbros.in/api/data/posts?populate=author,category" ^
85+
-H "x-api-key: pk_live_xxx"
86+
```
87+
88+
> [!TIP]
89+
> This solves the N+1 query problem, allowing you to fetch complex relational data in a single request.
90+
6491
## 3. Update a Document
6592

6693
**Endpoint**: `PUT /api/data/:collectionName/:id`

‎packages/common/src/utils/injectModel.js‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ const typeMapping = {
1010
};
1111

1212
// Recursive field definition builder
13-
function buildFieldDef(field) {
13+
function buildFieldDef(field, projectId, isExternal) {
1414
// Object type — nested sub-schema
1515
if (field.type === "Object" && field.fields && field.fields.length > 0) {
1616
const subSchema = {};
1717
field.fields.forEach((f) => {
1818
const normalizedKey = normalizeKey(f.key);
1919
if (!normalizedKey) return;
2020

21-
subSchema[normalizedKey] = buildFieldDef(f);
21+
subSchema[normalizedKey] = buildFieldDef(f, projectId, isExternal);
2222
});
2323
return { type: subSchema, required: !!field.required };
2424
}
@@ -42,14 +42,15 @@ function buildFieldDef(field) {
4242
const normalizedKey = normalizeKey(f.key);
4343
if (!normalizedKey) return;
4444

45-
subSchema[normalizedKey] = buildFieldDef(f);
45+
subSchema[normalizedKey] = buildFieldDef(f, projectId, isExternal);
4646
});
4747
return { type: [subSchema], required: !!field.required };
4848
}
4949
// Array of Ref
50-
if (field.items.type === "Ref") {
50+
if (field.items && field.items.type === "Ref") {
51+
const targetRef = isExternal ? field.items.ref : `${projectId}_${field.items.ref}`;
5152
return {
52-
type: [{ type: mongoose.Schema.Types.ObjectId }],
53+
type: [{ type: mongoose.Schema.Types.ObjectId, ref: targetRef }],
5354
required: !!field.required,
5455
};
5556
}
@@ -61,8 +62,10 @@ function buildFieldDef(field) {
6162

6263
// Ref type — stores ObjectId
6364
if (field.type === "Ref") {
65+
const targetRef = isExternal ? field.ref : `${projectId}_${field.ref}`;
6466
return {
6567
type: mongoose.Schema.Types.ObjectId,
68+
ref: targetRef,
6669
required: !!field.required,
6770
};
6871
}
@@ -79,14 +82,14 @@ function normalizeKey(key) {
7982
.trim();
8083
}
8184

82-
function buildMongooseSchema(fieldsArray = []) {
85+
function buildMongooseSchema(fieldsArray = [], projectId, isExternal) {
8386
const schemaDef = {};
8487

8588
fieldsArray.forEach((field) => {
8689
const normalizedKey = normalizeKey(field.key);
8790
if (!normalizedKey) return;
8891

89-
schemaDef[normalizedKey] = buildFieldDef(field);
92+
schemaDef[normalizedKey] = buildFieldDef(field, projectId, isExternal);
9093
});
9194

9295
return new mongoose.Schema(schemaDef, {
@@ -123,7 +126,7 @@ function getCompiledModel(connection, collectionData, projectId, isExternal) {
123126
}
124127

125128
// Build schema + compile
126-
const schema = buildMongooseSchema(collectionData.model);
129+
const schema = buildMongooseSchema(collectionData.model, projectId, isExternal);
127130
const model = connection.model(collectionName, schema);
128131

129132
// Cache it

‎packages/common/src/utils/queryEngine.js‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class QueryEngine {
66

77
filter() {
88
const queryObj = { ...this.queryString };
9-
const excludedFields = ['page', 'sort', 'limit', 'fields'];
9+
const excludedFields = ['page', 'sort', 'limit', 'fields', 'populate', 'expand'];
1010
excludedFields.forEach(el => delete queryObj[el]);
1111

1212
const mongoQuery = {};
@@ -53,13 +53,24 @@ class QueryEngine {
5353

5454
paginate() {
5555
const page = parseInt(this.queryString.page, 10) || 1;
56-
const limit = parseInt(this.queryString.limit, 10) || 100;
56+
const limit = Math.min(parseInt(this.queryString.limit, 10) || 100, 100);
5757
const skip = (page - 1) * limit;
5858

5959
this.query = this.query.skip(skip).limit(limit);
6060

6161
return this;
6262
}
63+
64+
populate() {
65+
const rawParam = this.queryString.populate || this.queryString.expand;
66+
if (!rawParam) return this;
67+
const populateParam = Array.isArray(rawParam) ? rawParam.join(',') : String(rawParam);
68+
const fields = populateParam.split(',').map(f => f.trim()).filter(Boolean);
69+
fields.forEach(f => {
70+
this.query = this.query.populate(f);
71+
});
72+
return this;
73+
}
6374
}
6475

6576
module.exports = QueryEngine;

0 commit comments

Comments
 (0)