@@ -32,6 +32,22 @@ class Builder
3232 */
3333 protected array $ orders = [];
3434
35+ /**
36+ * Columns that are allowed to be used in ORDER BY clauses.
37+ *
38+ * If empty, a conservative identifier pattern will be used instead.
39+ *
40+ * @var string[]
41+ */
42+ protected array $ allowedOrderColumns = [];
43+
44+ /**
45+ * Columns that are safe to sort on via orderBy().
46+ *
47+ * @var string[] $sortable
48+ */
49+ protected array $ sortable = [];
50+
3551 protected ?int $ limit = null ;
3652 protected ?int $ offset = null ;
3753
@@ -40,7 +56,7 @@ public function __construct(PDO $pdo, string $table, ?string $modelClass = null,
4056 $ this ->pdo = $ pdo ;
4157 $ this ->table = $ table ;
4258 $ this ->modelClass = $ modelClass ;
43-
59+
4460 // Store connection for driver access
4561 // If not provided, try to get it from a static connection if available
4662 if ($ connection === null ) {
@@ -64,7 +80,7 @@ protected function createConnectionFromPdo(PDO $pdo): Connection
6480 $ pdoProperty = $ connection ->getProperty ('pdo ' );
6581 $ pdoProperty ->setAccessible (true );
6682 $ pdoProperty ->setValue ($ instance , $ pdo );
67-
83+
6884 return $ instance ;
6985 }
7086
@@ -95,7 +111,7 @@ public function __call(string $method, array $parameters): self
95111
96112 /**
97113 * Specify relationships to eager load.
98- *
114+ *
99115 * Example:
100116 * User::query()->with('posts', 'profile')->get()
101117 * User::query()->with(['posts', 'profile'])->get()
@@ -146,20 +162,44 @@ public function orWhere(string $column, string $operator, mixed $value = null):
146162
147163 public function whereIn (string $ column , array $ values ): self
148164 {
149- $ this ->wheres [] = ['AND ' , $ column , 'IN ' , $ values ];
165+ // empty values for IN, condition should match nothing but sql must still be valid
166+ if (empty ($ values )) {
167+ $ this ->wheres [] = ['AND ' , '1 = 0 ' , 'RAW ' , null ];
168+
169+ return $ this ;
170+ }
171+
172+
173+ $ this ->wheres [] = ['AND ' , $ column , 'IN ' , array_values ($ values )];
150174 return $ this ;
151175 }
152176
153177 public function whereNotIn (string $ column , array $ values ): self
154178 {
155- $ this ->wheres [] = ['AND ' , $ column , 'NOT IN ' , $ values ];
179+ // Empty NOT IN matches everything (no restrictions).
180+ if (empty ($ values )) {
181+ // we can safely ignore it
182+ return $ this ;
183+ }
184+ $ this ->wheres [] = ['AND ' , $ column , 'NOT IN ' , array_values ($ values )];
185+ return $ this ;
186+ }
187+
188+ public function whereRaw (string $ sql , string $ boolean = 'AND ' ): self
189+ {
190+ $ this ->wheres [] = [$ boolean , $ sql , 'RAW ' , null ];
191+
156192 return $ this ;
157193 }
158194
159-
195+
160196
161197 public function orderBy (string $ column , string $ direction = 'ASC ' ): self
162198 {
199+ if (! $ this ->isAllowedOrderColumn ($ column )) {
200+ throw new \InvalidArgumentException ("Invalid order by column: {$ column }" );
201+ }
202+
163203 $ direction = strtoupper ($ direction );
164204 if (!in_array ($ direction , ['ASC ' , 'DESC ' ], true )) {
165205 $ direction = 'ASC ' ;
@@ -181,11 +221,58 @@ public function offset(int $offset): self
181221 return $ this ;
182222 }
183223
224+ /**
225+ * Determine if the given column is allowed in ORDER BY clauses.
226+ *
227+ * @param string $column
228+ * @return bool
229+ */
230+ protected function isAllowedOrderColumn (string $ column ): bool
231+ {
232+ // If an explicit allowlist has been set, enforce it strictly.
233+ if (!empty ($ this ->allowedOrderColumns )) {
234+ return in_array ($ column , $ this ->allowedOrderColumns , true );
235+ }
236+
237+ // Fallback: allow only simple identifiers (no spaces, commas, operators, etc.)
238+ // This blocks payloads like "name; DROP TABLE users" or "name DESC, (SELECT ...)".
239+ return (bool ) preg_match ('/^[A-Za-z0-9_]+$/ ' , $ column );
240+ }
241+
242+ /**
243+ * Optionally set an explicit allowlist of sortable columns.
244+ *
245+ * @param string[] $columns
246+ * @return $this
247+ */
248+ public function setAllowedOrderColumns (array $ columns ): self
249+ {
250+ $ this ->allowedOrderColumns = array_values (array_unique ($ columns ));
251+
252+ return $ this ;
253+ }
254+
255+
256+ /**
257+ * Get new rows for the current query without model hydration.
258+ *
259+ * @return array<int, array<string, mixed>>
260+ */
261+ public function getRows (): array
262+ {
263+ [$ sql , $ bindings ] = $ this ->compileSelect ();
264+
265+ $ stmt = $ this ->pdo ->prepare ($ sql );
266+ $ stmt ->execute ($ bindings );
267+
268+ return $ stmt ->fetchAll ();
269+ }
270+
184271 protected function compileSelect (): array
185272 {
186273 $ driver = $ this ->connection ->getDriver ();
187274 $ quotedTable = $ driver ->quoteIdentifier ($ this ->table );
188-
275+
189276 $ sql = 'SELECT * FROM ' . $ quotedTable ;
190277 $ bindings = [];
191278
@@ -257,5 +344,5 @@ public function toSql(): string
257344 return $ sql ;
258345 }
259346
260-
347+
261348}
0 commit comments