Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .mock-config.json

This file was deleted.

19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
104 changes: 0 additions & 104 deletions review

This file was deleted.

64 changes: 36 additions & 28 deletions src/core/queryProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export interface ParsedQueryParams {
pageSize: number;
sort: SortEntry[];
exactFilters: Record<string, string>;
likeFilters: Record<string, string>;
fromFilters: Record<string, string>;
toFilters: Record<string, string>;
containsFilters: Record<string, string>;
gteFilters: Record<string, string>;
lteFilters: Record<string, string>;
}

export interface PaginationMeta {
Expand Down Expand Up @@ -103,26 +103,26 @@ export function parseQueryParams(

// filters — derived from remaining query params
const exactFilters: Record<string, string> = {};
const likeFilters: Record<string, string> = {};
const fromFilters: Record<string, string> = {};
const toFilters: Record<string, string> = {};
const containsFilters: Record<string, string> = {};
const gteFilters: Record<string, string> = {};
const lteFilters: Record<string, string> = {};

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 };
}

/**
Expand Down Expand Up @@ -166,7 +166,7 @@ function matchesExact(
return String(v) === value;
}

function matchesLike(
function matchesContains(
item: Record<string, unknown>,
field: string,
value: string
Expand All @@ -176,32 +176,40 @@ function matchesLike(
return String(v).toLowerCase().includes(value.toLowerCase());
}

function matchesFrom(
function matchesGte(
item: Record<string, unknown>,
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<string, unknown>,
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(
Expand All @@ -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;
});
Expand Down
27 changes: 22 additions & 5 deletions src/core/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,34 +164,51 @@ 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,
schema: { type: 'string' },
});
}

// _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;
Expand Down
Loading