diff --git a/docs/active-record.md b/docs/active-record.md index c5e1ac2..bd3fede 100644 --- a/docs/active-record.md +++ b/docs/active-record.md @@ -33,6 +33,20 @@ class MyClass MyClass::initialize($dbDriver); ``` +After the class is initialized you can use the Active Record to save, update, delete and retrieve the data. +If you call the `initialize` method more than once, it won't have any effect, unless you call the method `reset`. + +It is possible define a Default DBDriver for all classes using the Active Record. + +```php +delete(); ``` +### Refresh a record + +```php +refresh(); +``` + +### Update a model from another model or array + +```php + ### Using the `Query` class ```php diff --git a/src/ORM.php b/src/ORM.php index da81d58..4600910 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -2,10 +2,12 @@ namespace ByJG\MicroOrm; -use InvalidArgumentException; +use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\MicroOrm\Exception\InvalidArgumentException; class ORM { + private static ?DbDriverInterface $dbDriver = null; private static array $relationships = []; /** @@ -176,6 +178,25 @@ public static function clearRelationships(): void { static::$relationships = []; static::$incompleteRelationships = []; + foreach (static::$mapper as $mapper) { + // Reset the ActiveRecord DbDriver + if (method_exists($mapper->getEntity(), 'reset')) { + call_user_func([$mapper->getEntity(), 'reset']); + } + } static::$mapper = []; } + + public static function defaultDbDriver(?DbDriverInterface $dbDriver = null): DbDriverInterface + { + if (is_null($dbDriver)) { + if (is_null(static::$dbDriver)) { + throw new InvalidArgumentException("You must initialize the ORM with a DbDriverInterface"); + } + return static::$dbDriver; + } + + static::$dbDriver = $dbDriver; + return $dbDriver; + } } diff --git a/src/Repository.php b/src/Repository.php index ac6af57..6d10df9 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -211,21 +211,26 @@ public function deleteByQuery(DeleteQuery $updatable): bool * @param bool $forUpdate * @return array */ - public function getByFilter(string|IteratorFilter $filter, array $params = [], bool $forUpdate = false): array + public function getByFilter(string|IteratorFilter $filter = "", array $params = [], bool $forUpdate = false, int $page = 0, ?int $limit = null): array { if ($filter instanceof IteratorFilter) { $formatter = new IteratorFilterSqlFormatter(); $filter = $formatter->getFilter($filter->getRawFilters(), $params); } - - $query = $this->getMapper()->getQuery() - ->where($filter, $params); + $query = $this->getMapper()->getQuery(); + if (!empty($filter)) { + $query->where($filter, $params); + } if ($forUpdate) { $query->forUpdate(); } + if (!is_null($limit)) { + $query->limit($page, ($page + 1) * $limit); + } + return $this->getByQuery($query); } diff --git a/src/Trait/ActiveRecord.php b/src/Trait/ActiveRecord.php index 4173908..2d953bf 100644 --- a/src/Trait/ActiveRecord.php +++ b/src/Trait/ActiveRecord.php @@ -4,9 +4,13 @@ use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; +use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\ORM; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Repository; +use ByJG\Serializer\ObjectCopy; +use ByJG\Serializer\Serialize; trait ActiveRecord { @@ -14,67 +18,135 @@ trait ActiveRecord protected static ?Repository $repository = null; - public static function initialize(DbDriverInterface $dbDriver) + public static function initialize(?DbDriverInterface $dbDriver = null) { + if (!is_null(self::$dbDriver)) { + return; + } + + if (is_null($dbDriver)) { + $dbDriver = ORM::defaultDbDriver(); + } + self::$dbDriver = $dbDriver; + self::$repository = new Repository($dbDriver, self::discoverClass()); + } - self::$repository = new Repository($dbDriver, static::class); + public static function reset(?DbDriverInterface $dbDriver = null) + { + self::$dbDriver = null; + self::$repository = null; + if (!is_null($dbDriver)) { + self::initialize($dbDriver); + } } public static function tableName(): string { + self::initialize(); return self::$repository->getMapper()->getTable(); } public function save() { + self::initialize(); self::$repository->save($this); } - public function delete() + protected function pkList(): array { + self::initialize(); $pk = self::$repository->getMapper()->getPrimaryKeyModel(); $filter = []; foreach ($pk as $field) { + $pkValue = $this->{$field}; + if (empty($pkValue)) { + throw new OrmInvalidFieldsException("Primary key '$field' is null"); + } $filter[] = $this->{$field}; } - self::$repository->delete($filter); + return $filter; } - public static function new(array $data): static + public function delete() + { + self::$repository->delete($this->pkList()); + } + + public static function new(mixed $data = null): static { - return self::$repository->entity($data); + self::initialize(); + $data = $data ?? []; + return self::$repository->entity(Serialize::from($data)->toArray()); } public static function get(mixed ...$pk) { + self::initialize(); return self::$repository->get(...$pk); } + public function fill(mixed $data) + { + $newData = self::new($data)->toArray(); + ObjectCopy::copy($newData, $this); + } + + public function refresh() + { + $this->fill(self::$repository->get(...$this->pkList())); + } + /** * @param IteratorFilter $filter + * @param int $page + * @param int $limit * @return static[] */ - public static function filter(IteratorFilter $filter): array + public static function filter(IteratorFilter $filter, int $page = 0, int $limit = 50): array { - return self::$repository->getByFilter($filter); + self::initialize(); + return self::$repository->getByFilter($filter, page: $page, limit: $limit); + } + + public static function all(int $page = 0, int $limit = 50): array + { + self::initialize(); + return self::$repository->getByFilter(page: $page, limit: $limit); } public static function joinWith(string ...$tables): Query { + self::initialize(); $tables[] = self::$repository->getMapper()->getTable(); return ORM::getQueryInstance(...$tables); } + public function toArray(bool $includeNullValue = false): array + { + if ($includeNullValue) { + return Serialize::from($this)->toArray(); + } + + return Serialize::from($this)->withDoNotParseNullValues()->toArray(); + } + /** * @param Query $query * @return static[] */ public static function query(Query $query): array { + self::initialize(); return self::$repository->getByQuery($query); } + // Override this method to create a custom mapper instead of discovering by attributes in the class + protected static function discoverClass(): string|Mapper + { + return static::class; + } + } \ No newline at end of file diff --git a/src/UpdateQuery.php b/src/UpdateQuery.php index 0ab82ae..7943dab 100644 --- a/src/UpdateQuery.php +++ b/src/UpdateQuery.php @@ -11,6 +11,8 @@ class UpdateQuery extends Updatable { protected array $set = []; + protected array $joinTables = []; + /** * @throws InvalidArgumentException */ @@ -49,6 +51,24 @@ public function set(string $field, int|float|bool|string|LiteralInterface|null $ return $this; } + protected function getJoinTables(DbFunctionsInterface $dbHelper = null): array + { + if (is_null($dbHelper)) { + if (!empty($this->joinTables)) { + throw new InvalidArgumentException('You must specify a DbFunctionsInterface to use join tables'); + } + return ['sql' => '', 'position' => 'before_set']; + } + + return $dbHelper->getJoinTablesUpdate($this->joinTables); + } + + public function join(string $table, string $joinCondition): UpdateQuery + { + $this->joinTables[] = ["table" => $table, "condition" => $joinCondition]; + return $this; + } + /** * @param DbFunctionsInterface|null $dbHelper * @return SqlObject @@ -63,12 +83,17 @@ public function build(DbFunctionsInterface $dbHelper = null): SqlObject $fieldsStr = []; $params = []; foreach ($this->set as $field => $value) { - $fieldName = $field; + $fieldName = explode('.', $field); + $paramName = preg_replace('/[^A-Za-z0-9_]/', '', $fieldName[count($fieldName) - 1]); if (!is_null($dbHelper)) { - $fieldName = $dbHelper->delimiterField($fieldName); + foreach ($fieldName as $key => $item) { + $fieldName[$key] = $dbHelper->delimiterField($item); + } } - $fieldsStr[] = "$fieldName = :$field "; - $params[$field] = $value; + /** @psalm-suppress InvalidArgument $fieldName */ + $fieldName = implode('.', $fieldName); + $fieldsStr[] = "$fieldName = :{$paramName} "; + $params[$paramName] = $value; } $whereStr = $this->getWhere(); @@ -81,8 +106,14 @@ public function build(DbFunctionsInterface $dbHelper = null): SqlObject $tableName = $dbHelper->delimiterTable($tableName); } - $sql = 'UPDATE ' . $tableName . ' SET ' - . implode(', ', $fieldsStr) + $joinTables = $this->getJoinTables($dbHelper); + $joinBeforeSet = $joinTables['position'] === 'before_set' ? $joinTables['sql'] : ''; + $joinAfterSet = $joinTables['position'] === 'after_set' ? $joinTables['sql'] : ''; + + $sql = 'UPDATE ' . $tableName + . $joinBeforeSet + . ' SET ' . implode(', ', $fieldsStr) + . $joinAfterSet . ' WHERE ' . $whereStr[0]; $params = array_merge($params, $whereStr[1]); @@ -100,6 +131,10 @@ public function convert(?DbFunctionsInterface $dbDriver = null): QueryBuilderInt $query->where($item['filter'], $item['params']); } + foreach ($this->joinTables as $joinTable) { + $query->join($joinTable['table'], $joinTable['condition']); + } + return $query; } } diff --git a/src/WhereTrait.php b/src/WhereTrait.php index 2892c77..9c3d89b 100644 --- a/src/WhereTrait.php +++ b/src/WhereTrait.php @@ -44,9 +44,14 @@ protected function getWhere(): ?array /** @psalm-suppress RedundantCondition This is a Trait, and $this->join is defined elsewhere */ if (isset($this->join)) { foreach ($this->join as $item) { - if (!($item['table'] instanceof QueryBasic) && !in_array($item['table'], $tableList) && ORM::getMapper($item['table'])?->isSoftDeleteEnabled() === true) { - $tableList[] = $item['table']; - $where[] = ["filter" => "{$item['table']}.deleted_at is null", "params" => []]; + if ($item['table'] instanceof QueryBasic) { + continue; + } + + $tableName = $item["alias"] ?? $item['table']; + if (!in_array($tableName, $tableList) && ORM::getMapper($item['table'])?->isSoftDeleteEnabled() === true) { + $tableList[] = $tableName; + $where[] = ["filter" => "{$tableName}.deleted_at is null", "params" => []]; } } } diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 67d6093..55e92c5 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -11,6 +11,7 @@ use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\AllowOnlyNewValuesConstraintException; use ByJG\MicroOrm\Exception\InvalidArgumentException; +use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Exception\RepositoryReadOnlyException; use ByJG\MicroOrm\FieldMapping; use ByJG\MicroOrm\InsertQuery; @@ -1261,7 +1262,7 @@ public function testMappingAttributeInsert() $this->assertNull($result[0]->getDeletedAt()); // Check if the updated_at works - sleep(2); + sleep(1); $info->value = 99.5; $infoRepository->save($info); /** @var ModelWithAttributes[] $result2 */ @@ -1448,6 +1449,89 @@ public function testActiveRecordGet() $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM } + public function testActiveRecordRefresh() + { + ActiveRecordModel::initialize($this->dbDriver); + + $this->assertEquals('info', ActiveRecordModel::tableName()); + + /** + * @var ActiveRecordModel $model + */ + $model = ActiveRecordModel::get(3); + + $createdAt = $model->getCreatedAt(); + $updatedAt = $model->getUpdatedAt(); + $deletedAt = $model->getDeletedAt(); + + $this->assertEquals(3, $model->getPk()); + $this->assertEquals(3, $model->iduser); + $this->assertEquals(3.5, $model->value); + $this->assertNull($createdAt); // Because it was not set in the initial insert outside the ORM + $this->assertNull($updatedAt); // Because it was not set in the initial insert outside the ORM + $this->assertNull($deletedAt); // Because it was not set in the initial insert outside the ORM + + // Update the record OUTSIDE the Active Record + $this->dbDriver->execute("UPDATE info SET iduser = 4, property = 44.44 WHERE id = 3"); + + // Check model isn't updated (which is the expected behavior) + $this->assertEquals(3, $model->getPk()); + $this->assertEquals(3, $model->iduser); + $this->assertEquals(3.5, $model->value); + $this->assertEquals($createdAt, $model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertEquals($updatedAt, $model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertEquals($deletedAt, $model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + + // Refresh the model + $model->refresh(); + + // Check model is updated + $this->assertEquals(3, $model->getPk()); + $this->assertEquals(4, $model->iduser); + $this->assertEquals(44.44, $model->value); + $this->assertEquals($createdAt, $model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertEquals($updatedAt, $model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertEquals($deletedAt, $model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + } + + public function testActiveRecordRefreshError() + { + ActiveRecordModel::initialize($this->dbDriver); + + $this->expectException(OrmInvalidFieldsException::class); + $this->expectExceptionMessage("Primary key 'pk' is null"); + + $model = ActiveRecordModel::new(); + $model->refresh(); + } + + public function testActiveRecordFill() + { + ActiveRecordModel::initialize($this->dbDriver); + + $model = ActiveRecordModel::get(3); + + $this->assertEquals(3, $model->getPk()); + $this->assertEquals(3, $model->iduser); + $this->assertEquals(3.5, $model->value); + $this->assertNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + + $model->fill([ + 'iduser' => 4, + 'value' => 44.44 + ]); + + $this->assertEquals(3, $model->getPk()); + $this->assertEquals(4, $model->iduser); + $this->assertEquals(44.44, $model->value); + $this->assertNull($model->getCreatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNull($model->getUpdatedAt()); // Because it was not set in the initial insert outside the ORM + $this->assertNull($model->getDeletedAt()); // Because it was not set in the initial insert outside the ORM + } + + public function testActiveRecordFilter() { ActiveRecordModel::initialize($this->dbDriver); @@ -1470,6 +1554,24 @@ public function testActiveRecordFilter() $this->assertNull($model[1]->getDeletedAt()); // Because it was not set in the initial insert outside the ORM } + public function testActiveRecordEmptyFilter() + { + ActiveRecordModel::initialize($this->dbDriver); + + $model = ActiveRecordModel::filter(new IteratorFilter()); + + $this->assertCount(3, $model); + } + + public function testActiveRecordAll() + { + ActiveRecordModel::initialize($this->dbDriver); + + $model = ActiveRecordModel::all(); + + $this->assertCount(3, $model); + } + public function testActiveRecordNew() { ActiveRecordModel::initialize($this->dbDriver); @@ -1539,4 +1641,25 @@ public function testActiveRecordDelete() $model = ActiveRecordModel::get(3); $this->assertEmpty($model); } + + public function testInitializeActiveRecordDefaultDbDriverError() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("You must initialize the ORM with a DbDriverInterface"); + ActiveRecordModel::initialize(); + } + + public function testInitializeActiveRecordDefaultDbDriver() + { + ORM::defaultDbDriver($this->dbDriver); + ActiveRecordModel::initialize(); + $this->assertTrue(true); + } + + public function testInitializeActiveRecordDefaultDbDriver2() + { + ORM::defaultDbDriver($this->dbDriver); + $model = ActiveRecordModel::get(1); + $this->assertNotNull($model); + } } diff --git a/tests/UpdateQueryTest.php b/tests/UpdateQueryTest.php index ca00530..a32fe7b 100644 --- a/tests/UpdateQueryTest.php +++ b/tests/UpdateQueryTest.php @@ -2,6 +2,8 @@ namespace Tests; +use ByJG\AnyDataset\Db\Helpers\DbMysqlFunctions; +use ByJG\AnyDataset\Db\Helpers\DbPgsqlFunctions; use ByJG\AnyDataset\Db\Helpers\DbSqliteFunctions; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\SqlObject; @@ -114,4 +116,52 @@ public function testQueryUpdatable() ); } + public function testUpdateJoinFail() + { + $this->expectException(InvalidArgumentException::class); + $this->object->table('test'); + $this->object->join('table2', 'table2.id = test.id'); + $this->object->build(); + } + + public function testUpdateJoinMySQl() + { + $this->object->table('test'); + $this->object->join('table2', 'table2.id = test.id'); + $this->object->set('fld1', 'A'); + $this->object->set('fld2', 'B'); + $this->object->set('fld3', 'C'); + $this->object->where('fld1 = :id', ['id' => 10]); + + $sqlObject = $this->object->build(new DbMysqlFunctions()); + $this->assertEquals( + new SqlObject( + 'UPDATE `test` INNER JOIN `table2` ON table2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + SqlObjectEnum::UPDATE + ), + $sqlObject + ); + } + + public function testUpdateJoinPostgres() + { + $this->object->table('test'); + $this->object->join('table2', 'table2.id = test.id'); + $this->object->set('fld1', 'A'); + $this->object->set('fld2', 'B'); + $this->object->set('fld3', 'C'); + $this->object->where('fld1 = :id', ['id' => 10]); + + $sqlObject = $this->object->build(new DbPgsqlFunctions()); + $this->assertEquals( + new SqlObject( + 'UPDATE "test" SET "fld1" = :fld1 , "fld2" = :fld2 , "fld3" = :fld3 FROM "table2" ON table2.id = test.id WHERE fld1 = :id', + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + SqlObjectEnum::UPDATE + ), + $sqlObject + ); + + } }