@objectql/server 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -58,17 +58,27 @@ export class ObjectQLServer {
58
58
  switch (req.op) {
59
59
  case 'find':
60
60
  result = await repo.find(req.args);
61
- break;
61
+ // For find operations, return items array with pagination metadata
62
+ return this.buildListResponse(result, req.args, repo);
62
63
  case 'findOne':
63
64
  // Support both string ID and query object
64
65
  result = await repo.findOne(req.args);
65
- break;
66
+ if (result) {
67
+ return { ...result, '@type': req.object };
68
+ }
69
+ return result;
66
70
  case 'create':
67
71
  result = await repo.create(req.args);
68
- break;
72
+ if (result) {
73
+ return { ...result, '@type': req.object };
74
+ }
75
+ return result;
69
76
  case 'update':
70
77
  result = await repo.update(req.args.id, req.args.data);
71
- break;
78
+ if (result) {
79
+ return { ...result, '@type': req.object };
80
+ }
81
+ return result;
72
82
  case 'delete':
73
83
  result = await repo.delete(req.args.id);
74
84
  if (!result) {
@@ -77,12 +87,15 @@ export class ObjectQLServer {
77
87
  `Record with id '${req.args.id}' not found for delete`
78
88
  );
79
89
  }
80
- // Return standardized delete response on success
81
- result = { id: req.args.id, deleted: true };
82
- break;
90
+ // Return standardized delete response with object type
91
+ return {
92
+ id: req.args.id,
93
+ deleted: true,
94
+ '@type': req.object
95
+ };
83
96
  case 'count':
84
97
  result = await repo.count(req.args);
85
- break;
98
+ return { count: result, '@type': req.object };
86
99
  case 'action':
87
100
  // Map generic args to ActionContext
88
101
  result = await app.executeAction(req.object, req.args.action, {
@@ -90,7 +103,52 @@ export class ObjectQLServer {
90
103
  id: req.args.id,
91
104
  input: req.args.input || req.args.params // Support both for convenience
92
105
  });
93
- break;
106
+ if (result && typeof result === 'object') {
107
+ return { ...result, '@type': req.object };
108
+ }
109
+ return result;
110
+ case 'createMany':
111
+ // Bulk create operation
112
+ if (!Array.isArray(req.args)) {
113
+ return this.errorResponse(
114
+ ErrorCode.INVALID_REQUEST,
115
+ 'createMany expects args to be an array of records'
116
+ );
117
+ }
118
+ result = await repo.createMany(req.args);
119
+ return {
120
+ items: result,
121
+ count: Array.isArray(result) ? result.length : 0,
122
+ '@type': req.object
123
+ };
124
+ case 'updateMany':
125
+ // Bulk update operation
126
+ // args should be { filters, data }
127
+ if (!req.args || typeof req.args !== 'object' || !req.args.data) {
128
+ return this.errorResponse(
129
+ ErrorCode.INVALID_REQUEST,
130
+ 'updateMany expects args to be an object with { filters, data }'
131
+ );
132
+ }
133
+ result = await repo.updateMany(req.args.filters || {}, req.args.data);
134
+ return {
135
+ count: result,
136
+ '@type': req.object
137
+ };
138
+ case 'deleteMany':
139
+ // Bulk delete operation
140
+ // args should be { filters }
141
+ if (!req.args || typeof req.args !== 'object') {
142
+ return this.errorResponse(
143
+ ErrorCode.INVALID_REQUEST,
144
+ 'deleteMany expects args to be an object with { filters }'
145
+ );
146
+ }
147
+ result = await repo.deleteMany(req.args.filters || {});
148
+ return {
149
+ count: result,
150
+ '@type': req.object
151
+ };
94
152
  default:
95
153
  return this.errorResponse(
96
154
  ErrorCode.INVALID_REQUEST,
@@ -98,13 +156,44 @@ export class ObjectQLServer {
98
156
  );
99
157
  }
100
158
 
101
- return { data: result };
102
-
103
159
  } catch (e: any) {
104
160
  return this.handleError(e);
105
161
  }
106
162
  }
107
163
 
164
+ /**
165
+ * Build a standardized list response with pagination metadata
166
+ */
167
+ private async buildListResponse(items: any[], args: any, repo: any): Promise<ObjectQLResponse> {
168
+ const response: ObjectQLResponse = {
169
+ items
170
+ };
171
+
172
+ // Calculate pagination metadata if limit/skip are present
173
+ if (args && (args.limit || args.skip)) {
174
+ const skip = args.skip || 0;
175
+ const limit = args.limit || items.length;
176
+
177
+ // Get total count - use the same arguments as the query to ensure consistency
178
+ const total = await repo.count(args || {});
179
+
180
+ const size = limit;
181
+ const page = limit > 0 ? Math.floor(skip / limit) + 1 : 1;
182
+ const pages = limit > 0 ? Math.ceil(total / limit) : 1;
183
+ const has_next = skip + items.length < total;
184
+
185
+ response.meta = {
186
+ total,
187
+ page,
188
+ size,
189
+ pages,
190
+ has_next
191
+ };
192
+ }
193
+
194
+ return response;
195
+ }
196
+
108
197
  /**
109
198
  * Handle errors and convert them to appropriate error responses
110
199
  */
package/src/types.ts CHANGED
@@ -37,7 +37,7 @@ export interface ObjectQLRequest {
37
37
  };
38
38
 
39
39
  // The actual operation
40
- op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action';
40
+ op: 'find' | 'findOne' | 'create' | 'update' | 'delete' | 'count' | 'action' | 'createMany' | 'updateMany' | 'deleteMany';
41
41
  object: string;
42
42
 
43
43
  // Arguments
@@ -60,19 +60,35 @@ export interface ErrorDetails {
60
60
  [key: string]: unknown;
61
61
  }
62
62
 
63
+ /**
64
+ * Pagination metadata
65
+ */
66
+ export interface PaginationMeta {
67
+ total: number; // Total number of records
68
+ page?: number; // Current page number (1-indexed, e.g. page 1 corresponds to skip=0)
69
+ size?: number; // Number of items per page
70
+ pages?: number; // Total number of pages
71
+ has_next?: boolean; // Whether there is a next page
72
+ }
73
+
63
74
  /**
64
75
  * ObjectQL API response
65
76
  */
66
77
  export interface ObjectQLResponse {
67
- data?: any;
78
+ // For list operations (find)
79
+ items?: any[];
80
+
81
+ // Pagination metadata (for list operations)
82
+ meta?: PaginationMeta;
83
+
84
+ // Error information
68
85
  error?: {
69
86
  code: ErrorCode | string;
70
87
  message: string;
71
- details?: ErrorDetails;
72
- };
73
- meta?: {
74
- total?: number;
75
- page?: number;
76
- per_page?: number;
88
+ details?: ErrorDetails | any; // Allow flexible details structure
77
89
  };
90
+
91
+ // For single item operations, the response is the object itself with '@type' field
92
+ // This allows any additional fields from the actual data object
93
+ [key: string]: any;
78
94
  }
@@ -0,0 +1,439 @@
1
+ import request from 'supertest';
2
+ import { createServer } from 'http';
3
+ import { ObjectQL } from '@objectql/core';
4
+ import { createGraphQLHandler } from '../src/adapters/graphql';
5
+ import { Driver } from '@objectql/types';
6
+
7
+ // Simple Mock Driver
8
+ class MockDriver implements Driver {
9
+ private initialData: Record<string, any[]> = {
10
+ user: [
11
+ { _id: '1', name: 'Alice', email: 'alice@example.com', age: 30 },
12
+ { _id: '2', name: 'Bob', email: 'bob@example.com', age: 25 }
13
+ ],
14
+ task: [
15
+ { _id: 'task1', title: 'Task 1', status: 'open', priority: 'high' },
16
+ { _id: 'task2', title: 'Task 2', status: 'closed', priority: 'low' }
17
+ ]
18
+ };
19
+ private data: Record<string, any[]>;
20
+ private nextId = 3;
21
+
22
+ constructor() {
23
+ // Create a deep copy of initialData
24
+ this.data = JSON.parse(JSON.stringify(this.initialData));
25
+ }
26
+
27
+ reset() {
28
+ // Reset data to initial state
29
+ this.data = JSON.parse(JSON.stringify(this.initialData));
30
+ this.nextId = 3;
31
+ }
32
+
33
+ async init() {}
34
+
35
+ async find(objectName: string, query: any) {
36
+ let items = this.data[objectName] || [];
37
+
38
+ // Apply skip and limit if provided
39
+ if (query) {
40
+ if (query.skip) {
41
+ items = items.slice(query.skip);
42
+ }
43
+ if (query.limit) {
44
+ items = items.slice(0, query.limit);
45
+ }
46
+ }
47
+
48
+ return items;
49
+ }
50
+
51
+ async findOne(objectName: string, id: string | number, query?: any, options?: any) {
52
+ const items = this.data[objectName] || [];
53
+ if (id !== undefined && id !== null) {
54
+ const found = items.find(item => item._id === String(id));
55
+ return found || null;
56
+ }
57
+ return items[0] || null;
58
+ }
59
+
60
+ async create(objectName: string, data: any) {
61
+ const newItem = { _id: String(this.nextId++), ...data };
62
+ if (!this.data[objectName]) {
63
+ this.data[objectName] = [];
64
+ }
65
+ this.data[objectName].push(newItem);
66
+ return newItem;
67
+ }
68
+
69
+ // Note: update() returns the updated object to match the behavior of the SQL driver,
70
+ // which returns { id, ...data }. This is different from the affected row count pattern.
71
+ async update(objectName: string, id: string, data: any) {
72
+ const items = this.data[objectName] || [];
73
+ const index = items.findIndex(item => item._id === id);
74
+ if (index >= 0) {
75
+ this.data[objectName][index] = { ...items[index], ...data };
76
+ return this.data[objectName][index];
77
+ }
78
+ return null;
79
+ }
80
+
81
+ async delete(objectName: string, id: string) {
82
+ const items = this.data[objectName] || [];
83
+ const index = items.findIndex(item => item._id === id);
84
+ if (index >= 0) {
85
+ this.data[objectName].splice(index, 1);
86
+ return 1;
87
+ }
88
+ return 0;
89
+ }
90
+
91
+ async count(objectName: string, query: any) {
92
+ return (this.data[objectName] || []).length;
93
+ }
94
+
95
+ async execute(sql: string) {}
96
+ }
97
+
98
+ describe('GraphQL API Adapter', () => {
99
+ let app: ObjectQL;
100
+ let server: any;
101
+ let handler: any;
102
+ let mockDriver: MockDriver;
103
+
104
+ beforeAll(async () => {
105
+ mockDriver = new MockDriver();
106
+ app = new ObjectQL({
107
+ datasources: {
108
+ default: mockDriver
109
+ }
110
+ });
111
+
112
+ // Register user object schema
113
+ app.metadata.register('object', {
114
+ type: 'object',
115
+ id: 'user',
116
+ content: {
117
+ name: 'user',
118
+ label: 'User',
119
+ fields: {
120
+ name: { type: 'text', label: 'Name' },
121
+ email: { type: 'email', label: 'Email' },
122
+ age: { type: 'number', label: 'Age' }
123
+ }
124
+ }
125
+ });
126
+
127
+ // Register task object schema
128
+ app.metadata.register('object', {
129
+ type: 'object',
130
+ id: 'task',
131
+ content: {
132
+ name: 'task',
133
+ label: 'Task',
134
+ fields: {
135
+ title: { type: 'text', label: 'Title', required: true },
136
+ status: { type: 'select', label: 'Status', options: ['open', 'closed'] },
137
+ priority: { type: 'select', label: 'Priority', options: ['low', 'medium', 'high'] }
138
+ }
139
+ }
140
+ });
141
+
142
+ // Create handler and server once for all tests
143
+ handler = createGraphQLHandler(app);
144
+ server = createServer(handler);
145
+ });
146
+
147
+ beforeEach(() => {
148
+ // Reset mock data before each test
149
+ mockDriver.reset();
150
+ });
151
+
152
+ describe('Queries', () => {
153
+ it('should query a single user by ID', async () => {
154
+ const query = `
155
+ query {
156
+ user(id: "1") {
157
+ id
158
+ name
159
+ email
160
+ }
161
+ }
162
+ `;
163
+
164
+ const response = await request(server)
165
+ .post('/api/graphql')
166
+ .send({ query })
167
+ .set('Accept', 'application/json');
168
+
169
+ expect(response.status).toBe(200);
170
+ expect(response.body.data).toBeDefined();
171
+ expect(response.body.data.user).toEqual({
172
+ id: '1',
173
+ name: 'Alice',
174
+ email: 'alice@example.com'
175
+ });
176
+ });
177
+
178
+ it('should query list of users', async () => {
179
+ const query = `
180
+ query {
181
+ userList {
182
+ id
183
+ name
184
+ email
185
+ }
186
+ }
187
+ `;
188
+
189
+ const response = await request(server)
190
+ .post('/api/graphql')
191
+ .send({ query })
192
+ .set('Accept', 'application/json');
193
+
194
+ expect(response.status).toBe(200);
195
+ expect(response.body.data).toBeDefined();
196
+ expect(response.body.data.userList).toHaveLength(2);
197
+ expect(response.body.data.userList[0].name).toBe('Alice');
198
+ });
199
+
200
+ it('should support pagination with limit and skip', async () => {
201
+ const query = `
202
+ query {
203
+ userList(limit: 1, skip: 1) {
204
+ id
205
+ name
206
+ }
207
+ }
208
+ `;
209
+
210
+ const response = await request(server)
211
+ .post('/api/graphql')
212
+ .send({ query })
213
+ .set('Accept', 'application/json');
214
+
215
+ expect(response.status).toBe(200);
216
+ expect(response.body.data.userList).toHaveLength(1);
217
+ expect(response.body.data.userList[0].name).toBe('Bob');
218
+ });
219
+
220
+ it('should support field selection', async () => {
221
+ const query = `
222
+ query {
223
+ userList(fields: ["name"]) {
224
+ id
225
+ name
226
+ }
227
+ }
228
+ `;
229
+
230
+ const response = await request(server)
231
+ .post('/api/graphql')
232
+ .send({ query })
233
+ .set('Accept', 'application/json');
234
+
235
+ expect(response.status).toBe(200);
236
+ expect(response.body.data.userList).toBeDefined();
237
+ });
238
+
239
+ it('should query tasks', async () => {
240
+ const query = `
241
+ query {
242
+ taskList {
243
+ id
244
+ title
245
+ status
246
+ priority
247
+ }
248
+ }
249
+ `;
250
+
251
+ const response = await request(server)
252
+ .post('/api/graphql')
253
+ .send({ query })
254
+ .set('Accept', 'application/json');
255
+
256
+ expect(response.status).toBe(200);
257
+ expect(response.body.data.taskList).toHaveLength(2);
258
+ expect(response.body.data.taskList[0].title).toBe('Task 1');
259
+ });
260
+
261
+ it('should support GET requests with query parameter', async () => {
262
+ const query = encodeURIComponent('{ user(id: "1") { id name } }');
263
+
264
+ const response = await request(server)
265
+ .get(`/api/graphql?query=${query}`)
266
+ .set('Accept', 'application/json');
267
+
268
+ expect(response.status).toBe(200);
269
+ expect(response.body.data.user.name).toBe('Alice');
270
+ });
271
+ });
272
+
273
+ describe('Mutations', () => {
274
+ it('should create a new user', async () => {
275
+ const mutation = `
276
+ mutation {
277
+ createUser(input: { name: "Charlie", email: "charlie@example.com", age: 35 }) {
278
+ id
279
+ name
280
+ email
281
+ age
282
+ }
283
+ }
284
+ `;
285
+
286
+ const response = await request(server)
287
+ .post('/api/graphql')
288
+ .send({ query: mutation })
289
+ .set('Accept', 'application/json');
290
+
291
+ expect(response.status).toBe(200);
292
+ expect(response.body.data).toBeDefined();
293
+ expect(response.body.data.createUser.name).toBe('Charlie');
294
+ expect(response.body.data.createUser.email).toBe('charlie@example.com');
295
+ expect(response.body.data.createUser.id).toBeDefined();
296
+ });
297
+
298
+ it('should update an existing user', async () => {
299
+ const mutation = `
300
+ mutation {
301
+ updateUser(id: "1", input: { name: "Alice Updated" }) {
302
+ id
303
+ name
304
+ }
305
+ }
306
+ `;
307
+
308
+ const response = await request(server)
309
+ .post('/api/graphql')
310
+ .send({ query: mutation })
311
+ .set('Accept', 'application/json');
312
+
313
+ expect(response.status).toBe(200);
314
+ expect(response.body.data).toBeDefined();
315
+ expect(response.body.data.updateUser.name).toBe('Alice Updated');
316
+ });
317
+
318
+ it('should delete a user', async () => {
319
+ const mutation = `
320
+ mutation {
321
+ deleteUser(id: "2") {
322
+ id
323
+ deleted
324
+ }
325
+ }
326
+ `;
327
+
328
+ const response = await request(server)
329
+ .post('/api/graphql')
330
+ .send({ query: mutation })
331
+ .set('Accept', 'application/json');
332
+
333
+ expect(response.status).toBe(200);
334
+ expect(response.body.data).toBeDefined();
335
+ expect(response.body.data.deleteUser.id).toBe('2');
336
+ expect(response.body.data.deleteUser.deleted).toBe(true);
337
+ });
338
+
339
+ it('should create a task', async () => {
340
+ const mutation = `
341
+ mutation {
342
+ createTask(input: { title: "New Task", status: "open", priority: "medium" }) {
343
+ id
344
+ title
345
+ status
346
+ }
347
+ }
348
+ `;
349
+
350
+ const response = await request(server)
351
+ .post('/api/graphql')
352
+ .send({ query: mutation })
353
+ .set('Accept', 'application/json');
354
+
355
+ expect(response.status).toBe(200);
356
+ expect(response.body.data.createTask.title).toBe('New Task');
357
+ });
358
+ });
359
+
360
+ describe('Error Handling', () => {
361
+ it('should return error for invalid query', async () => {
362
+ const query = `query { invalid }`;
363
+
364
+ const response = await request(server)
365
+ .post('/api/graphql')
366
+ .send({ query })
367
+ .set('Accept', 'application/json');
368
+
369
+ expect(response.status).toBe(200);
370
+ expect(response.body.errors).toBeDefined();
371
+ });
372
+
373
+ it('should return error when query is missing', async () => {
374
+ const response = await request(server)
375
+ .post('/api/graphql')
376
+ .send({})
377
+ .set('Accept', 'application/json');
378
+
379
+ expect(response.status).toBe(400);
380
+ expect(response.body.errors).toBeDefined();
381
+ expect(response.body.errors[0].message).toContain('query');
382
+ });
383
+
384
+ it('should reject non-POST/GET methods', async () => {
385
+ const response = await request(server)
386
+ .put('/api/graphql')
387
+ .send({ query: '{ userList { id } }' })
388
+ .set('Accept', 'application/json');
389
+
390
+ expect(response.status).toBe(405);
391
+ });
392
+ });
393
+
394
+ describe('Variables', () => {
395
+ it('should support GraphQL variables', async () => {
396
+ const query = `
397
+ query GetUser($userId: String!) {
398
+ user(id: $userId) {
399
+ id
400
+ name
401
+ }
402
+ }
403
+ `;
404
+
405
+ const response = await request(server)
406
+ .post('/api/graphql')
407
+ .send({
408
+ query,
409
+ variables: { userId: '1' }
410
+ })
411
+ .set('Accept', 'application/json');
412
+
413
+ expect(response.status).toBe(200);
414
+ expect(response.body.data.user.name).toBe('Alice');
415
+ });
416
+
417
+ it('should support variables in GET requests', async () => {
418
+ const query = encodeURIComponent('query GetUser($id: String!) { user(id: $id) { name } }');
419
+ const variables = encodeURIComponent(JSON.stringify({ id: '1' }));
420
+
421
+ const response = await request(server)
422
+ .get(`/api/graphql?query=${query}&variables=${variables}`)
423
+ .set('Accept', 'application/json');
424
+
425
+ expect(response.status).toBe(200);
426
+ expect(response.body.data.user.name).toBe('Alice');
427
+ });
428
+ });
429
+
430
+ describe('CORS', () => {
431
+ it('should handle OPTIONS preflight request', async () => {
432
+ const response = await request(server)
433
+ .options('/api/graphql');
434
+
435
+ expect(response.status).toBe(200);
436
+ expect(response.headers['access-control-allow-methods']).toContain('POST');
437
+ });
438
+ });
439
+ });