diff --git a/composer.json b/composer.json index c12ef94f1..214c124e6 100644 --- a/composer.json +++ b/composer.json @@ -73,6 +73,10 @@ } }, "config": { + "allow-plugins": { + "ergebnis/composer-normalize": true, + "phpstan/extension-installer": true + }, "sort-packages": true } } diff --git a/docs/persistence/sql/queries.rst b/docs/persistence/sql/queries.rst index d2528703c..a204add96 100644 --- a/docs/persistence/sql/queries.rst +++ b/docs/persistence/sql/queries.rst @@ -473,11 +473,11 @@ and overwrite this simple method to support expressions like this, for example: Joining with other tables ------------------------- -.. php:method:: join($foreign_table, $master_field, $join_kind) +.. php:method:: join($foreignTable, $master_field, $join_kind) Join results with additional table using "JOIN" statement in your query. - :param string|array $foreign_table: table to join (may include field and alias) + :param string|array $foreignTable: table to join (may include field and alias) :param mixed $master_field: main field (and table) to join on or Expression :param string $join_kind: 'left' (default), 'inner', 'right' etc - which join type to use :returns: $this diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3adbf85c1..f85d3cbda 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -33,7 +33,7 @@ parameters: # for Doctrine DBAL 2.x, remove the support once Doctrine ORM 2.10 is released # see https://github.com/doctrine/orm/issues/8526 - - message: '~^(Call to an undefined method Doctrine\\DBAL\\Driver\\Connection::getWrappedConnection\(\)\.|Call to an undefined method Doctrine\\DBAL\\Connection::createSchemaManager\(\)\.|Call to an undefined static method Doctrine\\DBAL\\Exception::invalidPdoInstance\(\)\.|Call to method (getCreateTableSQL|getClobTypeDeclarationSQL|initializeCommentedDoctrineTypes)\(\) of deprecated class Doctrine\\DBAL\\Platforms\\\w+Platform:\n.+|Anonymous class extends deprecated class Doctrine\\DBAL\\Platforms\\(PostgreSQL94Platform|SQLServer2012Platform):\n.+|Call to deprecated method fetch(|All)\(\) of class Doctrine\\DBAL\\Result:\n.+|Call to deprecated method getSchemaManager\(\) of class Doctrine\\DBAL\\Connection:\n.+|Access to an undefined property Doctrine\\DBAL\\Driver\\PDO\\Connection::\$connection\.|Parameter #1 \$dsn of class Doctrine\\DBAL\\Driver\\PDO\\SQLSrv\\Connection constructor expects string, Doctrine\\DBAL\\Driver\\PDO\\Connection given\.|Method Atk4\\Data\\Persistence\\Sql\\Expression::execute\(\) should return Doctrine\\DBAL\\Result\|PDOStatement but returns bool\.|PHPDoc tag @return contains generic type Doctrine\\DBAL\\Schema\\AbstractSchemaManager but class Doctrine\\DBAL\\Schema\\AbstractSchemaManager is not generic\.|Class Doctrine\\DBAL\\Platforms\\(MySqlPlatform|PostgreSqlPlatform) referenced with incorrect case: Doctrine\\DBAL\\Platforms\\(MySQLPlatform|PostgreSQLPlatform)\.)$~' + message: '~^(Call to an undefined method Doctrine\\DBAL\\Driver\\Connection::getWrappedConnection\(\)\.|Call to an undefined method Doctrine\\DBAL\\Connection::createSchemaManager\(\)\.|Call to an undefined static method Doctrine\\DBAL\\Exception::invalidPdoInstance\(\)\.|Call to method (getCreateTableSQL|getClobTypeDeclarationSQL|initializeCommentedDoctrineTypes)\(\) of deprecated class Doctrine\\DBAL\\Platforms\\\w+Platform:\n.+|Anonymous class extends deprecated class Doctrine\\DBAL\\Platforms\\(PostgreSQL94Platform|SQLServer2012Platform):\n.+|Call to deprecated method fetch(|All)\(\) of class Doctrine\\DBAL\\Result:\n.+|Call to deprecated method getSchemaManager\(\) of class Doctrine\\DBAL\\Connection:\n.+|Call to deprecated method getWrappedConnection\(\) of class Doctrine\\DBAL\\Connection:\n.+getNativeConnection\(\).+|Call to deprecated method getWrappedResourceHandle\(\) of class Doctrine\\DBAL\\Driver\\Mysqli\\Connection:\n.+getNativeConnection\(\).+|Access to an undefined property Doctrine\\DBAL\\Driver\\PDO\\Connection::\$connection\.|Parameter #1 \$dsn of class Doctrine\\DBAL\\Driver\\PDO\\SQLSrv\\Connection constructor expects string, Doctrine\\DBAL\\Driver\\PDO\\Connection given\.|Method Atk4\\Data\\Persistence\\Sql\\Expression::execute\(\) should return Doctrine\\DBAL\\Result\|PDOStatement but returns bool\.|PHPDoc tag @return contains generic type Doctrine\\DBAL\\Schema\\AbstractSchemaManager but class Doctrine\\DBAL\\Schema\\AbstractSchemaManager is not generic\.|Class Doctrine\\DBAL\\Platforms\\(MySqlPlatform|PostgreSqlPlatform) referenced with incorrect case: Doctrine\\DBAL\\Platforms\\(MySQLPlatform|PostgreSQLPlatform)\.)$~' path: '*' # count for DBAL 3.x matched in "src/Persistence/GenericPlatform.php" file count: 40 @@ -53,9 +53,6 @@ parameters: # for src/Persistence/Sql.php - '~^Call to an undefined method Atk4\\Data\\Persistence::expr\(\)\.$~' - '~^Call to an undefined method Atk4\\Data\\Persistence::exprNow\(\)\.$~' - # for src/Persistence/Sql/Join.php - - '~^Call to an undefined method Atk4\\Data\\Persistence::initQuery\(\)\.$~' - - '~^Call to an undefined method Atk4\\Data\\Persistence::lastInsertId\(\)\.$~' # for src/Reference/HasMany.php - '~^Call to an undefined method Atk4\\Data\\Model::dsql\(\)\.$~' # for tests/FieldTest.php diff --git a/src/Model.php b/src/Model.php index bd1bed894..046fa802f 100644 --- a/src/Model.php +++ b/src/Model.php @@ -147,7 +147,7 @@ class Model implements \IteratorAggregate /** @var array Array of set order by. */ public $order = []; - /** @var array Array of WITH cursors set. */ + /** @var array, 'recursive': bool}> */ public $with = []; /** @@ -1074,8 +1074,8 @@ public function withId($id) */ public function addWith(self $model, string $alias, array $mapping = [], bool $recursive = false) { - if (isset($this->with[$alias])) { - throw (new Exception('With cursor already set with this alias')) + if ($alias === $this->table || $alias === $this->table_alias || isset($this->with[$alias])) { + throw (new Exception('With cursor already set with given alias')) ->addMoreInfo('alias', $alias); } diff --git a/src/Model/Join.php b/src/Model/Join.php index f5da760d3..0b597888d 100644 --- a/src/Model/Join.php +++ b/src/Model/Join.php @@ -11,7 +11,6 @@ use Atk4\Data\Exception; use Atk4\Data\Field; use Atk4\Data\Model; -use Atk4\Data\Persistence; use Atk4\Data\Reference; /** @@ -19,7 +18,7 @@ * * @method Model getOwner() */ -class Join +abstract class Join { use DiContainerTrait; use InitializerTrait { @@ -31,31 +30,12 @@ class Join } /** - * Name of the table (or collection) that can be used to retrieve data from. - * For SQL, This can also be an expression or sub-select. + * Foreign model or WITH/CTE alias when used with SQL persistence. * * @var string */ protected $foreign_table; - /** - * If $persistence is set, then it's used for loading - * and storing the values, instead $owner->persistence. - * - * @var Persistence|Persistence\Sql|null - */ - protected $persistence; - - /** - * Field that is used as native "ID" in the foreign table. - * When deleting record, this field will be conditioned. - * - * ->where($join->id_field, $join->id)->delete(); - * - * @var string - */ - protected $id_field = 'id'; - /** * By default this will be either "inner" (for strong) or "left" for weak joins. * You can specify your own type of join by passing ['kind' => 'right'] @@ -65,7 +45,7 @@ class Join */ protected $kind; - /** @var bool Is our join weak? Weak join will stop you from touching foreign table. */ + /** @var bool Weak join does not update foreign table. */ protected $weak = false; /** @@ -75,7 +55,7 @@ class Join * * If you are using the following syntax: * - * $user->join('contact','default_contact_id'); + * $user->join('contact', 'default_contact_id') * * Then the ID connecting tables is stored in foreign table and the order * of saving and delete needs to be reversed. In this case $reverse @@ -86,18 +66,16 @@ class Join protected $reverse; /** - * Field to be used for matching inside master table. By default - * it's $foreign_table.'_id'. - * Note that it should be actual field name in master table. + * Field to be used for matching inside master table. + * By default it's $foreign_table.'_id'. * * @var string */ protected $master_field; /** - * Field to be used for matching in a foreign table. By default - * it's 'id'. - * Note that it should be actual field name in foreign table. + * Field to be used for matching in a foreign table. + * By default it's 'id'. * * @var string */ @@ -120,9 +98,9 @@ class Join /** @var array> Data indexed by spl_object_id(entity) which is populated here as the save/insert progresses. */ private $saveBufferByOid = []; - public function __construct(string $foreign_table = null) + public function __construct(string $foreignTable = null) { - $this->foreign_table = $foreign_table; + $this->foreign_table = $foreignTable; // handle foreign table containing a dot - that will be reverse join if (strpos($this->foreign_table, '.') !== false) { @@ -132,6 +110,42 @@ public function __construct(string $foreign_table = null) } } + /** + * Create fake foreign model, in the future, this method should be removed + * in favor of always requiring an object model. + */ + protected function createFakeForeignModel(): Model + { + $fakeModel = new Model($this->getOwner()->persistence, [ + 'table' => $this->foreign_table, + ]); + foreach ($this->getOwner()->getFields() as $ownerField) { + if ($ownerField->hasJoin() && $ownerField->getJoin()->short_name === $this->short_name + && $ownerField->short_name !== $fakeModel->id_field + && $ownerField->short_name !== $this->foreign_field) { + $fakeModel->addField($ownerField->short_name, [ + 'actual' => $ownerField->actual, + 'type' => $ownerField->type, + ]); + } + } + if ($fakeModel->id_field !== $this->foreign_field && $this->foreign_field !== null) { + $fakeModel->addField($this->foreign_field, ['type' => 'integer']); + } + + return $fakeModel; + } + + public function getForeignModel(): Model + { + // TODO this should be removed in the future + if (!isset($this->getOwner()->with[$this->foreign_table])) { + return $this->createFakeForeignModel(); + } + + return $this->getOwner()->with[$this->foreign_table]['model']; + } + /** * @param Model $owner * @@ -203,6 +217,8 @@ protected function init(): void { $this->_init(); + $this->getForeignModel(); // assert valid foreign_table + // owner model should have id_field set $id_field = $this->getOwner()->id_field; if (!$id_field) { @@ -214,7 +230,7 @@ protected function init(): void if ($this->master_field && $this->master_field !== $id_field) { // TODO not implemented yet, see https://github.com/atk4/data/issues/803 throw (new Exception('Joining tables on non-id fields is not implemented yet')) ->addMoreInfo('master_field', $this->master_field) - ->addMoreInfo('id_field', $this->id_field); + ->addMoreInfo('id_field', $id_field); } if (!$this->master_field) { @@ -236,12 +252,29 @@ protected function init(): void } } - $this->onHookToOwnerEntity(Model::HOOK_AFTER_UNLOAD, \Closure::fromCallable([$this, 'afterUnload'])); - // if kind is not specified, figure out join type if (!$this->kind) { $this->kind = $this->weak ? 'left' : 'inner'; } + + $this->initJoinHooks(); + } + + protected function initJoinHooks(): void + { + $this->onHookToOwnerEntity(Model::HOOK_AFTER_UNLOAD, \Closure::fromCallable([$this, 'afterUnload'])); + + if ($this->reverse) { + $this->onHookToOwnerEntity(Model::HOOK_AFTER_INSERT, \Closure::fromCallable([$this, 'afterInsert']), [], -5); + $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate']), [], -5); + $this->onHookToOwnerEntity(Model::HOOK_BEFORE_DELETE, \Closure::fromCallable([$this, 'doDelete']), [], -5); + } else { + $this->onHookToOwnerEntity(Model::HOOK_BEFORE_INSERT, \Closure::fromCallable([$this, 'beforeInsert']), [], -5); + $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate']), [], -5); + $this->onHookToOwnerEntity(Model::HOOK_AFTER_DELETE, \Closure::fromCallable([$this, 'doDelete'])); + + $this->onHookToOwnerEntity(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); + } } /** @@ -280,11 +313,11 @@ public function addFields(array $fields = [], array $defaults = []) * * @param array $defaults */ - public function join(string $foreign_table, array $defaults = []): self + public function join(string $foreignTable, array $defaults = []): self { $defaults['joinName'] = $this->short_name; - return $this->getOwner()->join($foreign_table, $defaults); + return $this->getOwner()->join($foreignTable, $defaults); } /** @@ -292,11 +325,11 @@ public function join(string $foreign_table, array $defaults = []): self * * @param array $defaults */ - public function leftJoin(string $foreign_table, array $defaults = []): self + public function leftJoin(string $foreignTable, array $defaults = []): self { $defaults['joinName'] = $this->short_name; - return $this->getOwner()->leftJoin($foreign_table, $defaults); + return $this->getOwner()->leftJoin($foreignTable, $defaults); } /** @@ -318,9 +351,10 @@ public function hasOne(string $link, array $defaults = []) */ public function hasMany(string $link, array $defaults = []) { + $id_field = $this->getOwner()->id_field; $defaults = array_merge([ - 'our_field' => $this->id_field, - 'their_field' => $this->getModelTableString($this->getOwner()) . '_' . $this->id_field, + 'our_field' => $id_field, + 'their_field' => $this->getModelTableString($this->getOwner()) . '_' . $id_field, ], $defaults); return $this->getOwner()->hasMany($link, $defaults); @@ -452,12 +486,100 @@ public function setSaveBufferValue(Model $entity, string $fieldName, $value): vo $this->saveBufferByOid[spl_object_id($entity)][$fieldName] = $value; } - /** - * Clears id and save buffer. - */ protected function afterUnload(Model $entity): void { $this->unsetId($entity); $this->unsetSaveBuffer($entity); } + + abstract public function afterLoad(Model $entity): void; + + public function beforeInsert(Model $entity, array &$data): void + { + if ($this->weak) { + return; + } + + $model = $this->getOwner(); + + // the value for the master_field is set, so we are going to use existing record anyway + if ($model->hasField($this->master_field) && $entity->get($this->master_field) !== null) { + return; + } + + $foreignModel = $this->getForeignModel(); + $foreignEntity = $foreignModel->createEntity() + ->setMulti($this->getAndUnsetSaveBuffer($entity)) + /*->set($this->foreign_field, null)*/; + $foreignEntity->save(); + + $this->setId($entity, $foreignEntity->getId()); + + if ($this->hasJoin()) { + $this->getJoin()->setSaveBufferValue($entity, $this->master_field, $this->getId($entity)); + } else { + $data[$this->master_field] = $this->getId($entity); + } + + // $entity->set($this->master_field, $this->getId($entity)); // TODO needed? from array persistence + } + + public function afterInsert(Model $entity): void + { + if ($this->weak) { + return; + } + + $this->setSaveBufferValue($entity, $this->foreign_field, $this->hasJoin() ? $this->getJoin()->getId($entity) : $entity->getId()); // TODO needed? from array persistence + + $foreignModel = $this->getForeignModel(); + $foreignEntity = $foreignModel->createEntity() + ->setMulti($this->getAndUnsetSaveBuffer($entity)) + ->set($this->foreign_field, $this->hasJoin() ? $this->getJoin()->getId($entity) : $entity->getId()); + $foreignEntity->save(); + + $this->setId($entity, $entity->getId()); // TODO why is this here? it seems to be not needed + } + + public function beforeUpdate(Model $entity, array &$data): void + { + if ($this->weak) { + return; + } + + if (!$this->issetSaveBuffer($entity)) { + return; + } + + $foreignModel = $this->getForeignModel(); + $foreignId = $this->reverse ? $entity->getId() : $entity->get($this->master_field); + $saveBuffer = $this->getAndUnsetSaveBuffer($entity); + $foreignModel->atomic(function () use ($foreignModel, $foreignId, $saveBuffer) { + $foreignModel = (clone $foreignModel)->addCondition($this->foreign_field, $foreignId); + foreach ($foreignModel as $foreignEntity) { + $foreignEntity->setMulti($saveBuffer); + $foreignEntity->save(); + } + }); + + // $this->setId($entity, ??); // TODO needed? from array persistence + } + + public function doDelete(Model $entity): void + { + if ($this->weak) { + return; + } + + $foreignModel = $this->getForeignModel(); + $foreignId = $this->reverse ? $entity->getId() : $entity->get($this->master_field); + $foreignModel->atomic(function () use ($foreignModel, $foreignId) { + $foreignModel = (clone $foreignModel)->addCondition($this->foreign_field, $foreignId); + foreach ($foreignModel as $foreignEntity) { + $foreignEntity->delete(); + } + }); + + $this->unsetId($entity); // TODO needed? from array persistence + } } diff --git a/src/Model/Scope/RootScope.php b/src/Model/Scope/RootScope.php index 0476a02f4..5b7b298b4 100644 --- a/src/Model/Scope/RootScope.php +++ b/src/Model/Scope/RootScope.php @@ -48,12 +48,18 @@ public function negate() throw new Exception('Model Scope cannot be negated!'); } - public static function createAnd(...$conditions) + /** + * @return Model\Scope + */ + public static function createAnd(...$conditions) // @phpstan-ignore-line { return (parent::class)::createAnd(...$conditions); } - public static function createOr(...$conditions) + /** + * @return Model\Scope + */ + public static function createOr(...$conditions) // @phpstan-ignore-line { return (parent::class)::createOr(...$conditions); } diff --git a/src/Persistence/Array_.php b/src/Persistence/Array_.php index 697fe8f07..637f1bdb7 100644 --- a/src/Persistence/Array_.php +++ b/src/Persistence/Array_.php @@ -214,20 +214,21 @@ public function tryLoad(Model $model, $id): ?array { if ($id === self::ID_LOAD_ONE || $id === self::ID_LOAD_ANY) { $action = $this->action($model, 'select'); - $action->generator->rewind(); // TODO needed for some reasons! - $selectRow = $action->getRow(); - if ($selectRow === null) { + $action->limit($id === self::ID_LOAD_ANY ? 1 : 2); + + $rowsRaw = $action->getRows(); + if (count($rowsRaw) === 0) { return null; - } elseif ($id === self::ID_LOAD_ONE && $action->getRow() !== null) { + } elseif (count($rowsRaw) !== 1) { throw (new Exception('Ambiguous conditions, more than one record can be loaded.')) ->addMoreInfo('model', $model) ->addMoreInfo('id', null); } - $id = $selectRow[$model->id_field]; + $idRaw = reset($rowsRaw)[$model->id_field]; - $row = $this->tryLoad($model, $id); + $row = $this->tryLoad($model, $idRaw); return $row; } @@ -238,7 +239,6 @@ public function tryLoad(Model $model, $id): ?array $condition->key = $model->getField($model->id_field); $condition->setOwner($model->createEntity()); // TODO needed for typecasting to apply $action->filter($condition); - $action->generator->rewind(); // TODO needed for some reasons! $rowData = $action->getRow(); if ($rowData === null) { diff --git a/src/Persistence/Array_/Action.php b/src/Persistence/Array_/Action.php index 4b692458a..5c1cc0263 100644 --- a/src/Persistence/Array_/Action.php +++ b/src/Persistence/Array_/Action.php @@ -45,6 +45,9 @@ public function filter(Model\Scope\AbstractScope $condition) } else { $this->generator = new \CallbackFilterIterator($this->generator, $filterFx); } + // initialize filter iterator, it is not rewound by default + // https://github.com/php/php-src/issues/7952 + $this->generator->rewind(); } return $this; @@ -53,13 +56,9 @@ public function filter(Model\Scope\AbstractScope $condition) /** * Calculates SUM|AVG|MIN|MAX aggregate values for $field. * - * @param string $fx - * @param string $field - * @param bool $coalesce - * * @return $this */ - public function aggregate($fx, $field, $coalesce = false) + public function aggregate(string $fx, string $field, bool $coalesce = false) { $result = 0; $column = array_column($this->getRows(), $field); @@ -97,15 +96,10 @@ public function aggregate($fx, $field, $coalesce = false) /** * Checks if $row matches $condition. - * - * @return bool */ - protected function match(array $row, Model\Scope\AbstractScope $condition) + protected function match(array $row, Model\Scope\AbstractScope $condition): bool { - $match = false; - - // simple condition - if ($condition instanceof Model\Scope\Condition) { + if ($condition instanceof Model\Scope\Condition) { // simple condition $args = $condition->toQueryArguments(); $field = $args[0]; @@ -123,11 +117,8 @@ protected function match(array $row, Model\Scope\AbstractScope $condition) ->addMoreInfo('condition', $condition); } - $match = $this->evaluateIf($row[$field->short_name] ?? null, $operator, $value); - } - - // nested conditions - if ($condition instanceof Model\Scope) { + return $this->evaluateIf($row[$field->short_name] ?? null, $operator, $value); + } elseif ($condition instanceof Model\Scope) { // nested conditions $matches = []; foreach ($condition->getNestedConditions() as $nestedCondition) { @@ -140,10 +131,11 @@ protected function match(array $row, Model\Scope\AbstractScope $condition) } // any matches && all matches the same (if all required) - $match = array_filter($matches) && ($condition->isAnd() ? count(array_unique($matches)) === 1 : true); + return array_filter($matches) && ($condition->isAnd() ? count(array_unique($matches)) === 1 : true); } - return $match; + throw (new Exception('Unexpected condition type')) + ->addMoreInfo('class', get_class($condition)); } /** @@ -157,7 +149,8 @@ protected function evaluateIf($v1, string $operator, $v2): bool } if ($v2 instanceof \Traversable) { - throw new \Exception('Unexpected v2 type'); + throw (new Exception('Unexpected v2 type')) + ->addMoreInfo('class', get_class($v2)); } switch (strtoupper($operator)) { @@ -230,11 +223,9 @@ protected function evaluateIf($v1, string $operator, $v2): bool /** * Applies sorting on Iterator. * - * @param array $fields - * * @return $this */ - public function order($fields) + public function order(array $fields) { $data = $this->getRows(); @@ -260,12 +251,16 @@ public function order($fields) * * @return $this */ - public function limit(int $limit = null, int $offset = 0) + public function limit(?int $limit, int $offset = 0) { - $data = array_slice($this->getRows(), $offset, $limit, true); - - // put data back in generator - $this->generator = new \ArrayIterator($data); + // LimitIterator with circular reference is not GCed in PHP 7.4 - ???, see + // https://github.com/php/php-src/issues/7958 + if (\PHP_MAJOR_VERSION < 20) { // TODO update condition once fixed in php-src + $data = array_slice($this->getRows(), $offset, $limit, true); + $this->generator = new \ArrayIterator($data); + } else { + $this->generator = new \LimitIterator($this->generator, $offset, $limit ?? -1); + } return $this; } @@ -289,6 +284,7 @@ public function count() */ public function exists() { + $this->generator->rewind(); $this->generator = new \ArrayIterator([[$this->generator->valid() ? 1 : 0]]); return $this; @@ -307,6 +303,7 @@ public function getRows(): array */ public function getRow(): ?array { + $this->generator->rewind(); // TODO alternatively allow to fetch only once $row = $this->generator->current(); $this->generator->next(); diff --git a/src/Persistence/Array_/Join.php b/src/Persistence/Array_/Join.php index 900d1b201..517e82e0d 100644 --- a/src/Persistence/Array_/Join.php +++ b/src/Persistence/Array_/Join.php @@ -8,51 +8,8 @@ use Atk4\Data\Model; use Atk4\Data\Persistence; -/** - * @property Persistence\Array_|null $persistence - */ class Join extends Model\Join { - /** - * This method is to figure out stuff. - */ - protected function init(): void - { - parent::init(); - - // add necessary hooks - if ($this->reverse) { - $this->onHookToOwnerEntity(Model::HOOK_AFTER_INSERT, \Closure::fromCallable([$this, 'afterInsert']), [], -5); - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate']), [], -5); - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_DELETE, \Closure::fromCallable([$this, 'doDelete']), [], -5); - } else { - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_INSERT, \Closure::fromCallable([$this, 'beforeInsert'])); - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); - $this->onHookToOwnerEntity(Model::HOOK_AFTER_DELETE, \Closure::fromCallable([$this, 'doDelete'])); - $this->onHookToOwnerEntity(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); - } - } - - protected function makeFakeModelWithForeignTable(): Model - { - $this->getOwner()->assertIsModel(); - - $modelCloned = clone $this->getOwner(); - foreach ($modelCloned->getFields() as $field) { - if ($field->hasJoin() && $field->getJoin()->foreign_table === $this->foreign_table) { - \Closure::bind(fn () => $field->joinName = null, null, \Atk4\Data\Field::class)(); - } else { - $modelCloned->removeField($field->short_name); - } - } - $modelCloned->addField($this->id_field, ['type' => 'integer']); - $modelCloned->table = $this->foreign_table; - - // @TODO hooks will be fixed on a cloned model, foreign_table string name should be replaced with object model - - return $modelCloned; - } - public function afterLoad(Model $entity): void { // we need to collect ID @@ -63,7 +20,7 @@ public function afterLoad(Model $entity): void try { $data = Persistence\Array_::assertInstanceOf($this->getOwner()->persistence) - ->load($this->makeFakeModelWithForeignTable(), $this->getId($entity)); + ->load($this->createFakeForeignModel(), $this->getId($entity)); } catch (Exception $e) { throw (new Exception('Unable to load joined record', $e->getCode(), $e)) ->addMoreInfo('table', $this->foreign_table) @@ -72,77 +29,4 @@ public function afterLoad(Model $entity): void $dataRef = &$entity->getDataRef(); $dataRef = array_merge($data, $entity->getDataRef()); } - - public function beforeInsert(Model $entity, array &$data): void - { - if ($this->weak) { - return; - } - - if ($entity->hasField($this->master_field) && $entity->get($this->master_field)) { - // The value for the master_field is set, - // we are going to use existing record. - return; - } - - // Figure out where are we going to save data - $persistence = $this->persistence ?: $this->getOwner()->persistence; - - $this->setId($entity, $persistence->insert( - $this->makeFakeModelWithForeignTable(), - $this->getAndUnsetSaveBuffer($entity) - )); - - $data[$this->master_field] = $this->getId($entity); - - // $entity->set($this->master_field, $this->getId($entity)); - } - - public function afterInsert(Model $entity): void - { - if ($this->weak) { - return; - } - - $this->setSaveBufferValue($entity, $this->foreign_field, $this->hasJoin() ? $this->getJoin()->getId($entity) : $entity->getId()); - - $persistence = $this->persistence ?: $this->getOwner()->persistence; - - $this->setId($entity, $persistence->insert( - $this->makeFakeModelWithForeignTable(), - $this->getAndUnsetSaveBuffer($entity) - )); - } - - public function beforeUpdate(Model $entity, array &$data): void - { - if ($this->weak) { - return; - } - - $persistence = $this->persistence ?: $this->getOwner()->persistence; - - // @phpstan-ignore-next-line TODO this cannot work, Persistence::update() returns void - $this->setId($entity, $persistence->update( - $this->makeFakeModelWithForeignTable(), - $this->getId($entity), - $this->getAndUnsetSaveBuffer($entity) - )); - } - - public function doDelete(Model $entity): void - { - if ($this->weak) { - return; - } - - $persistence = $this->persistence ?: $this->getOwner()->persistence; - - $persistence->delete( - $this->makeFakeModelWithForeignTable(), - $this->getId($entity) - ); - - $this->unsetId($entity); - } } diff --git a/src/Persistence/GenericPlatform.php b/src/Persistence/GenericPlatform.php index da6dc4c48..3fe2df090 100644 --- a/src/Persistence/GenericPlatform.php +++ b/src/Persistence/GenericPlatform.php @@ -43,11 +43,6 @@ private function createNotSupportedException(): \Exception $connection->getSchemaManager(); $connection->getSchemaManager(); $connection->getSchemaManager(); - $connection->getSchemaManager(); - $connection->getSchemaManager(); - $connection->getSchemaManager(); - $connection->getSchemaManager(); - $connection->getSchemaManager(); } } diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index 70adfa514..62e5f6ba7 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -212,7 +212,6 @@ public function initQuery(Model $model): Query ); } - // add With cursors $this->initWithCursors($model, $query); return $query; @@ -223,11 +222,12 @@ public function initQuery(Model $model): Query */ public function initWithCursors(Model $model, Query $query): void { - if (!$with = $model->with) { + $with = $model->with; + if (count($with) === 0) { return; } - foreach ($with as $alias => ['model' => $withModel, 'mapping' => $withMapping, 'recursive' => $recursive]) { + foreach ($with as $alias => ['model' => $withModel, 'mapping' => $withMapping, 'recursive' => $withRecursive]) { // prepare field names $fieldsFrom = $fieldsTo = []; foreach ($withMapping as $from => $to) { @@ -237,14 +237,13 @@ public function initWithCursors(Model $model, Query $query): void // prepare sub-query if ($fieldsFrom) { - $withModel->setOnlyFields($fieldsFrom); + $withModel->setOnlyFields($fieldsFrom); // TODO this mutates model state } // 2nd parameter here strictly define which fields should be selected // as result system fields will not be added if they are not requested $subQuery = $withModel->action('select', [$fieldsFrom]); - // add With cursor - $query->with($subQuery, $alias, $fieldsTo ?: null, $recursive); + $query->with($subQuery, $alias, $fieldsTo ?: null, $withRecursive); } } diff --git a/src/Persistence/Sql/Join.php b/src/Persistence/Sql/Join.php index 8a0ed7e53..787a1003c 100644 --- a/src/Persistence/Sql/Join.php +++ b/src/Persistence/Sql/Join.php @@ -7,9 +7,6 @@ use Atk4\Data\Model; use Atk4\Data\Persistence; -/** - * @property Persistence\Sql $persistence - */ class Join extends Model\Join { /** @@ -28,9 +25,6 @@ public function getDesiredName(): string return '_' . ($this->foreign_alias ?: $this->foreign_table); } - /** - * This method is to figure out stuff. - */ protected function init(): void { parent::init(); @@ -42,43 +36,25 @@ protected function init(): void $this->foreign_alias = ($this->getOwner()->table_alias ?: '') . $this->short_name; } - $this->onHookToOwnerBoth(Persistence\Sql::HOOK_INIT_SELECT_QUERY, \Closure::fromCallable([$this, 'initSelectQuery'])); + // Master field indicates ID of the joined item. In the past it had to be + // defined as a physical field in the main table. Now it is a model field + // so you can use expressions or fields inside joined entities. + // If string specified here does not point to an existing model field + // a new basic field is inserted and marked hidden. + if (!$this->reverse && !$this->getOwner()->hasField($this->master_field)) { + $owner = $this->hasJoin() ? $this->getJoin() : $this->getOwner(); - // add necessary hooks - if ($this->reverse) { - $this->onHookToOwnerEntity(Model::HOOK_AFTER_INSERT, \Closure::fromCallable([$this, 'afterInsert'])); - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_DELETE, \Closure::fromCallable([$this, 'doDelete']), [], -5); - $this->onHookToOwnerEntity(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); - } else { - // Master field indicates ID of the joined item. In the past it had to be - // defined as a physical field in the main table. Now it is a model field - // so you can use expressions or fields inside joined entities. - // If string specified here does not point to an existing model field - // a new basic field is inserted and marked hidden. - if (!$this->getOwner()->hasField($this->master_field)) { - $owner = $this->hasJoin() ? $this->getJoin() : $this->getOwner(); - - $field = $owner->addField($this->master_field, ['system' => true, 'read_only' => true]); - - $this->master_field = $field->short_name; - } - - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_INSERT, \Closure::fromCallable([$this, 'beforeInsert']), [], -5); - $this->onHookToOwnerEntity(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); - $this->onHookToOwnerEntity(Model::HOOK_AFTER_DELETE, \Closure::fromCallable([$this, 'doDelete'])); - $this->onHookToOwnerEntity(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); + $field = $owner->addField($this->master_field, ['system' => true, 'read_only' => true]); + + $this->master_field = $field->short_name; } } - /** - * Returns DSQL query. - */ - public function dsql(): Query + protected function initJoinHooks(): void { - $dsql = $this->getOwner()->persistence->initQuery($this->getOwner()); + parent::initJoinHooks(); - return $dsql->reset('table')->table($this->foreign_table, $this->foreign_alias); + $this->onHookToOwnerBoth(Persistence\Sql::HOOK_INIT_SELECT_QUERY, \Closure::fromCallable([$this, 'initSelectQuery'])); } /** @@ -128,83 +104,4 @@ public function afterLoad(Model $entity): void unset($entity->getDataRef()[$this->short_name]); } } - - public function beforeInsert(Model $entity, array &$data): void - { - if ($this->weak) { - return; - } - - $model = $this->getOwner(); - - // The value for the master_field is set, so we are going to use existing record anyway - if ($model->hasField($this->master_field) && $entity->get($this->master_field)) { - return; - } - - $query = $this->dsql(); - $query->mode('insert'); - $query->setMulti($model->persistence->typecastSaveRow($model, $this->getAndUnsetSaveBuffer($entity))); - // $query->set($this->foreign_field, null); - $query->mode('insert')->execute(); // TODO IMPORTANT migrate to Model insert - $this->setId($entity, $model->persistence->lastInsertId(new Model($model->persistence, ['table' => $this->foreign_table]))); - - if ($this->hasJoin()) { - $this->getJoin()->setSaveBufferValue($entity, $this->master_field, $this->getId($entity)); - } else { - $data[$this->master_field] = $this->getId($entity); - } - } - - public function afterInsert(Model $entity): void - { - if ($this->weak) { - return; - } - - $model = $this->getOwner(); - - $query = $this->dsql(); - $query->setMulti($model->persistence->typecastSaveRow($model, $this->getAndUnsetSaveBuffer($entity))); - $query->set($this->foreign_field, $this->hasJoin() ? $this->getJoin()->getId($entity) : $entity->getId()); - $query->mode('insert')->execute(); // TODO IMPORTANT migrate to Model insert - $modelForLastInsertId = $model; - while (is_object($modelForLastInsertId->table)) { - $modelForLastInsertId = $modelForLastInsertId->table; - } - // assumes same ID field across all nested models (not needed once migrated to Model insert) - $this->setId($entity, $model->persistence->lastInsertId($modelForLastInsertId)); - } - - public function beforeUpdate(Model $entity, array &$data): void - { - if ($this->weak) { - return; - } - - if (!$this->issetSaveBuffer($entity)) { - return; - } - - $model = $this->getOwner(); - - $query = $this->dsql(); - $query->setMulti($model->persistence->typecastSaveRow($model, $this->getAndUnsetSaveBuffer($entity))); - - $id = $this->reverse ? $entity->getId() : $entity->get($this->master_field); - - $query->where($this->foreign_field, $id)->mode('update')->execute(); // TODO IMPORTANT migrate to Model update - } - - public function doDelete(Model $entity): void - { - if ($this->weak) { - return; - } - - $query = $this->dsql(); - $id = $this->reverse ? $entity->getId() : $entity->get($this->master_field); - - $query->where($this->foreign_field, $id)->mode('delete')->execute(); // TODO IMPORTANT migrate to Model delete - } } diff --git a/src/Schema/TestCase.php b/src/Schema/TestCase.php index 21ff2fca2..da1dcabf3 100644 --- a/src/Schema/TestCase.php +++ b/src/Schema/TestCase.php @@ -52,7 +52,7 @@ public function __construct(TestCase $testCase) $this->testCaseWeakRef = \WeakReference::create($testCase); } - public function startQuery($sql, $params = null, $types = null): void + public function startQuery($sql, array $params = null, array $types = null): void { if (!$this->testCaseWeakRef->get()->debug) { return;