Skip to content

Commit 4e9de3f

Browse files
authored
Merge pull request #2 from baremetalphp/feat/model-mass-assignment-guard
csrf/seeder/fillable attributes/datamapper
2 parents 06c3723 + dcfd54e commit 4e9de3f

24 files changed

Lines changed: 1354 additions & 87 deletions

phpunit.out

Lines changed: 675 additions & 0 deletions
Large diffs are not rendered by default.

resources/js/App.jsx

Lines changed: 0 additions & 12 deletions
This file was deleted.

src/Application.php

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -181,12 +181,30 @@ protected function resolveParameter(ReflectionParameter $param): mixed
181181

182182
$type = $param->getType();
183183

184-
// If no type or built-in type without default, we can't resolve it
185-
if (! $type || $type->isBuiltin()) {
186-
throw new \RuntimeException("Cannot resolve parameter {$param->getName()} of class " . ($param->getDeclaringClass() ? $param->getDeclaringClass()->getName() : 'unknown'));
184+
// Named, non-union type
185+
if ($type instanceof \ReflectionNamedType) {
186+
// class / interface type -> let container build
187+
if (! $type->isBuiltin()) {
188+
return $this->make($type->getName());
189+
}
190+
191+
// Built-in types (lenient for array/iterable)
192+
$builtin = $type->getName();
193+
194+
if ($builtin === 'array' || $builtin === 'iterable') {
195+
return [];
196+
}
187197
}
188198

189-
return $this->make($type->getName());
199+
$owner = $param->getDeclaringClass()->getName() ?? 'unknown';
200+
201+
throw new \RuntimeException(
202+
sprintf(
203+
'Cannot resolve parameter $%s of %s::__construct(). Either give it a default value or bind it explicitly to the container.',
204+
$param->getName(),
205+
$owner
206+
)
207+
);
190208
}
191209

192210
public function registerProviders(array $providers): void

src/Auth/Auth.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ public static function check(): bool
4141
*/
4242
public static function id(): ?int
4343
{
44-
return Session::get(self::SESSION_KEY);
44+
$userId = Session::get(self::SESSION_KEY);
45+
return $userId ? (int)$userId : null;
4546
}
4647

4748
/**
@@ -50,7 +51,8 @@ public static function id(): ?int
5051
public static function login(User $user): void
5152
{
5253
Session::regenerate();
53-
Session::set(self::SESSION_KEY, (int)$user->getAttribute('id'));
54+
$userId = $user->id ?? $user->getAttribute('id');
55+
Session::set(self::SESSION_KEY, $userId ? (int)$userId : null);
5456
}
5557

5658
/**

src/Database/Builder.php

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

src/Database/Entity.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace BareMetalPHP\Database;
4+
5+
/**
6+
* Base class for Data Mapper entities.
7+
*/
8+
abstract class Entity
9+
{
10+
/**
11+
* Original attribute values as loaded from the DB.
12+
*
13+
* @var array<array,mixed> $original
14+
*/
15+
protected array $original = [];
16+
17+
/**
18+
* Attributers that have been modified since laad/flush.
19+
*
20+
* @var array>string,mixed>
21+
*/
22+
protected array $dirty = [];
23+
24+
/**
25+
* Mark this entity as having been loaded from a given row.
26+
*
27+
* @param array $row
28+
* @return void
29+
*/
30+
public function hydrateFromRow(array $row): void
31+
{
32+
foreach ($row as $key => $value) {
33+
// Assign directly; subclasse can override for casting if needed.
34+
$this->{$key} = $value;
35+
}
36+
37+
$this->original = $row;
38+
$this->dirty = [];
39+
}
40+
41+
/**
42+
* Called by setters / property hooks to mark a field dirty.
43+
*
44+
* @param string $field
45+
* @param mixed $value
46+
* @return void
47+
*/
48+
protected function trackDirty(string $field, mixed $value): void
49+
{
50+
$this->dirty[$field] = $value;
51+
}
52+
53+
/**
54+
* Get all dirty attributes.
55+
*
56+
* @return array<string,mixed>
57+
*/
58+
public function getDirty(): array
59+
{
60+
return $this->dirty;
61+
}
62+
63+
/**
64+
* Get all original attributes as last loaded from the database.
65+
*
66+
* @return array<string,mixed>
67+
*/
68+
public function getOriginal(): array
69+
{
70+
return $this->original;
71+
}
72+
73+
/**
74+
* Mark the entity as clean (after a successful insert/update).
75+
*
76+
* @return void
77+
*/
78+
public function markClean(): void
79+
{
80+
$data = $this->toArray();
81+
$this->original = $data;
82+
$this->dirty = [];
83+
}
84+
85+
/**
86+
* Determine if the entity represents a new row.
87+
*
88+
* @return bool
89+
*/
90+
public function isNew(): bool
91+
{
92+
$pk = static::primaryKey();
93+
return !isset($this->{$pk});
94+
}
95+
96+
/**
97+
* Get the entity attributes as an array.
98+
*
99+
* Default behavior uses geT_object_vars() and strip internal properties.
100+
*
101+
* @return array<string,mixed>
102+
*/
103+
public function toArray(): array
104+
{
105+
$data = get_object_vars($this);
106+
107+
unset($data['original'], $data['dirty']);
108+
109+
return $data;
110+
}
111+
112+
/**
113+
* Get the database table name for this entity.
114+
*
115+
* @return string
116+
*/
117+
abstract public static function table(): string;
118+
119+
/**
120+
* Get the primary key column for this entity.
121+
*
122+
* @return string
123+
*/
124+
abstract public static function primaryKey(): string;
125+
126+
}

0 commit comments

Comments
 (0)