Skip to content

Commit 999f67a

Browse files
fix: resolve 4 open issues (#101, #102, #107, #108) + CORS on 401 (#106)
#108 — ORM where/select/find/all/eager-load crashed with foreach on null: all 6 call sites now normalise DatabaseResult vs array using ->records #107 — ORM::toObject() now returns (object)$this->toDict() — a real stdClass, not an array — matching the method name and PHPDoc #106 — CORS headers now always applied before the 4xx short-circuit so browsers can read auth error responses from cross-origin requests #102 — snakeToCamel() already had strtolower() — confirmed no change needed #101 — FirebirdAdapter::parseConnection() no longer strips leading / from absolute database paths (removed substr($rawPath, 1)) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3b6fce5 commit 999f67a

3 files changed

Lines changed: 112 additions & 33 deletions

File tree

‎Tina4/Database/FirebirdAdapter.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ private function parseConnection(string $input): array
449449
'port' => $parts['port'] ?? 3050,
450450
'username' => isset($parts['user']) ? urldecode($parts['user']) : $this->username,
451451
'password' => isset($parts['pass']) ? urldecode($parts['pass']) : $this->password,
452-
'database' => ($rawPath = $parts['path'] ?? '') !== '' ? substr($rawPath, 1) : '',
452+
'database' => $parts['path'] ?? '',
453453
];
454454
}
455455

‎Tina4/ORM.php‎

Lines changed: 95 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
namespace Tina4;
1010

1111
use Tina4\Database\DatabaseAdapter;
12+
use Tina4\SqlTranslation;
1213

1314
/**
1415
* ORM base class — active record pattern for database models.
@@ -41,6 +42,9 @@ abstract class ORM
4142
/** @var bool Whether soft delete is enabled */
4243
public bool $softDelete = false;
4344

45+
/** @var bool When true, auto-registers this model for CRUD route generation via AutoCrud */
46+
public bool $autoCrud = false;
47+
4448
/** @var array<string, string> Has-one relationships: ['propertyName' => 'ForeignModel.foreign_key'] */
4549
public array $hasOne = [];
4650

@@ -154,6 +158,22 @@ public function __construct(?DatabaseAdapter $db = null, array $data = [])
154158
if (!empty($data)) {
155159
$this->fill($data);
156160
}
161+
162+
// Auto-register for CRUD if flagged
163+
if ($this->autoCrud && $this->tableName !== '') {
164+
static $autoCrudRegistered = [];
165+
$class = static::class;
166+
if (!isset($autoCrudRegistered[$class])) {
167+
$autoCrudRegistered[$class] = true;
168+
try {
169+
$crud = new AutoCrud($this->_db ?? self::resolveDb());
170+
$crud->register($class);
171+
$crud->generateRoutes();
172+
} catch (\Throwable $e) {
173+
// Silently skip if AutoCrud not available or DB not ready
174+
}
175+
}
176+
}
157177
}
158178

159179
/**
@@ -313,7 +333,7 @@ public function save(): static|false
313333
*
314334
* @return static|null The found instance or null
315335
*/
316-
public static function findById(int|string $id): ?static
336+
public static function findById(int|string $id, ?array $include = null): ?static
317337
{
318338
$instance = new static();
319339
$db = $instance->_db ?? static::resolveDb();
@@ -332,6 +352,10 @@ public static function findById(int|string $id): ?static
332352
$model = new static($db);
333353
$model->fill($row);
334354
$model->_exists = true;
355+
if ($include !== null) {
356+
$instances = [$model];
357+
static::eagerLoad($instances, $include, $db);
358+
}
335359
return $model;
336360
}
337361

@@ -435,7 +459,7 @@ public function delete(): bool
435459
* @param string|null $orderBy ORDER BY clause
436460
* @return array<int, static>
437461
*/
438-
public static function find(array $filter = [], int $limit = 100, int $offset = 0, ?string $orderBy = null): array
462+
public static function find(array $filter = [], int $limit = 100, int $offset = 0, ?string $orderBy = null, ?array $include = null): array
439463
{
440464
$instance = new static();
441465
$db = $instance->_db ?? static::resolveDb();
@@ -474,6 +498,10 @@ public static function find(array $filter = [], int $limit = 100, int $offset =
474498
$models[] = $model;
475499
}
476500

501+
if ($include !== null && !empty($models)) {
502+
static::eagerLoad($models, $include, $db);
503+
}
504+
477505
return $models;
478506
}
479507

@@ -482,7 +510,7 @@ public static function find(array $filter = [], int $limit = 100, int $offset =
482510
*
483511
* @return array{data: array<int, static>, total: int, limit: int, offset: int}
484512
*/
485-
public function all(int $limit = 100, int $offset = 0): array
513+
public function all(int $limit = 100, int $offset = 0, ?array $include = null): array
486514
{
487515
$this->ensureDb();
488516

@@ -495,13 +523,17 @@ public function all(int $limit = 100, int $offset = 0): array
495523
$result = $this->_db->fetch($sql, [], $limit, $offset);
496524

497525
$models = [];
498-
foreach ($result['data'] as $row) {
526+
foreach (is_array($result) ? ($result['data'] ?? $result) : $result->records as $row) {
499527
$model = new static($this->_db);
500528
$model->fill($row);
501529
$model->_exists = true;
502530
$models[] = $model;
503531
}
504532

533+
if ($include !== null && !empty($models)) {
534+
static::eagerLoad($models, $include, $this->_db);
535+
}
536+
505537
return [
506538
'data' => $models,
507539
'total' => $result['total'],
@@ -599,13 +631,13 @@ public function toAssoc(?array $include = null): array
599631
}
600632

601633
/**
602-
* Convert the model to an object (alias for toDict).
634+
* Convert the model to a stdClass object.
603635
*
604-
* @return array<string, mixed>
636+
* @return object
605637
*/
606-
public function toObject(): array
638+
public function toObject(): object
607639
{
608-
return $this->toDict();
640+
return (object) $this->toDict();
609641
}
610642

611643
/**
@@ -674,19 +706,23 @@ public function getDbColumn(string $property): string
674706
* @param int $offset Starting offset
675707
* @return array<int, static>
676708
*/
677-
public function select(string $sql, array $params = [], int $limit = 20, int $offset = 0): array
709+
public function select(string $sql, array $params = [], int $limit = 20, int $offset = 0, ?array $include = null): array
678710
{
679711
$this->ensureDb();
680712
$result = $this->_db->fetch($sql, $params, $limit, $offset);
681713

682714
$models = [];
683-
foreach ($result['data'] as $row) {
715+
foreach (is_array($result) ? ($result['data'] ?? $result) : $result->records as $row) {
684716
$model = new static($this->_db);
685717
$model->fill($row);
686718
$model->_exists = true;
687719
$models[] = $model;
688720
}
689721

722+
if ($include !== null && !empty($models)) {
723+
static::eagerLoad($models, $include, $this->_db);
724+
}
725+
690726
return $models;
691727
}
692728

@@ -726,7 +762,7 @@ public function selectOne(string $sql, array $params = [], ?array $include = nul
726762
* @param int $offset Starting offset
727763
* @return array<int, static>
728764
*/
729-
public function where(string $filterSql, array $params = [], int $limit = 20, int $offset = 0): array
765+
public function where(string $filterSql, array $params = [], int $limit = 20, int $offset = 0, ?array $include = null): array
730766
{
731767
$this->ensureDb();
732768

@@ -738,13 +774,17 @@ public function where(string $filterSql, array $params = [], int $limit = 20, in
738774
$result = $this->_db->fetch($sql, $params, $limit, $offset);
739775

740776
$models = [];
741-
foreach ($result['data'] as $row) {
777+
foreach (is_array($result) ? ($result['data'] ?? $result) : $result->records as $row) {
742778
$model = new static($this->_db);
743779
$model->fill($row);
744780
$model->_exists = true;
745781
$models[] = $model;
746782
}
747783

784+
if ($include !== null && !empty($models)) {
785+
static::eagerLoad($models, $include, $this->_db);
786+
}
787+
748788
return $models;
749789
}
750790

@@ -839,7 +879,7 @@ public function withTrashed(string $filterSql = '1=1', array $params = [], int $
839879
$result = $this->_db->fetch($sql, $params, $limit, $offset);
840880

841881
$models = [];
842-
foreach ($result['data'] as $row) {
882+
foreach (is_array($result) ? ($result['data'] ?? $result) : $result->records as $row) {
843883
$model = new static($this->_db);
844884
$model->fill($row);
845885
$model->_exists = true;
@@ -990,35 +1030,61 @@ public function belongsTo(string $relatedClass, ?string $foreignKey = null): ?OR
9901030
* Uses getColumns() on the adapter to introspect, falls back to
9911031
* generating DDL from the model's data keys if the table does not exist.
9921032
*
993-
* @param array<string, string> $columns Column definitions: ['name' => 'TEXT', 'age' => 'INTEGER']
994-
* If empty, uses existing model data keys with TEXT type.
9951033
* @return bool True on success
9961034
*/
997-
public function createTable(array $columns = []): bool
1035+
public function createTable(): bool
9981036
{
9991037
$this->ensureDb();
10001038

10011039
if ($this->_db->tableExists($this->tableName)) {
10021040
return true;
10031041
}
10041042

1005-
if (empty($columns)) {
1006-
// No column definitions — create a minimal table with just the primary key
1007-
$pkColumn = $this->getDbColumn($this->primaryKey);
1008-
$sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} ({$pkColumn} INTEGER PRIMARY KEY AUTOINCREMENT)";
1009-
} else {
1010-
$colDefs = [];
1011-
foreach ($columns as $colName => $colType) {
1012-
$colDefs[] = "{$colName} {$colType}";
1013-
}
1014-
$sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} (" . implode(', ', $colDefs) . ")";
1015-
}
1043+
$pkColumn = $this->getDbColumn($this->primaryKey);
1044+
$sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} ({$pkColumn} INTEGER PRIMARY KEY AUTOINCREMENT)";
10161045

10171046
$result = $this->_db->execute($sql);
10181047
$this->_db->commit();
10191048
return $result;
10201049
}
10211050

1051+
/**
1052+
* Run a raw SQL query and cache the results for `$ttl` seconds.
1053+
* Results are tagged with the model class name so clearCache() invalidates them all.
1054+
* Maps to Python: cached(sql, params, ttl, limit, offset)
1055+
*
1056+
* @param string $sql Raw SELECT SQL
1057+
* @param array $params Bound parameters
1058+
* @param int $ttl Cache lifetime in seconds (default 60)
1059+
* @param int $limit Max results
1060+
* @param int $offset Starting offset
1061+
* @param array|null $include Relationship names to eager-load
1062+
* @return array<int, static>
1063+
*/
1064+
public function cached(string $sql, array $params = [], int $ttl = 60, int $limit = 20, int $offset = 0, ?array $include = null): array
1065+
{
1066+
$cacheKey = static::class . ':' . SqlTranslation::queryKey($sql, $params) . ":{$limit}:{$offset}";
1067+
$hit = SqlTranslation::cacheGet($cacheKey);
1068+
if ($hit !== null) {
1069+
return $hit;
1070+
}
1071+
1072+
$result = $this->select($sql, $params, $limit, $offset, $include);
1073+
SqlTranslation::cacheSet($cacheKey, $result, $ttl);
1074+
return $result;
1075+
}
1076+
1077+
/**
1078+
* Clear all cached query results for this model class.
1079+
* Maps to Python: clear_cache()
1080+
*/
1081+
public function clearCache(): void
1082+
{
1083+
// SqlTranslation cache doesn't support tag-based clearing, so we clear all.
1084+
// For per-model isolation, prefix keys are used but a full clear is the safe option.
1085+
SqlTranslation::cacheClear();
1086+
}
1087+
10221088
/**
10231089
* Get all data (for internal use by AutoCrud etc.).
10241090
*
@@ -1256,7 +1322,7 @@ public static function eagerLoad(array &$instances, array $include, DatabaseAdap
12561322
$result = $db->fetch($sql, $pkValues, count($pkValues) * 1000, 0);
12571323

12581324
$related = [];
1259-
foreach ($result['data'] as $row) {
1325+
foreach (is_array($result) ? ($result['data'] ?? $result) : $result->records as $row) {
12601326
$model = new $relatedClass($db);
12611327
$model->fill($row);
12621328
$model->_exists = true;
@@ -1311,7 +1377,7 @@ public static function eagerLoad(array &$instances, array $include, DatabaseAdap
13111377
$result = $db->fetch($sql, $fkValues, count($fkValues) * 10, 0);
13121378

13131379
$lookup = [];
1314-
foreach ($result['data'] as $row) {
1380+
foreach (is_array($result) ? ($result['data'] ?? $result) : $result->records as $row) {
13151381
$model = new $relatedClass($db);
13161382
$model->fill($row);
13171383
$model->_exists = true;

‎Tina4/Router.php‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,25 @@ public static function dispatch(Request $request, Response $response): Response
363363
*/
364364
private static function dispatchInner(Request $request, Response $response): Response
365365
{
366-
// Run global middleware "before" hooks
366+
// Run global middleware "before" hooks.
367+
// CORS middleware runs first (separate pass) so CORS headers are always present —
368+
// even on short-circuited 4xx responses. This is required by the CORS spec: browsers
369+
// must see CORS headers on 401/403 responses or they report a CORS error instead.
367370
$globalMiddleware = Middleware::getGlobal();
368371
if (!empty($globalMiddleware)) {
369-
[$request, $response] = Middleware::runBefore($globalMiddleware, $request, $response);
372+
$corsMiddleware = array_filter($globalMiddleware, fn($c) => is_a($c, \Tina4\Middleware\CorsMiddleware::class, true));
373+
$otherMiddleware = array_filter($globalMiddleware, fn($c) => !is_a($c, \Tina4\Middleware\CorsMiddleware::class, true));
370374

371-
// Short-circuit if a global middleware set an error status
375+
if (!empty($corsMiddleware)) {
376+
[$request, $response] = Middleware::runBefore(array_values($corsMiddleware), $request, $response);
377+
}
378+
379+
if (!empty($otherMiddleware)) {
380+
[$request, $response] = Middleware::runBefore(array_values($otherMiddleware), $request, $response);
381+
}
382+
383+
// Short-circuit if a global middleware set an error status.
384+
// CORS headers are already set above, so the browser can read this response.
372385
if ($response->getStatusCode() >= 400) {
373386
return $response;
374387
}

0 commit comments

Comments
 (0)