@schmock/query 2.0.0 → 2.0.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,UAAU,iBAAiB;IACzB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,cAAc;IACtB,iCAAiC;IACjC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0CAA0C;IAC1C,YAAY,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,gBAAgB;IACxB,mCAAmC;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,kBAAkB;IAC1B,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAoCvE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,UAAU,iBAAiB;IACzB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,cAAc;IACtB,iCAAiC;IACjC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,yBAAyB;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,0CAA0C;IAC1C,YAAY,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC9B,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,UAAU,gBAAgB;IACxB,mCAAmC;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,UAAU,kBAAkB;IAC1B,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,SAAS,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,MAAM,CAoCvE"}
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- function W(z){return{name:"query",version:"1.0.0",process(A,j){if(!Array.isArray(j))return{context:A,response:j};let C=[...j],D=A.query||{};if(z.filtering)C=R(C,D,z.filtering);if(z.sorting)C=T(C,D,z.sorting);if(z.pagination){let B=U(C,D,z.pagination);return{context:A,response:B}}return{context:A,response:C}}}}function R(z,A,j){let C=j.filterPrefix??"filter",D=z;for(let B of j.allowed){let Q=`${C}[${B}]`,J=`${C}.${B}`,G=A[Q]??A[J]??A[B];if(G!==void 0)D=D.filter((M)=>{let E=M[B];if(E===void 0)return!1;return String(E)===G})}return D}function T(z,A,j){let C=j.sortParam??"sort",D=j.orderParam??"order",B=A[C]??j.default,J=(A[D]??j.defaultOrder??"asc")==="desc"?"desc":"asc";if(!B)return z;if(!j.allowed.includes(B))return z;return z.sort((G,M)=>{let E=G[B],H=M[B];if(E===H)return 0;if(E===void 0)return 1;if(H===void 0)return-1;let N;if(typeof E==="number"&&typeof H==="number")N=E-H;else N=String(E).localeCompare(String(H));return J==="desc"?-N:N})}function U(z,A,j){let C=j.pageParam??"page",D=j.limitParam??"limit",B=j.defaultLimit??10,Q=j.maxLimit??100,J=Math.max(1,Number.parseInt(A[C]||"1",10)||1),G=Math.min(Q,Math.max(1,Number.parseInt(A[D]||String(B),10)||B)),M=z.length,E=Math.ceil(M/G),H=(J-1)*G;return{data:z.slice(H,H+G),pagination:{page:J,limit:G,total:M,totalPages:E}}}export{W as queryPlugin};
1
+ var B="2.0.1";function N(c){return{name:"query",version:B,process(h,d){if(!Array.isArray(d))return{context:h,response:d};let j=[...d],k=h.query||{};if(c.filtering)j=E(j,k,c.filtering);if(c.sorting)j=G(j,k,c.sorting);if(c.pagination){let v=H(j,k,c.pagination);return{context:h,response:v}}return{context:h,response:j}}}}function E(c,h,d){let j=d.filterPrefix??"filter",k=c;for(let v of d.allowed){let g=`${j}[${v}]`,f=`${j}.${v}`,b=h[g]??h[f]??h[v];if(b!==void 0)k=k.filter((w)=>{if(typeof w!=="object"||w===null)return!1;let D=w[v];if(D===void 0)return!1;return String(D)===b})}return k}function G(c,h,d){let j=d.sortParam??"sort",k=d.orderParam??"order",v=h[j]??d.default,f=(h[k]??d.defaultOrder??"asc")==="desc"?"desc":"asc";if(!v)return c;if(!d.allowed.includes(v))return c;return c.sort((b,w)=>{if(typeof b!=="object"||b===null)return 0;if(typeof w!=="object"||w===null)return 0;let z=b,D=w,M=z[v],T=D[v];if(M===T)return 0;if(M===void 0)return 1;if(T===void 0)return-1;let A;if(typeof M==="number"&&typeof T==="number")A=M-T;else A=String(M).localeCompare(String(T));return f==="desc"?-A:A})}function H(c,h,d){let j=d.pageParam??"page",k=d.limitParam??"limit",v=d.defaultLimit??10,g=d.maxLimit??100,f=Math.max(1,Number.parseInt(h[j]||"1",10)||1),b=Math.min(g,Math.max(1,Number.parseInt(h[k]||String(v),10)||v)),w=c.length,z=Math.ceil(w/b),D=(f-1)*b;return{data:c.slice(D,D+b),pagination:{page:f,limit:b,total:w,totalPages:z}}}export{N as queryPlugin};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/query",
3
3
  "description": "Pagination, filtering, and sorting plugin for Schmock",
4
- "version": "2.0.0",
4
+ "version": "2.0.1",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
package/src/index.test.ts CHANGED
@@ -5,7 +5,7 @@ describe("queryPlugin", () => {
5
5
  it("creates a plugin with correct name", () => {
6
6
  const plugin = queryPlugin({ pagination: {} });
7
7
  expect(plugin.name).toBe("query");
8
- expect(plugin.version).toBe("1.0.0");
8
+ expect(plugin.version).toBe("2.0.1");
9
9
  });
10
10
 
11
11
  it("passes through non-array responses", async () => {
@@ -131,4 +131,334 @@ describe("queryPlugin", () => {
131
131
 
132
132
  expect(result.response.data).toHaveLength(3);
133
133
  });
134
+
135
+ describe("filtering edge cases", () => {
136
+ it("returns empty result when input array is empty", async () => {
137
+ const plugin = queryPlugin({
138
+ filtering: { allowed: ["role"] },
139
+ });
140
+
141
+ const result = await plugin.process(
142
+ {
143
+ path: "/test",
144
+ route: {},
145
+ method: "GET",
146
+ params: {},
147
+ query: { "filter[role]": "admin" },
148
+ headers: {},
149
+ state: new Map(),
150
+ },
151
+ [],
152
+ );
153
+
154
+ expect(result.response).toEqual([]);
155
+ });
156
+
157
+ it("excludes items that lack the filter field", async () => {
158
+ const plugin = queryPlugin({
159
+ filtering: { allowed: ["role"] },
160
+ });
161
+
162
+ const items = [
163
+ { id: 1, role: "admin" },
164
+ { id: 2, name: "no-role" },
165
+ { id: 3, role: "admin" },
166
+ ];
167
+
168
+ const result = await plugin.process(
169
+ {
170
+ path: "/test",
171
+ route: {},
172
+ method: "GET",
173
+ params: {},
174
+ query: { "filter[role]": "admin" },
175
+ headers: {},
176
+ state: new Map(),
177
+ },
178
+ items,
179
+ );
180
+
181
+ expect(result.response).toHaveLength(2);
182
+ expect(result.response.every((i: any) => i.role === "admin")).toBe(true);
183
+ });
184
+
185
+ it("handles filter value with special regex characters as literal string", async () => {
186
+ const plugin = queryPlugin({
187
+ filtering: { allowed: ["domain"] },
188
+ });
189
+
190
+ const items = [
191
+ { id: 1, domain: "foo.bar" },
192
+ { id: 2, domain: "fooXbar" },
193
+ { id: 3, domain: "foo.bar" },
194
+ ];
195
+
196
+ const result = await plugin.process(
197
+ {
198
+ path: "/test",
199
+ route: {},
200
+ method: "GET",
201
+ params: {},
202
+ query: { "filter[domain]": "foo.bar" },
203
+ headers: {},
204
+ state: new Map(),
205
+ },
206
+ items,
207
+ );
208
+
209
+ expect(result.response).toHaveLength(2);
210
+ expect(result.response.every((i: any) => i.domain === "foo.bar")).toBe(
211
+ true,
212
+ );
213
+ });
214
+ });
215
+
216
+ describe("sorting edge cases", () => {
217
+ it("sorts with mixed numeric and string values in the sort field", async () => {
218
+ const plugin = queryPlugin({
219
+ sorting: { allowed: ["score"], default: "score" },
220
+ });
221
+
222
+ const items = [
223
+ { id: 1, score: 10 },
224
+ { id: 2, score: "5" },
225
+ { id: 3, score: 2 },
226
+ ];
227
+
228
+ const result = await plugin.process(
229
+ {
230
+ path: "/test",
231
+ route: {},
232
+ method: "GET",
233
+ params: {},
234
+ query: { sort: "score", order: "asc" },
235
+ headers: {},
236
+ state: new Map(),
237
+ },
238
+ items,
239
+ );
240
+
241
+ // Mixed types fall back to localeCompare via String() coercion
242
+ expect(result.response).toHaveLength(3);
243
+ });
244
+
245
+ it("pushes items with undefined sort field values to the end", async () => {
246
+ const plugin = queryPlugin({
247
+ sorting: { allowed: ["name"], default: "name" },
248
+ });
249
+
250
+ const items = [
251
+ { id: 1 },
252
+ { id: 2, name: "Alice" },
253
+ { id: 3, name: "Bob" },
254
+ ];
255
+
256
+ const result = await plugin.process(
257
+ {
258
+ path: "/test",
259
+ route: {},
260
+ method: "GET",
261
+ params: {},
262
+ query: { sort: "name", order: "asc" },
263
+ headers: {},
264
+ state: new Map(),
265
+ },
266
+ items,
267
+ );
268
+
269
+ expect(result.response[0].name).toBe("Alice");
270
+ expect(result.response[1].name).toBe("Bob");
271
+ expect(result.response[2].name).toBeUndefined();
272
+ });
273
+
274
+ it("maintains relative order for items with equal sort values", async () => {
275
+ const plugin = queryPlugin({
276
+ sorting: { allowed: ["priority"], default: "priority" },
277
+ });
278
+
279
+ const items = [
280
+ { id: 1, priority: 1, label: "first" },
281
+ { id: 2, priority: 1, label: "second" },
282
+ { id: 3, priority: 1, label: "third" },
283
+ ];
284
+
285
+ const result = await plugin.process(
286
+ {
287
+ path: "/test",
288
+ route: {},
289
+ method: "GET",
290
+ params: {},
291
+ query: { sort: "priority", order: "asc" },
292
+ headers: {},
293
+ state: new Map(),
294
+ },
295
+ items,
296
+ );
297
+
298
+ // Sort stability: equal values preserve insertion order
299
+ expect(result.response.map((i: any) => i.label)).toEqual([
300
+ "first",
301
+ "second",
302
+ "third",
303
+ ]);
304
+ });
305
+ });
306
+
307
+ describe("pagination edge cases", () => {
308
+ const makePaginationPlugin = (defaultLimit = 10, maxLimit = 100) =>
309
+ queryPlugin({
310
+ pagination: { defaultLimit, maxLimit },
311
+ });
312
+
313
+ const items = Array.from({ length: 5 }, (_, i) => ({ id: i + 1 }));
314
+
315
+ const makeContext = (query: Record<string, string>) => ({
316
+ path: "/test",
317
+ route: {},
318
+ method: "GET" as const,
319
+ params: {},
320
+ query,
321
+ headers: {},
322
+ state: new Map(),
323
+ });
324
+
325
+ it("clamps page=0 to page 1", async () => {
326
+ const plugin = makePaginationPlugin(2);
327
+ const result = await plugin.process(makeContext({ page: "0" }), items);
328
+
329
+ expect(result.response.pagination.page).toBe(1);
330
+ expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
331
+ });
332
+
333
+ it("clamps page=-1 to page 1", async () => {
334
+ const plugin = makePaginationPlugin(2);
335
+ const result = await plugin.process(makeContext({ page: "-1" }), items);
336
+
337
+ expect(result.response.pagination.page).toBe(1);
338
+ expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
339
+ });
340
+
341
+ it("clamps limit=0 to defaultLimit (0 is falsy, falls back to default)", async () => {
342
+ const plugin = makePaginationPlugin(10, 100);
343
+ const result = await plugin.process(makeContext({ limit: "0" }), items);
344
+
345
+ // parseInt("0") is 0, which is falsy → falls back to defaultLimit (10)
346
+ expect(result.response.pagination.limit).toBe(10);
347
+ expect(result.response.data).toHaveLength(5);
348
+ });
349
+
350
+ it("clamps limit exceeding maxLimit to maxLimit", async () => {
351
+ const plugin = makePaginationPlugin(10, 3);
352
+ const result = await plugin.process(makeContext({ limit: "999" }), items);
353
+
354
+ expect(result.response.pagination.limit).toBe(3);
355
+ expect(result.response.data).toHaveLength(3);
356
+ });
357
+
358
+ it("defaults gracefully when page is non-numeric", async () => {
359
+ const plugin = makePaginationPlugin(2);
360
+ const result = await plugin.process(makeContext({ page: "abc" }), items);
361
+
362
+ expect(result.response.pagination.page).toBe(1);
363
+ expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
364
+ });
365
+
366
+ it("defaults gracefully when limit is non-numeric", async () => {
367
+ const plugin = makePaginationPlugin(2);
368
+ const result = await plugin.process(makeContext({ limit: "abc" }), items);
369
+
370
+ expect(result.response.pagination.limit).toBe(2);
371
+ expect(result.response.data).toEqual([{ id: 1 }, { id: 2 }]);
372
+ });
373
+
374
+ it("returns empty data with correct total for a very large page number", async () => {
375
+ const plugin = makePaginationPlugin(2);
376
+ const result = await plugin.process(makeContext({ page: "9999" }), items);
377
+
378
+ expect(result.response.data).toEqual([]);
379
+ expect(result.response.pagination.total).toBe(5);
380
+ expect(result.response.pagination.totalPages).toBe(3);
381
+ });
382
+
383
+ it("returns data=[], total=0, totalPages=0 for empty array input", async () => {
384
+ const plugin = makePaginationPlugin(10);
385
+ const result = await plugin.process(makeContext({}), []);
386
+
387
+ expect(result.response.data).toEqual([]);
388
+ expect(result.response.pagination.total).toBe(0);
389
+ expect(result.response.pagination.totalPages).toBe(0);
390
+ });
391
+ });
392
+
393
+ describe("process method edge cases", () => {
394
+ it("passes through null response unchanged", async () => {
395
+ const plugin = queryPlugin({
396
+ pagination: { defaultLimit: 10 },
397
+ filtering: { allowed: ["role"] },
398
+ sorting: { allowed: ["name"] },
399
+ });
400
+
401
+ const result = await plugin.process(
402
+ {
403
+ path: "/test",
404
+ route: {},
405
+ method: "GET",
406
+ params: {},
407
+ query: {},
408
+ headers: {},
409
+ state: new Map(),
410
+ },
411
+ null,
412
+ );
413
+
414
+ expect(result.response).toBeNull();
415
+ });
416
+
417
+ it("passes through object (non-array) response unchanged", async () => {
418
+ const plugin = queryPlugin({
419
+ pagination: { defaultLimit: 10 },
420
+ filtering: { allowed: ["role"] },
421
+ sorting: { allowed: ["name"] },
422
+ });
423
+
424
+ const obj = { message: "hello", count: 42 };
425
+ const result = await plugin.process(
426
+ {
427
+ path: "/test",
428
+ route: {},
429
+ method: "GET",
430
+ params: {},
431
+ query: {},
432
+ headers: {},
433
+ state: new Map(),
434
+ },
435
+ obj,
436
+ );
437
+
438
+ expect(result.response).toEqual(obj);
439
+ });
440
+
441
+ it("passes through undefined response unchanged", async () => {
442
+ const plugin = queryPlugin({
443
+ pagination: { defaultLimit: 10 },
444
+ filtering: { allowed: ["role"] },
445
+ sorting: { allowed: ["name"] },
446
+ });
447
+
448
+ const result = await plugin.process(
449
+ {
450
+ path: "/test",
451
+ route: {},
452
+ method: "GET",
453
+ params: {},
454
+ query: {},
455
+ headers: {},
456
+ state: new Map(),
457
+ },
458
+ undefined,
459
+ );
460
+
461
+ expect(result.response).toBeUndefined();
462
+ });
463
+ });
134
464
  });
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /// <reference path="../../core/schmock.d.ts" />
2
2
 
3
+ import { version as packageVersion } from "../package.json";
4
+
3
5
  interface PaginationOptions {
4
6
  /** Default items per page (default: 10) */
5
7
  defaultLimit?: number;
@@ -40,18 +42,18 @@ interface QueryPluginOptions {
40
42
  export function queryPlugin(options: QueryPluginOptions): Schmock.Plugin {
41
43
  return {
42
44
  name: "query",
43
- version: "1.0.0",
45
+ version: packageVersion,
44
46
 
45
47
  process(
46
48
  context: Schmock.PluginContext,
47
- response?: any,
49
+ response?: unknown,
48
50
  ): Schmock.PluginResult {
49
51
  // Only process array responses
50
52
  if (!Array.isArray(response)) {
51
53
  return { context, response };
52
54
  }
53
55
 
54
- let items = [...response];
56
+ let items: unknown[] = [...response];
55
57
  const query = context.query || {};
56
58
 
57
59
  // Apply filtering
@@ -76,10 +78,10 @@ export function queryPlugin(options: QueryPluginOptions): Schmock.Plugin {
76
78
  }
77
79
 
78
80
  function applyFiltering(
79
- items: any[],
81
+ items: unknown[],
80
82
  query: Record<string, string>,
81
83
  options: FilteringOptions,
82
- ): any[] {
84
+ ): unknown[] {
83
85
  const prefix = options.filterPrefix ?? "filter";
84
86
  let result = items;
85
87
 
@@ -93,7 +95,9 @@ function applyFiltering(
93
95
 
94
96
  if (value !== undefined) {
95
97
  result = result.filter((item) => {
96
- const itemValue = item[field];
98
+ if (typeof item !== "object" || item === null) return false;
99
+ const record = item as Record<string, unknown>;
100
+ const itemValue = record[field];
97
101
  if (itemValue === undefined) return false;
98
102
  // Intentional string coercion: query params are inherently strings
99
103
  return String(itemValue) === value;
@@ -105,10 +109,10 @@ function applyFiltering(
105
109
  }
106
110
 
107
111
  function applySorting(
108
- items: any[],
112
+ items: unknown[],
109
113
  query: Record<string, string>,
110
114
  options: SortingOptions,
111
- ): any[] {
115
+ ): unknown[] {
112
116
  const sortParam = options.sortParam ?? "sort";
113
117
  const orderParam = options.orderParam ?? "order";
114
118
  const sortField = query[sortParam] ?? options.default;
@@ -121,8 +125,12 @@ function applySorting(
121
125
  if (!options.allowed.includes(sortField)) return items;
122
126
 
123
127
  return items.sort((a, b) => {
124
- const aVal = a[sortField];
125
- const bVal = b[sortField];
128
+ if (typeof a !== "object" || a === null) return 0;
129
+ if (typeof b !== "object" || b === null) return 0;
130
+ const aRecord = a as Record<string, unknown>;
131
+ const bRecord = b as Record<string, unknown>;
132
+ const aVal = aRecord[sortField];
133
+ const bVal = bRecord[sortField];
126
134
 
127
135
  if (aVal === bVal) return 0;
128
136
  if (aVal === undefined) return 1;
@@ -140,7 +148,7 @@ function applySorting(
140
148
  }
141
149
 
142
150
  interface PaginatedResult {
143
- data: any[];
151
+ data: unknown[];
144
152
  pagination: {
145
153
  page: number;
146
154
  limit: number;
@@ -150,7 +158,7 @@ interface PaginatedResult {
150
158
  }
151
159
 
152
160
  function applyPagination(
153
- items: any[],
161
+ items: unknown[],
154
162
  query: Record<string, string>,
155
163
  options: PaginationOptions,
156
164
  ): PaginatedResult {
@@ -79,30 +79,6 @@ describeFeature(feature, ({ Scenario }) => {
79
79
  });
80
80
  });
81
81
 
82
- Scenario("Pagination beyond last page returns empty", ({ Given, When, Then, And }) => {
83
- Given("I create a mock with 25 items and pagination plugin", () => {
84
- mock = schmock();
85
- mock("GET /items", generateItems(25))
86
- .pipe(queryPlugin({
87
- pagination: { defaultLimit: 10, maxLimit: 100 },
88
- }));
89
- });
90
-
91
- When("I request page 10", async () => {
92
- response = await mock.handle("GET", "/items", {
93
- query: { page: "10" },
94
- });
95
- });
96
-
97
- Then("I should receive {int} items", (_, count: number) => {
98
- expect(response.body.data).toHaveLength(count);
99
- });
100
-
101
- And("the pagination total should be {int}", (_, total: number) => {
102
- expect(response.body.pagination.total).toBe(total);
103
- });
104
- });
105
-
106
82
  Scenario("Sort items ascending by name", ({ Given, When, Then, And }) => {
107
83
  Given("I create a mock with named items and sorting plugin", () => {
108
84
  mock = schmock();