Skip to content

Commit 783e672

Browse files
CopilotBitsHost
andcommitted
Add advanced filtering, field selection, and input validation
Co-authored-by: BitsHost <23263143+BitsHost@users.noreply.github.com>
1 parent af9b37c commit 783e672

File tree

6 files changed

+469
-21
lines changed

6 files changed

+469
-21
lines changed

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,37 @@
11
# Changelog
22

3+
## 1.1.0 - Enhanced Query Capabilities
4+
5+
### New Features
6+
- **Advanced Filter Operators**: Support for comparison operators (eq, neq, gt, gte, lt, lte, like, in, notin, null, notnull)
7+
- **Field Selection**: Select specific fields in list queries using the `fields` parameter
8+
- **Input Validation**: Added comprehensive input validation for table names, column names, IDs, and query parameters
9+
- **Backward Compatibility**: Old filter format (`col:value`) still works alongside new format (`col:op:value`)
10+
11+
### Improvements
12+
- Fixed SQL injection vulnerability in filter parameter by using parameterized queries with unique parameter names
13+
- Added Validator class for centralized input validation and sanitization
14+
- Improved error messages with proper HTTP status codes
15+
- Enhanced documentation with detailed examples of new features
16+
17+
### Filter Operators
18+
- `eq` - Equals
19+
- `neq`/`ne` - Not equals
20+
- `gt` - Greater than
21+
- `gte`/`ge` - Greater than or equal
22+
- `lt` - Less than
23+
- `lte`/`le` - Less than or equal
24+
- `like` - Pattern matching
25+
- `in` - In list (pipe-separated values)
26+
- `notin`/`nin` - Not in list
27+
- `null` - Is NULL
28+
- `notnull` - Is NOT NULL
29+
30+
### Examples
31+
- Field selection: `/index.php?action=list&table=users&fields=id,name,email`
32+
- Advanced filtering: `/index.php?action=list&table=users&filter=age:gt:18,status:eq:active`
33+
- IN operator: `/index.php?action=list&table=orders&filter=status:in:pending|processing|shipped`
34+
335
## 1.0.0
436

537
- Initial release: automatic CRUD API generator for MySQL/MariaDB.

README.md

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ OpenAPI (Swagger) docs, and zero code generation.
1111
- Auto-discovers tables and columns
1212
- Full CRUD endpoints for any table
1313
- Configurable authentication (API Key, Basic Auth, JWT, or none)
14-
- Advanced query features: filtering, sorting, pagination
14+
- **Advanced query features:**
15+
- **Field selection** - Choose specific columns to return
16+
- **Advanced filtering** - Support for multiple comparison operators (eq, neq, gt, gte, lt, lte, like, in, notin, null, notnull)
17+
- **Sorting** - Multi-column sorting with ascending/descending order
18+
- **Pagination** - Efficient pagination with metadata
19+
- **Input validation** - Comprehensive validation to prevent SQL injection and invalid inputs
1520
- RBAC: per-table role-based access control
1621
- Admin panel (minimal)
1722
- OpenAPI (Swagger) JSON endpoint for instant docs
@@ -114,23 +119,44 @@ curl -u admin:secret "http://localhost/index.php?action=list&table=users"
114119
---
115120

116121

117-
### 🔄 Advanced Query Features (Filtering, Sorting, Pagination)
122+
### 🔄 Advanced Query Features (Filtering, Sorting, Pagination, Field Selection)
118123

119124
The `list` action endpoint now supports advanced query parameters:
120125

121126
| Parameter | Type | Description |
122127
|--------------|---------|---------------------------------------------------------------------------------------------------|
123-
| `filter` | string | Filter rows by column values. Format: `filter=col1:value1,col2:value2`. Use `%` for wildcards. |
128+
| `filter` | string | Filter rows by column values. Format: `filter=col:op:value` or `filter=col:value` (backward compatible). Use `,` to combine multiple filters. |
124129
| `sort` | string | Sort by columns. Comma-separated. Use `-` prefix for DESC. Example: `sort=-created_at,name` |
125130
| `page` | int | Page number (1-based). Default: `1` |
126131
| `page_size` | int | Number of rows per page (max 100). Default: `20` |
132+
| `fields` | string | Select specific fields. Comma-separated. Example: `fields=id,name,email` |
133+
134+
#### Filter Operators
135+
136+
| Operator | Description | Example |
137+
|----------|-------------|---------|
138+
| `eq` or `:` | Equals | `filter=name:eq:Alice` or `filter=name:Alice` |
139+
| `neq` or `ne` | Not equals | `filter=status:neq:deleted` |
140+
| `gt` | Greater than | `filter=age:gt:18` |
141+
| `gte` or `ge` | Greater than or equal | `filter=price:gte:100` |
142+
| `lt` | Less than | `filter=stock:lt:10` |
143+
| `lte` or `le` | Less than or equal | `filter=discount:lte:50` |
144+
| `like` | Pattern match | `filter=email:like:%@gmail.com` |
145+
| `in` | In list (pipe-separated) | `filter=status:in:active|pending` |
146+
| `notin` or `nin` | Not in list | `filter=role:notin:admin|super` |
147+
| `null` | Is NULL | `filter=deleted_at:null:` |
148+
| `notnull` | Is NOT NULL | `filter=email:notnull:` |
127149

128150
**Examples:**
129151

130-
- `GET /index.php?action=list&table=users&filter=name:Alice`
131-
- `GET /index.php?action=list&table=users&sort=-created_at,name`
132-
- `GET /index.php?action=list&table=users&page=2&page_size=10`
133-
- `GET /index.php?action=list&table=users&filter=email:%gmail.com&sort=name&page=1&page_size=5`
152+
- **Basic filtering:** `GET /index.php?action=list&table=users&filter=name:Alice`
153+
- **Advanced filtering:** `GET /index.php?action=list&table=users&filter=age:gt:18,status:eq:active`
154+
- **Field selection:** `GET /index.php?action=list&table=users&fields=id,name,email`
155+
- **Sorting:** `GET /index.php?action=list&table=users&sort=-created_at,name`
156+
- **Pagination:** `GET /index.php?action=list&table=users&page=2&page_size=10`
157+
- **Combined query:** `GET /index.php?action=list&table=users&filter=email:like:%gmail.com&sort=name&page=1&page_size=5&fields=id,name,email`
158+
- **IN operator:** `GET /index.php?action=list&table=orders&filter=status:in:pending|processing|shipped`
159+
- **Multiple conditions:** `GET /index.php?action=list&table=products&filter=price:gte:10,price:lte:100,stock:gt:0`
134160

135161
**Response:**
136162
```json
@@ -206,6 +232,9 @@ get:
206232
- **Enable authentication for any public deployment!**
207233
- Never commit real credentials—use `.gitignore` and example configs.
208234
- Restrict DB user privileges.
235+
- **Input validation**: All user inputs (table names, column names, IDs, filters) are validated to prevent SQL injection and invalid queries.
236+
- **Parameterized queries**: All database queries use prepared statements with bound parameters.
237+
- **RBAC enforcement**: Role-based access control is enforced at the routing level before any database operations.
209238

210239
---
211240

src/ApiGenerator.php

Lines changed: 99 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,119 @@ public function __construct(PDO $pdo)
1616
}
1717

1818
/**
19-
* Enhanced list: supports filtering, sorting, pagination.
19+
* Enhanced list: supports filtering, sorting, pagination, field selection.
2020
*/
2121
public function list(string $table, array $opts = []): array
2222
{
2323
$columns = $this->inspector->getColumns($table);
2424
$colNames = array_column($columns, 'Field');
2525

26+
// --- Field Selection ---
27+
$selectedFields = '*';
28+
if (!empty($opts['fields'])) {
29+
$requestedFields = array_map('trim', explode(',', $opts['fields']));
30+
$validFields = array_filter($requestedFields, fn($f) => in_array($f, $colNames, true));
31+
if (!empty($validFields)) {
32+
$selectedFields = implode(', ', array_map(fn($f) => "`$f`", $validFields));
33+
}
34+
}
35+
2636
// --- Filtering ---
2737
$where = [];
2838
$params = [];
39+
$paramCounter = 0; // To handle duplicate column filters
2940
if (!empty($opts['filter'])) {
30-
// Example filter: ['name:Alice', 'email:gmail.com']
41+
// Example filter: ['name:eq:Alice', 'age:gt:18', 'email:like:%gmail.com']
3142
$filters = explode(',', $opts['filter']);
3243
foreach ($filters as $f) {
33-
$parts = explode(':', $f, 2);
34-
if (count($parts) === 2 && in_array($parts[0], $colNames, true)) {
44+
$parts = explode(':', $f, 3);
45+
if (count($parts) === 2) {
46+
// Backward compatibility: col:value means col = value
3547
$col = $parts[0];
3648
$val = $parts[1];
37-
// Use LIKE for partial match, = for exact
38-
if (str_contains($val, '%')) {
39-
$where[] = "`$col` LIKE :$col";
40-
$params[$col] = $val;
41-
} else {
42-
$where[] = "`$col` = :$col";
43-
$params[$col] = $val;
49+
if (in_array($col, $colNames, true)) {
50+
if (str_contains($val, '%')) {
51+
$paramKey = "{$col}_{$paramCounter}";
52+
$where[] = "`$col` LIKE :$paramKey";
53+
$params[$paramKey] = $val;
54+
$paramCounter++;
55+
} else {
56+
$paramKey = "{$col}_{$paramCounter}";
57+
$where[] = "`$col` = :$paramKey";
58+
$params[$paramKey] = $val;
59+
$paramCounter++;
60+
}
61+
}
62+
} elseif (count($parts) === 3 && in_array($parts[0], $colNames, true)) {
63+
// New format: col:operator:value
64+
$col = $parts[0];
65+
$operator = strtolower($parts[1]);
66+
$val = $parts[2];
67+
$paramKey = "{$col}_{$paramCounter}";
68+
69+
switch ($operator) {
70+
case 'eq':
71+
$where[] = "`$col` = :$paramKey";
72+
$params[$paramKey] = $val;
73+
break;
74+
case 'neq':
75+
case 'ne':
76+
$where[] = "`$col` != :$paramKey";
77+
$params[$paramKey] = $val;
78+
break;
79+
case 'gt':
80+
$where[] = "`$col` > :$paramKey";
81+
$params[$paramKey] = $val;
82+
break;
83+
case 'gte':
84+
case 'ge':
85+
$where[] = "`$col` >= :$paramKey";
86+
$params[$paramKey] = $val;
87+
break;
88+
case 'lt':
89+
$where[] = "`$col` < :$paramKey";
90+
$params[$paramKey] = $val;
91+
break;
92+
case 'lte':
93+
case 'le':
94+
$where[] = "`$col` <= :$paramKey";
95+
$params[$paramKey] = $val;
96+
break;
97+
case 'like':
98+
$where[] = "`$col` LIKE :$paramKey";
99+
$params[$paramKey] = $val;
100+
break;
101+
case 'in':
102+
// Support for IN operator: col:in:val1|val2|val3
103+
$values = explode('|', $val);
104+
$placeholders = [];
105+
foreach ($values as $i => $v) {
106+
$inParamKey = "{$paramKey}_in_{$i}";
107+
$placeholders[] = ":$inParamKey";
108+
$params[$inParamKey] = $v;
109+
}
110+
$where[] = "`$col` IN (" . implode(',', $placeholders) . ")";
111+
break;
112+
case 'notin':
113+
case 'nin':
114+
// Support for NOT IN operator: col:notin:val1|val2|val3
115+
$values = explode('|', $val);
116+
$placeholders = [];
117+
foreach ($values as $i => $v) {
118+
$inParamKey = "{$paramKey}_nin_{$i}";
119+
$placeholders[] = ":$inParamKey";
120+
$params[$inParamKey] = $v;
121+
}
122+
$where[] = "`$col` NOT IN (" . implode(',', $placeholders) . ")";
123+
break;
124+
case 'null':
125+
$where[] = "`$col` IS NULL";
126+
break;
127+
case 'notnull':
128+
$where[] = "`$col` IS NOT NULL";
129+
break;
44130
}
131+
$paramCounter++;
45132
}
46133
}
47134
}
@@ -73,7 +160,7 @@ public function list(string $table, array $opts = []): array
73160
$offset = ($page - 1) * $pageSize;
74161
$limit = "LIMIT $pageSize OFFSET $offset";
75162

76-
$sql = "SELECT * FROM `$table`";
163+
$sql = "SELECT $selectedFields FROM `$table`";
77164
if ($where) {
78165
$sql .= ' WHERE ' . implode(' AND ', $where);
79166
}

src/Router.php

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ public function route(array $query)
8383

8484
case 'columns':
8585
if (isset($query['table'])) {
86+
if (!Validator::validateTableName($query['table'])) {
87+
http_response_code(400);
88+
echo json_encode(['error' => 'Invalid table name']);
89+
break;
90+
}
8691
$this->enforceRbac('read', $query['table']);
8792
echo json_encode($this->inspector->getColumns($query['table']));
8893
} else {
@@ -93,13 +98,25 @@ public function route(array $query)
9398

9499
case 'list':
95100
if (isset($query['table'])) {
101+
if (!Validator::validateTableName($query['table'])) {
102+
http_response_code(400);
103+
echo json_encode(['error' => 'Invalid table name']);
104+
break;
105+
}
96106
$this->enforceRbac('list', $query['table']);
97107
$opts = [
98108
'filter' => $query['filter'] ?? null,
99109
'sort' => $query['sort'] ?? null,
100-
'page' => $query['page'] ?? 1,
101-
'page_size' => $query['page_size'] ?? 20,
110+
'page' => Validator::validatePage($query['page'] ?? 1),
111+
'page_size' => Validator::validatePageSize($query['page_size'] ?? 20),
112+
'fields' => $query['fields'] ?? null,
102113
];
114+
// Validate sort if provided
115+
if (isset($opts['sort']) && !Validator::validateSort($opts['sort'])) {
116+
http_response_code(400);
117+
echo json_encode(['error' => 'Invalid sort parameter']);
118+
break;
119+
}
103120
echo json_encode($this->api->list($query['table'], $opts));
104121
} else {
105122
http_response_code(400);
@@ -109,6 +126,16 @@ public function route(array $query)
109126

110127
case 'read':
111128
if (isset($query['table'], $query['id'])) {
129+
if (!Validator::validateTableName($query['table'])) {
130+
http_response_code(400);
131+
echo json_encode(['error' => 'Invalid table name']);
132+
break;
133+
}
134+
if (!Validator::validateId($query['id'])) {
135+
http_response_code(400);
136+
echo json_encode(['error' => 'Invalid id parameter']);
137+
break;
138+
}
112139
$this->enforceRbac('read', $query['table']);
113140
echo json_encode($this->api->read($query['table'], $query['id']));
114141
} else {
@@ -123,6 +150,11 @@ public function route(array $query)
123150
echo json_encode(['error' => 'Method Not Allowed']);
124151
break;
125152
}
153+
if (!isset($query['table']) || !Validator::validateTableName($query['table'])) {
154+
http_response_code(400);
155+
echo json_encode(['error' => 'Invalid or missing table parameter']);
156+
break;
157+
}
126158
$this->enforceRbac('create', $query['table']);
127159
$data = $_POST;
128160
if (empty($data) && strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === 0) {
@@ -137,6 +169,16 @@ public function route(array $query)
137169
echo json_encode(['error' => 'Method Not Allowed']);
138170
break;
139171
}
172+
if (!isset($query['table']) || !Validator::validateTableName($query['table'])) {
173+
http_response_code(400);
174+
echo json_encode(['error' => 'Invalid or missing table parameter']);
175+
break;
176+
}
177+
if (!isset($query['id']) || !Validator::validateId($query['id'])) {
178+
http_response_code(400);
179+
echo json_encode(['error' => 'Invalid or missing id parameter']);
180+
break;
181+
}
140182
$this->enforceRbac('update', $query['table']);
141183
$data = $_POST;
142184
if (empty($data) && strpos($_SERVER['CONTENT_TYPE'] ?? '', 'application/json') === 0) {
@@ -147,6 +189,16 @@ public function route(array $query)
147189

148190
case 'delete':
149191
if (isset($query['table'], $query['id'])) {
192+
if (!Validator::validateTableName($query['table'])) {
193+
http_response_code(400);
194+
echo json_encode(['error' => 'Invalid table name']);
195+
break;
196+
}
197+
if (!Validator::validateId($query['id'])) {
198+
http_response_code(400);
199+
echo json_encode(['error' => 'Invalid id parameter']);
200+
break;
201+
}
150202
$this->enforceRbac('delete', $query['table']);
151203
echo json_encode($this->api->delete($query['table'], $query['id']));
152204
} else {

0 commit comments

Comments
 (0)