Skip to content

Commit 3c93068

Browse files
Add ORM tests, strengthen fakes, fix migrator notice, update changelog
1 parent aa01a49 commit 3c93068

18 files changed

Lines changed: 741 additions & 8 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
All significant changes to this project will be documented in this file.
44

5+
## [1.4.0] - 2025-12-10
6+
7+
### Added
8+
9+
- **ORM test coverage:** Added PHPUnit tests for Model, ModelQuery, ModelCollection, SoftDeletes, and all relation types; seeded fake profiles/roles for relation scenarios.
10+
- **In-memory fakes:** FakeConnection now exposes table storage; FakeQueryBuilder emulates where/null filters, pivot joins, pagination counters, and CRUD without PDO.
11+
12+
### Fixed
13+
14+
- Eliminated PHPUnit notice in Migrator tests by stubbing `MigrationPathResolver`.
15+
- Fake query builder signatures now align with `QueryBuilderInterface` (return types, count/exists) to avoid compatibility errors during tests.
16+
517
## [1.3.0] – 2025-12-10
618

719
### Added
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
<?php
22

3-
namespace Codemonster\Database\Tests;
3+
namespace Codemonster\Database\Tests\Fakes;
44

55
use Codemonster\Database\Contracts\ConnectionInterface;
6-
use Codemonster\Database\Query\QueryBuilder;
6+
use Codemonster\Database\Contracts\QueryBuilderInterface;
77
use Codemonster\Database\Schema\Schema;
88
use PDO;
9+
use Codemonster\Database\Tests\Fakes\FakeQueryBuilder;
910

1011
class FakeConnection implements ConnectionInterface
1112
{
1213
public array $executed = [];
1314
public array $migrations = [];
15+
public array $tables = [];
1416

1517
public bool $transactionStarted = false;
1618
public bool $transactionCommitted = false;
@@ -114,9 +116,9 @@ public function transaction(callable $callback): mixed
114116
}
115117
}
116118

117-
public function table(string $table): QueryBuilder
119+
public function table(string $table): QueryBuilderInterface
118120
{
119-
return new QueryBuilder($this, $table);
121+
return new FakeQueryBuilder($this, $table);
120122
}
121123

122124
public function schema(): Schema

tests/Fakes/FakeModels/Pivot.php

Whitespace-only changes.

tests/Fakes/FakeModels/Post.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Codemonster\Database\Tests\Fakes\FakeModels;
4+
5+
use Codemonster\Database\ORM\Model;
6+
7+
class Post extends Model
8+
{
9+
protected string $table = 'posts';
10+
protected array $guarded = [];
11+
protected array $fillable = ['id', 'title', 'user_id'];
12+
13+
public function author()
14+
{
15+
return $this->belongsTo(User::class, 'user_id');
16+
}
17+
}

tests/Fakes/FakeModels/Profile.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Codemonster\Database\Tests\Fakes\FakeModels;
4+
5+
use Codemonster\Database\ORM\Model;
6+
7+
class Profile extends Model
8+
{
9+
protected string $table = 'profiles';
10+
protected array $guarded = [];
11+
protected array $fillable = ['id', 'user_id', 'bio'];
12+
}

tests/Fakes/FakeModels/Role.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Codemonster\Database\Tests\Fakes\FakeModels;
4+
5+
use Codemonster\Database\ORM\Model;
6+
7+
class Role extends Model
8+
{
9+
protected string $table = 'roles';
10+
protected array $guarded = [];
11+
protected array $fillable = ['id', 'name'];
12+
13+
public function users()
14+
{
15+
return $this->belongsToMany(User::class);
16+
}
17+
}

tests/Fakes/FakeModels/User.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Codemonster\Database\Tests\Fakes\FakeModels;
4+
5+
use Codemonster\Database\ORM\Model;
6+
7+
class User extends Model
8+
{
9+
protected string $table = 'users';
10+
protected array $guarded = [];
11+
protected array $fillable = ['id', 'name', 'email', 'deleted_at'];
12+
13+
public function posts()
14+
{
15+
return $this->hasMany(Post::class);
16+
}
17+
18+
public function role()
19+
{
20+
return $this->belongsTo(Role::class);
21+
}
22+
23+
public function profile()
24+
{
25+
return $this->hasOne(Profile::class);
26+
}
27+
28+
public function roles()
29+
{
30+
return $this->belongsToMany(Role::class);
31+
}
32+
}

tests/Fakes/FakeQueryBuilder.php

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
<?php
2+
3+
namespace Codemonster\Database\Tests\Fakes;
4+
5+
use Codemonster\Database\Query\QueryBuilder;
6+
7+
class FakeQueryBuilder extends QueryBuilder
8+
{
9+
public FakeConnection $fake;
10+
protected array $wheres = [];
11+
protected array $joins = [];
12+
protected ?int $limit = null;
13+
protected ?int $offset = null;
14+
15+
public function __construct(FakeConnection $fake, string $table)
16+
{
17+
$this->fake = $fake;
18+
parent::__construct($fake, $table);
19+
}
20+
21+
public function select(string|array ...$columns): static
22+
{
23+
// selection is ignored in fake builder (we always return full rows)
24+
return $this;
25+
}
26+
27+
public function where(string|callable $column, mixed $operator = null, mixed $value = null, string $boolean = 'AND'): static
28+
{
29+
if (is_callable($column)) {
30+
// not needed for fake tests
31+
return $this;
32+
}
33+
34+
if (func_num_args() === 2) {
35+
$value = $operator;
36+
$operator = '=';
37+
}
38+
39+
$this->wheres[] = [
40+
'column' => $column,
41+
'operator'=> $operator,
42+
'value' => $value,
43+
'boolean' => strtoupper($boolean),
44+
'type' => 'basic',
45+
];
46+
47+
return $this;
48+
}
49+
50+
public function whereNull(string $column, string $boolean = 'AND'): static
51+
{
52+
$this->wheres[] = [
53+
'column' => $column,
54+
'operator' => 'NULL',
55+
'boolean' => strtoupper($boolean),
56+
'type' => 'null',
57+
];
58+
59+
return $this;
60+
}
61+
62+
public function whereNotNull(string $column, string $boolean = 'AND'): static
63+
{
64+
$this->wheres[] = [
65+
'column' => $column,
66+
'operator' => 'NOT_NULL',
67+
'boolean' => strtoupper($boolean),
68+
'type' => 'not_null',
69+
];
70+
71+
return $this;
72+
}
73+
74+
public function join(string $table, string|callable $first, ?string $operator = null, ?string $second = null, string $type = 'INNER'): static
75+
{
76+
if (is_callable($first)) {
77+
return $this;
78+
}
79+
80+
$this->joins[] = [
81+
'table' => $table,
82+
'first' => $first,
83+
'operator' => $operator,
84+
'second' => $second,
85+
'type' => strtoupper($type),
86+
];
87+
88+
return $this;
89+
}
90+
91+
public function get(): array
92+
{
93+
if (!empty($this->joins)) {
94+
return $this->runJoinQuery();
95+
}
96+
97+
$rows = $this->fake->tables[$this->table] ?? [];
98+
99+
$rows = array_values(array_filter($rows, fn($row) => $this->matchesWhere($row)));
100+
101+
if ($this->offset !== null) {
102+
$rows = array_slice($rows, $this->offset);
103+
}
104+
105+
if ($this->limit !== null) {
106+
$rows = array_slice($rows, 0, $this->limit);
107+
}
108+
109+
return array_map(fn($row) => (array) $row, $rows);
110+
}
111+
112+
public function first(): ?array
113+
{
114+
$rows = $this->get();
115+
116+
return $rows[0] ?? null;
117+
}
118+
119+
public function insert(array $values): bool
120+
{
121+
$this->fake->tables[$this->table][] = $values;
122+
123+
return true;
124+
}
125+
126+
public function insertGetId(array $values, $sequence = null): int
127+
{
128+
$current = $this->fake->tables[$this->table] ?? [];
129+
$last = empty($current) ? 0 : ((int) ($current[array_key_last($current)]['id'] ?? count($current)));
130+
131+
$values['id'] = $last + 1;
132+
$this->fake->tables[$this->table][] = $values;
133+
134+
return $values['id'];
135+
}
136+
137+
public function update(array $values): int
138+
{
139+
$updated = 0;
140+
141+
foreach ($this->fake->tables[$this->table] as &$row) {
142+
if ($this->matchesWhere($row)) {
143+
$row = array_merge($row, $values);
144+
$updated++;
145+
}
146+
}
147+
148+
return $updated;
149+
}
150+
151+
public function delete(): int
152+
{
153+
$deleted = 0;
154+
155+
foreach ($this->fake->tables[$this->table] as $idx => $row) {
156+
if ($this->matchesWhere($row)) {
157+
unset($this->fake->tables[$this->table][$idx]);
158+
$deleted++;
159+
}
160+
}
161+
162+
$this->fake->tables[$this->table] = array_values($this->fake->tables[$this->table] ?? []);
163+
164+
return $deleted;
165+
}
166+
167+
public function count(string $column = '*'): int
168+
{
169+
return count($this->get());
170+
}
171+
172+
public function exists(): bool
173+
{
174+
return !empty($this->get());
175+
}
176+
177+
public function limit(int $value): self
178+
{
179+
$this->limit = $value;
180+
181+
return $this;
182+
}
183+
184+
public function offset(int $value): self
185+
{
186+
$this->offset = $value;
187+
188+
return $this;
189+
}
190+
191+
protected function matchesWhere(array $row): bool
192+
{
193+
$result = null;
194+
195+
foreach ($this->wheres as $where) {
196+
$column = str_contains($where['column'], '.')
197+
? explode('.', $where['column'])[1]
198+
: $where['column'];
199+
200+
$current = match ($where['type']) {
201+
'null' => !array_key_exists($column, $row) || $row[$column] === null,
202+
'not_null' => array_key_exists($column, $row) && $row[$column] !== null,
203+
default => ($row[$column] ?? null) == $where['value'],
204+
};
205+
206+
if ($result === null) {
207+
$result = $current;
208+
continue;
209+
}
210+
211+
if ($where['boolean'] === 'OR') {
212+
$result = $result || $current;
213+
} else {
214+
$result = $result && $current;
215+
}
216+
}
217+
218+
return $result ?? true;
219+
}
220+
221+
protected function runJoinQuery(): array
222+
{
223+
// Simplified join handling for belongsToMany (single pivot join).
224+
$join = $this->joins[0];
225+
226+
$pivotTable = $join['table'];
227+
$pivotRows = $this->fake->tables[$pivotTable] ?? [];
228+
229+
$filter = array_values(array_filter(
230+
$this->wheres,
231+
fn($w) => str_starts_with($w['column'], $pivotTable . '.')
232+
));
233+
234+
$relatedIds = [];
235+
foreach ($pivotRows as $pivot) {
236+
if ($filter) {
237+
$cond = $filter[0];
238+
$pivotColumn = str_replace($pivotTable . '.', '', $cond['column']);
239+
240+
if (($pivot[$pivotColumn] ?? null) != $cond['value']) {
241+
continue;
242+
}
243+
}
244+
245+
$pivotRelatedKey = str_replace($pivotTable . '.', '', $join['first']);
246+
$relatedIds[] = $pivot[$pivotRelatedKey] ?? null;
247+
}
248+
249+
$relatedTable = $this->table;
250+
$relatedRows = $this->fake->tables[$relatedTable] ?? [];
251+
252+
$relatedKey = str_contains($join['second'], '.')
253+
? explode('.', $join['second'])[1]
254+
: $join['second'];
255+
256+
$filtered = array_filter(
257+
$relatedRows,
258+
fn($row) => in_array($row[$relatedKey] ?? null, $relatedIds, true)
259+
);
260+
261+
return array_map(fn($row) => (array) $row, array_values($filtered));
262+
}
263+
}

0 commit comments

Comments
 (0)