99namespace Tina4 ;
1010
1111use 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 ;
0 commit comments