Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ActiveRecord::reset() method #23

Merged
merged 15 commits into from
Dec 17, 2024
33 changes: 33 additions & 0 deletions docs/active-record.md
Original file line number Diff line number Diff line change
@@ -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
<?php
// Set a default DBDriver
ORM::defaultDriver($dbDriver);

// Initialize the Active Record
MyClass::initialize()
```

## Using the Active Record

Once is properly configured you can use the Active Record to save, update, delete and retrieve the data.
@@ -78,6 +92,25 @@ $myClass = MyClass::get(1);
$myClass->delete();
```

### Refresh a record

```php
<?php
// Retrieve a record
$myClass = MyClass::get(1);

// do some changes in the database
// **OR**
// expect that the record in the database was changed by another process

// Get the updated data from the database
$myClass->refresh();
```

### Update a model from another model or array

```php

### Using the `Query` class

```php
23 changes: 22 additions & 1 deletion src/ORM.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
13 changes: 9 additions & 4 deletions src/Repository.php
Original file line number Diff line number Diff line change
@@ -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);
}

88 changes: 80 additions & 8 deletions src/Trait/ActiveRecord.php
Original file line number Diff line number Diff line change
@@ -4,77 +4,149 @@

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
{
protected static ?DbDriverInterface $dbDriver = null;

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;
}

}
47 changes: 41 additions & 6 deletions src/UpdateQuery.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 8 additions & 3 deletions src/WhereTrait.php
Original file line number Diff line number Diff line change
@@ -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" => []];
}
}
}
125 changes: 124 additions & 1 deletion tests/RepositoryTest.php
Original file line number Diff line number Diff line change
@@ -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);
}
}
50 changes: 50 additions & 0 deletions tests/UpdateQueryTest.php
Original file line number Diff line number Diff line change
@@ -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
);

}
}