From 664b71012a39a3b4791d9a7d389159e646c140f5 Mon Sep 17 00:00:00 2001 From: Many0nne Date: Sat, 7 Mar 2026 19:34:53 +0100 Subject: [PATCH] fix filtering --- .mock-config.json | 7 -- README.md | 19 +++--- review | 104 ------------------------------ src/core/queryProcessor.ts | 64 ++++++++++-------- src/core/swagger.ts | 27 ++++++-- tests/core/queryProcessor.test.ts | 56 ++++++++++------ 6 files changed, 105 insertions(+), 172 deletions(-) delete mode 100644 .mock-config.json delete mode 100644 review diff --git a/.mock-config.json b/.mock-config.json deleted file mode 100644 index d306925..0000000 --- a/.mock-config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "typesDir": "..\\testfront\\types", - "port": 8080, - "hotReload": true, - "cache": true, - "verbose": false -} \ No newline at end of file diff --git a/README.md b/README.md index ce21202..02eabaa 100644 --- a/README.md +++ b/README.md @@ -144,17 +144,18 @@ GET /users?page=2&pageSize=50 ### Filtering -| Convention | Example | Description | -|---|---|---| -| `field=value` | `status=active` | Exact match (case-insensitive for strings) | -| `field_like=value` | `email_like=@example.com` | Substring match (case-insensitive) | -| `field_from=date` | `createdAt_from=2024-01-01` | Date range — start (inclusive) | -| `field_to=date` | `createdAt_to=2024-12-31` | Date range — end (inclusive) | +| Convention | Applies to | Example | Description | +|---|---|---|---| +| `field=value` | string, number, boolean | `status=active` | Exact match (case-insensitive for strings) | +| `field_contains=value` | string | `email_contains=@example.com` | Substring match (case-insensitive) | +| `field_gte=value` | number, date | `price_gte=10`, `createdAt_gte=2024-01-01` | Greater than or equal | +| `field_lte=value` | number, date | `price_lte=100`, `createdAt_lte=2024-12-31` | Less than or equal | -Multiple filters are combined with AND logic. Unknown fields are silently ignored. +Multiple filters are combined with AND logic. Unknown fields are silently ignored. Date values must be ISO 8601. ```bash -GET /users?status=active&email_like=@example.com&createdAt_from=2024-01-01 +GET /users?status=active&email_contains=@example.com&createdAt_gte=2024-01-01 +GET /products?price_gte=10&price_lte=100&status=active ``` ### Sorting @@ -170,7 +171,7 @@ Sorting by a field that does not exist in the interface returns `400`. ### Combined Example ```bash -GET /users?page=2&pageSize=50&status=active&email_like=@example.com&sort=createdAt:desc +GET /users?page=2&pageSize=50&status=active&email_contains=@example.com&sort=createdAt:desc ``` ### Error Responses diff --git a/review b/review deleted file mode 100644 index 440b5d5..0000000 --- a/review +++ /dev/null @@ -1,104 +0,0 @@ -Pull request overview -This PR introduces an always-on mock data caching layer (MockDataStore) so that generated mock data remains stable across requests (instead of being regenerated each time), and adds a "Rebuild Data" button to the Swagger UI that allows users to clear the cached mock data and regenerate it on the next request. - -Changes: - -Added MockDataStore class in cache.ts that caches both single-object mocks and array pools, independent of the config.cache setting. -Integrated mockDataStore into the route handler (router.ts) to serve cached mock data on subsequent requests and regenerate only on first access. -Added a /mock-reset POST endpoint and a custom Swagger UI toolbar with a "Rebuild Data" button that triggers cache clearing. -Reviewed changes -Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments. - -File Description -src/core/cache.ts New MockDataStore class with singles/pools Maps, get/set/clear/getStats methods, and a global mockDataStore singleton. -src/core/router.ts Updated dynamic route handler to check mockDataStore before generating new mock data for both array and single-object endpoints. -src/server.ts Added /swagger-rebuild.js endpoint serving custom JS for the toolbar button, /mock-reset POST endpoint, and wired custom JS into Swagger UI setup. -src/core/swagger.ts Added OpenAPI spec entry for the /mock-reset endpoint. -💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started. - -src/server.ts -Comment on lines +107 to +108 - const cleared = mockDataStore.clear(); - res.json({ message: 'Mock data store cleared', cleared }); -Copilot AI -5 minutes ago -Bug: When config.cache is enabled, the schemaCache is checked before mockDataStore for single-object endpoints (lines 65-72 of router.ts). The /mock-reset endpoint only clears mockDataStore but does not clear schemaCache. This means after clicking "Rebuild Data", single-object endpoints will continue to serve the old cached data from schemaCache, making the reset appear broken for those endpoints. - -You should also call schemaCache.clear() in the /mock-reset handler. - -Suggested change - const cleared = mockDataStore.clear(); - res.json({ message: 'Mock data store cleared', cleared }); - const mockCleared = mockDataStore.clear(); - const schemaCleared = schemaCache.clear(); - res.json({ - message: 'Mock data store cleared', - cleared: { - mockDataStore: mockCleared, - schemaCache: schemaCleared, - }, - }); -Copilot uses AI. Check for mistakes. -@Many0nne Reply... -src/core/router.ts -Comment on lines +94 to +99 - // Use stable pool from the data store; generate and cache on first request - let pool = mockDataStore.getPool(mapping.typeName, filePath); - if (!pool) { - pool = generateMockArray(filePath, mapping.typeName, { arrayLength: POOL_SIZE }); - mockDataStore.setPool(mapping.typeName, filePath, pool); - } -Copilot AI -5 minutes ago -Bug: The file watcher invalidates schemaCache on file changes (see src/utils/fileWatcher.ts:32,50) but does not invalidate mockDataStore. When hot-reload is enabled and a type file is modified, mockDataStore will continue serving stale mock data because the mockDataStore cache hit (line 95-99 for arrays, line 115-118 for singles) happens before any generation, and the stale entries are never removed. - -mockDataStore should also be invalidated when type files change. Consider adding an invalidateFile method to MockDataStore (similar to SchemaCache.invalidateFile) and calling it from the file watcher. - -Copilot uses AI. Check for mistakes. -@Many0nne Reply... -src/core/cache.ts -Comment on lines +132 to +170 -export class MockDataStore { - private singles: Map>> = new Map(); - private pools: Map[]>> = new Map(); - - private key(typeName: string, filePath: string): string { - return `${filePath}::${typeName}`; - } - - getSingle(typeName: string, filePath: string): Record | undefined { - return this.singles.get(this.key(typeName, filePath))?.data; - } - - setSingle(typeName: string, filePath: string, data: Record): void { - this.singles.set(this.key(typeName, filePath), { data, createdAt: Date.now() }); - } - - getPool(typeName: string, filePath: string): Record[] | undefined { - return this.pools.get(this.key(typeName, filePath))?.data; - } - - setPool(typeName: string, filePath: string, data: Record[]): void { - this.pools.set(this.key(typeName, filePath), { data, createdAt: Date.now() }); - } - - clear(): { singles: number; pools: number } { - const singles = this.singles.size; - const pools = this.pools.size; - this.singles.clear(); - this.pools.clear(); - logger.info(`MockDataStore cleared: ${singles} single(s), ${pools} pool(s)`); - return { singles, pools }; - } - - getStats(): { singles: number; pools: number } { - return { singles: this.singles.size, pools: this.pools.size }; - } -} - -export const mockDataStore = new MockDataStore(); -Copilot AI -5 minutes ago -The MockDataStore class is missing tests. The existing tests/core/cache.test.ts comprehensively tests SchemaCache (get/set, invalidateFile, clear, size, getStats). Since MockDataStore is in the same file and follows a similar pattern, it should have analogous test coverage—especially for getSingle/setSingle, getPool/setPool, clear (including its return value), and getStats. - -Copilot uses AI. Check for mistakes. \ No newline at end of file diff --git a/src/core/queryProcessor.ts b/src/core/queryProcessor.ts index ba00604..e5b92c3 100644 --- a/src/core/queryProcessor.ts +++ b/src/core/queryProcessor.ts @@ -21,9 +21,9 @@ export interface ParsedQueryParams { pageSize: number; sort: SortEntry[]; exactFilters: Record; - likeFilters: Record; - fromFilters: Record; - toFilters: Record; + containsFilters: Record; + gteFilters: Record; + lteFilters: Record; } export interface PaginationMeta { @@ -103,26 +103,26 @@ export function parseQueryParams( // filters — derived from remaining query params const exactFilters: Record = {}; - const likeFilters: Record = {}; - const fromFilters: Record = {}; - const toFilters: Record = {}; + const containsFilters: Record = {}; + const gteFilters: Record = {}; + const lteFilters: Record = {}; for (const [key, value] of Object.entries(query)) { if (RESERVED_PARAMS.has(key) || value === undefined) continue; const strVal = Array.isArray(value) ? (value[0] ?? '') : value; - if (key.endsWith('_like')) { - likeFilters[key.slice(0, -5)] = strVal; - } else if (key.endsWith('_from')) { - fromFilters[key.slice(0, -5)] = strVal; - } else if (key.endsWith('_to')) { - toFilters[key.slice(0, -3)] = strVal; + if (key.endsWith('_contains')) { + containsFilters[key.slice(0, -9)] = strVal; + } else if (key.endsWith('_gte')) { + gteFilters[key.slice(0, -4)] = strVal; + } else if (key.endsWith('_lte')) { + lteFilters[key.slice(0, -4)] = strVal; } else { exactFilters[key] = strVal; } } - return { page, pageSize, sort, exactFilters, likeFilters, fromFilters, toFilters }; + return { page, pageSize, sort, exactFilters, containsFilters, gteFilters, lteFilters }; } /** @@ -166,7 +166,7 @@ function matchesExact( return String(v) === value; } -function matchesLike( +function matchesContains( item: Record, field: string, value: string @@ -176,32 +176,40 @@ function matchesLike( return String(v).toLowerCase().includes(value.toLowerCase()); } -function matchesFrom( +function matchesGte( item: Record, field: string, value: string ): boolean { const v = getFieldValue(item, field); if (v === undefined) return true; - const fromDate = new Date(value); - if (isNaN(fromDate.getTime())) return true; + if (typeof v === 'number') { + const num = Number(value); + return isNaN(num) ? true : v >= num; + } + const threshold = new Date(value); + if (isNaN(threshold.getTime())) return true; const itemDate = new Date(String(v)); if (isNaN(itemDate.getTime())) return true; - return itemDate >= fromDate; + return itemDate >= threshold; } -function matchesTo( +function matchesLte( item: Record, field: string, value: string ): boolean { const v = getFieldValue(item, field); if (v === undefined) return true; - const toDate = new Date(value); - if (isNaN(toDate.getTime())) return true; + if (typeof v === 'number') { + const num = Number(value); + return isNaN(num) ? true : v <= num; + } + const threshold = new Date(value); + if (isNaN(threshold.getTime())) return true; const itemDate = new Date(String(v)); if (isNaN(itemDate.getTime())) return true; - return itemDate <= toDate; + return itemDate <= threshold; } function applyFilters( @@ -212,14 +220,14 @@ function applyFilters( for (const [field, value] of Object.entries(params.exactFilters)) { if (!matchesExact(item, field, value)) return false; } - for (const [field, value] of Object.entries(params.likeFilters)) { - if (!matchesLike(item, field, value)) return false; + for (const [field, value] of Object.entries(params.containsFilters)) { + if (!matchesContains(item, field, value)) return false; } - for (const [field, value] of Object.entries(params.fromFilters)) { - if (!matchesFrom(item, field, value)) return false; + for (const [field, value] of Object.entries(params.gteFilters)) { + if (!matchesGte(item, field, value)) return false; } - for (const [field, value] of Object.entries(params.toFilters)) { - if (!matchesTo(item, field, value)) return false; + for (const [field, value] of Object.entries(params.lteFilters)) { + if (!matchesLte(item, field, value)) return false; } return true; }); diff --git a/src/core/swagger.ts b/src/core/swagger.ts index 4135b65..dabb025 100644 --- a/src/core/swagger.ts +++ b/src/core/swagger.ts @@ -164,10 +164,10 @@ function buildListParameters( }); } - // _like — substring match (non-date strings only) + // _contains — substring match (non-date strings only) if (isString) { params.push({ - name: `${field}_like`, + name: `${field}_contains`, in: 'query', description: `Case-insensitive substring filter on \`${field}\``, required: false, @@ -175,23 +175,40 @@ function buildListParameters( }); } - // _from / _to — range filters for dates + // _gte / _lte — range filters for dates and numbers if (isDate) { params.push({ - name: `${field}_from`, + name: `${field}_gte`, in: 'query', description: `Return items where \`${field}\` is on or after this date (ISO 8601)`, required: false, schema: { type: 'string', format: 'date-time' }, }); params.push({ - name: `${field}_to`, + name: `${field}_lte`, in: 'query', description: `Return items where \`${field}\` is on or before this date (ISO 8601)`, required: false, schema: { type: 'string', format: 'date-time' }, }); } + + if (isNumber) { + params.push({ + name: `${field}_gte`, + in: 'query', + description: `Return items where \`${field}\` is greater than or equal to this value`, + required: false, + schema: { type: 'number' }, + }); + params.push({ + name: `${field}_lte`, + in: 'query', + description: `Return items where \`${field}\` is less than or equal to this value`, + required: false, + schema: { type: 'number' }, + }); + } } return params; diff --git a/tests/core/queryProcessor.test.ts b/tests/core/queryProcessor.test.ts index d2e0d73..d1d30d2 100644 --- a/tests/core/queryProcessor.test.ts +++ b/tests/core/queryProcessor.test.ts @@ -27,9 +27,9 @@ describe('parseQueryParams', () => { pageSize: DEFAULT_PAGE_SIZE, sort: [], exactFilters: {}, - likeFilters: {}, - fromFilters: {}, - toFilters: {}, + containsFilters: {}, + gteFilters: {}, + lteFilters: {}, }); }); @@ -97,20 +97,20 @@ describe('parseQueryParams', () => { } }); - it('parses _like filters', () => { - const result = parseQueryParams({ email_like: '@example.com' }); + it('parses _contains filters', () => { + const result = parseQueryParams({ email_contains: '@example.com' }); expect('error' in result).toBe(false); if (!('error' in result)) { - expect(result.likeFilters).toEqual({ email: '@example.com' }); + expect(result.containsFilters).toEqual({ email: '@example.com' }); } }); - it('parses _from and _to filters', () => { - const result = parseQueryParams({ createdAt_from: '2024-01-01', createdAt_to: '2024-12-31' }); + it('parses _gte and _lte filters', () => { + const result = parseQueryParams({ createdAt_gte: '2024-01-01', createdAt_lte: '2024-12-31' }); expect('error' in result).toBe(false); if (!('error' in result)) { - expect(result.fromFilters).toEqual({ createdAt: '2024-01-01' }); - expect(result.toFilters).toEqual({ createdAt: '2024-12-31' }); + expect(result.gteFilters).toEqual({ createdAt: '2024-01-01' }); + expect(result.lteFilters).toEqual({ createdAt: '2024-12-31' }); } }); }); @@ -139,9 +139,9 @@ describe('applyPagination', () => { pageSize: DEFAULT_PAGE_SIZE, sort: [], exactFilters: {}, - likeFilters: {}, - fromFilters: {}, - toFilters: {}, + containsFilters: {}, + gteFilters: {}, + lteFilters: {}, }; describe('pagination', () => { @@ -194,22 +194,22 @@ describe('applyPagination', () => { result.data.forEach((item) => expect(item['status']).toBe('active')); }); - it('applies _like filter', () => { + it('applies _contains filter', () => { const pool = makeItems(10); const result = applyPagination(pool, { ...baseParams, - likeFilters: { email: '@example.com' }, + containsFilters: { email: '@example.com' }, }); result.data.forEach((item) => expect(String(item['email']).toLowerCase()).toContain('@example.com') ); }); - it('applies _from date filter', () => { + it('applies _gte date filter', () => { const pool = makeItems(10); const result = applyPagination(pool, { ...baseParams, - fromFilters: { createdAt: '2024-01-05' }, + gteFilters: { createdAt: '2024-01-05' }, }); result.data.forEach((item) => { const d = new Date(String(item['createdAt'])); @@ -217,11 +217,11 @@ describe('applyPagination', () => { }); }); - it('applies _to date filter', () => { + it('applies _lte date filter', () => { const pool = makeItems(10); const result = applyPagination(pool, { ...baseParams, - toFilters: { createdAt: '2024-01-05' }, + lteFilters: { createdAt: '2024-01-05' }, }); result.data.forEach((item) => { const d = new Date(String(item['createdAt'])); @@ -229,6 +229,24 @@ describe('applyPagination', () => { }); }); + it('applies _gte numeric filter', () => { + const pool = makeItems(10); + const result = applyPagination(pool, { + ...baseParams, + gteFilters: { score: '5' }, + }); + result.data.forEach((item) => expect(item['score'] as number).toBeGreaterThanOrEqual(5)); + }); + + it('applies _lte numeric filter', () => { + const pool = makeItems(10); + const result = applyPagination(pool, { + ...baseParams, + lteFilters: { score: '5' }, + }); + result.data.forEach((item) => expect(item['score'] as number).toBeLessThanOrEqual(5)); + }); + it('returns empty data when no items match filter', () => { const pool = makeItems(10); const result = applyPagination(pool, {