diff --git a/docs/joins.rst b/docs/joins.rst index 5e6f8a9ee2..a4f418618e 100644 --- a/docs/joins.rst +++ b/docs/joins.rst @@ -1,7 +1,11 @@ -================================ -Model from multiple joined table -================================ +.. _Joins: + +.. php:namespace:: atk4\data\Model + +================================= +Model from multiple joined tables +================================= .. php:class:: Join diff --git a/src/Join.php b/src/Join.php index 62f636c84a..dd55065176 100644 --- a/src/Join.php +++ b/src/Join.php @@ -4,464 +4,13 @@ namespace atk4\data; -use atk4\core\DiContainerTrait; -use atk4\core\InitializerTrait; -use atk4\core\TrackableTrait; +if (!class_exists(\SebastianBergmann\CodeCoverage\CodeCoverage::class, false)) { + 'trigger_error'('Class atk4\data\Join is deprecated. Use atk4\data\Model\Join instead', E_USER_DEPRECATED); +} /** - * Class description? - * - * @property Model $owner + * @deprecated use \atk4\data\Model\Join instead - will be removed in dec-2020 */ -class Join +class Join extends Model\Join { - use TrackableTrait; - use InitializerTrait { - init as _init; - } - use DiContainerTrait; - - /** - * 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. - * - * @var string - */ - protected $foreign_table; - - /** - * If $persistence is set, then it's used for loading - * and storing the values, instead $owner->persistence. - * - * @var Persistence - */ - protected $persistence; - - /** - * ID used by a joined table. - * - * @var mixed - */ - protected $id; - - /** - * 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'] - * as second argument to join(). - * - * @var string - */ - protected $kind; - - /** - * Is our join weak? Weak join will stop you from touching foreign table. - * - * @var bool - */ - protected $weak = false; - - /** - * Normally the foreign table is saved first, then it's ID is used in the - * primary table. When deleting, the primary table record is deleted first - * which is followed by the foreign table record. - * - * If you are using the following syntax: - * - * $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 - * will be set to `true`. You can specify value of this property. - * - * @var bool - */ - protected $reverse; - - /** - * Field to be used for matching inside master field. 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'. - * - * @var string - */ - protected $foreign_field; - - /** - * A short symbolic name that will be used as an alias for the joined table. - * - * @var string - */ - public $foreign_alias; - - /** - * When $prefix is set, then all the fields generated through - * our wrappers will be automatically prefixed inside the model. - * - * @var string - */ - protected $prefix = ''; - - /** - * Data which is populated here as the save/insert progresses. - * - * @var array - */ - protected $save_buffer = []; - - /** - * When join is done on another join. - * - * @var Join - */ - protected $join; - - /** - * Default constructor. Will copy argument into properties. - * - * @param array $defaults - */ - public function __construct($foreign_table = null) - { - if (isset($foreign_table)) { - $this->foreign_table = $foreign_table; - } - } - - /** - * Will use either foreign_alias or create #join_. - */ - public function getDesiredName(): string - { - return '#join_' . $this->foreign_table; - } - - /** - * Initialization. - */ - public function init(): void - { - $this->_init(); - - // handle foreign table containing a dot - if (is_string($this->foreign_table) && strpos($this->foreign_table, '.') !== false) { - if (!isset($this->reverse)) { - $this->reverse = true; - if (isset($this->master_field)) { - // both master and foreign fields are set - - // master_field exists, no we will use that - // if (!is_object($this->master_field) && !$this->owner->hasField($this->master_field)) { - throw (new Exception('You are trying to link tables on non-id fields. This is not implemented yet')) - ->addMoreInfo('condition', $this->owner->table . '.' . $this->master_field . ' = ' . $this->foreign_table); - // } $this->reverse = 'link'; - } - } - - // split by LAST dot in foreign_table name - [$this->foreign_table, $this->foreign_field] = preg_split('/\.+(?=[^\.]+$)/', $this->foreign_table); - - if (!$this->master_field) { - $this->master_field = 'id'; - } - } else { - $this->reverse = false; - $id_field = $this->owner->id_field ?: 'id'; - if (!$this->master_field) { - $this->master_field = $this->foreign_table . '_' . $id_field; - } - - if (!$this->foreign_field) { - $this->foreign_field = $id_field; - } - } - - $this->owner->onHook(Model::HOOK_AFTER_UNLOAD, \Closure::fromCallable([$this, 'afterUnload'])); - } - - /** - * Adding field into join will automatically associate that field - * with this join. That means it won't be loaded from $table, but - * form the join instead. - * - * @param string $name - * @param array $seed - * - * @return Field - */ - public function addField($name, $seed = []) - { - if ($seed && !is_array($seed)) { - $seed = [$seed]; - } - $seed['join'] = $this; - - return $this->owner->addField($this->prefix . $name, $seed); - } - - /** - * Adds multiple fields. - * - * @param array $fields - * - * @return $this - */ - public function addFields($fields = []) - { - foreach ($fields as $field) { - if (is_array($field)) { - $name = $field[0]; - unset($field[0]); - $this->addField($name, $field); - } else { - $this->addField($field); - } - } - - return $this; - } - - /** - * Adds any object to owner model. - */ - public function add(object $object, array $defaults = []): object - { - if (!is_array($defaults)) { - $defaults = ['name' => $defaults]; - } - - $defaults['join'] = $this; - - return $this->owner->add($object, $defaults); - } - - /** - * Another join will be attached to a current join. - * - * @param array $defaults - * - * @return Join - */ - public function join(string $foreign_table, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = ['master_field' => $defaults]; - } - $defaults['join'] = $this; - - return $this->owner->join($foreign_table, $defaults); - } - - /** - * Another leftJoin will be attached to a current join. - * - * @param array $defaults - * - * @return Join - */ - public function leftJoin(string $foreign_table, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = ['master_field' => $defaults]; - } - $defaults['join'] = $this; - - return $this->owner->leftJoin($foreign_table, $defaults); - } - - /** - * weakJoin will be attached to a current join. - * - * @todo NOT IMPLEMENTED! weakJoin method does not exist! - * - * @param array $defaults - * - * @return - */ - /* - public function weakJoin($defaults = []) - { - $defaults['join'] = $this; - - return $this->owner->weakJoin($defaults); - } - */ - - /** - * Creates reference based on a field from the join. - * - * @param string $link - * @param array $defaults - * - * @return Reference\HasOne - */ - public function hasOne($link, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = ['model' => $defaults ?: 'Model_' . $link]; - } - - $defaults['join'] = $this; - - return $this->owner->hasOne($link, $defaults); - } - - /** - * Creates reference based on the field from the join. - * - * @param string $link - * @param array $defaults - * - * @return Reference\HasMany - */ - public function hasMany($link, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = ['model' => $defaults ?: 'Model_' . $link]; - } - - $defaults = array_merge([ - 'our_field' => $this->id_field, - 'their_field' => $this->owner->table . '_' . $this->id_field, - ], $defaults); - - return $this->owner->hasMany($link, $defaults); - } - - /** - * Wrapper for containsOne that will associate field - * with join. - * - * @todo NOT IMPLEMENTED ! - * - * @param Model $model - * @param array $defaults - * - * @return ??? - */ - /* - public function containsOne($model, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = [$defaults]; - } - - if (is_string($defaults[0])) { - $defaults[0] = $this->addField($defaults[0], ['system' => true]); - } - - return parent::containsOne($model, $defaults); - } - */ - - /** - * Wrapper for containsMany that will associate field - * with join. - * - * @todo NOT IMPLEMENTED ! - * - * @param Model $model - * @param array $defaults - * - * @return ??? - */ - /* - public function containsMany($model, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = [$defaults]; - } - - if (is_string($defaults[0])) { - $defaults[0] = $this->addField($defaults[0], ['system' => true]); - } - - return parent::containsMany($model, $defaults); - } - */ - - /** - * Will iterate through this model by pulling - * - fields - * - references - * - conditions. - * - * and then will apply them locally. If you think that any fields - * could clash, then use ['prefix'=>'m2'] which will be pre-pended - * to all the fields. Conditions will be automatically mapped. - * - * @todo NOT IMPLEMENTED ! - * - * @param Model $model - * @param array $defaults - */ - /* - public function importModel($model, $defaults = []) - { - // not implemented yet !!! - } - */ - - /** - * Joins with the primary table of the model and - * then import all of the data into our model. - * - * @todo NOT IMPLEMENTED! - * - * @param Model $model - * @param array $fields - */ - /* - public function weakJoinModel($model, $fields = []) - { - if (!is_object($model)) { - $model = $this->owner->connection->add($model); - } - $j = $this->join($model->table); - - $j->importModel($model); - - return $j; - } - */ - - /** - * Set value. - * - * @param string $field - * @param mixed $value - * - * @return $this - */ - public function set($field, $value) - { - $this->save_buffer[$field] = $value; - - return $this; - } - - /** - * Clears id and save buffer. - */ - protected function afterUnload() - { - $this->id = null; - $this->save_buffer = []; - } } diff --git a/src/Join/Array_.php b/src/Join/Array_.php index b3a5cc1b76..1f162d0371 100644 --- a/src/Join/Array_.php +++ b/src/Join/Array_.php @@ -4,161 +4,13 @@ namespace atk4\data\Join; -use atk4\data\Exception; -use atk4\data\Join; -use atk4\data\Model; +if (!class_exists(\SebastianBergmann\CodeCoverage\CodeCoverage::class, false)) { + 'trigger_error'('Class atk4\data\Join\Array_ is deprecated. Use atk4\data\Persistence\Array_\Join instead', E_USER_DEPRECATED); +} /** - * Join\Array_ class. + * @deprecated use \atk4\data\Persistence\Array_\Join instead - will be removed in dec-2020 */ -class Array_ extends Join +class Array_ extends \atk4\data\Persistence\Array_\Join { - /** - * This method is to figure out stuff. - */ - public function init(): void - { - parent::init(); - - // If kind is not specified, figure out join type - if (!isset($this->kind)) { - $this->kind = $this->weak ? 'left' : 'inner'; - } - - // Add necessary hooks - if ($this->reverse) { - $this->owner->onHook(Model::HOOK_AFTER_INSERT, \Closure::fromCallable([$this, 'afterInsert']), [], -5); - $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate']), [], -5); - $this->owner->onHook(Model::HOOK_BEFORE_DELETE, \Closure::fromCallable([$this, 'doDelete']), [], -5); - } else { - $this->owner->onHook(Model::HOOK_BEFORE_INSERT, \Closure::fromCallable([$this, 'beforeInsert'])); - $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); - $this->owner->onHook(Model::HOOK_AFTER_DELETE, \Closure::fromCallable([$this, 'doDelete'])); - $this->owner->onHook(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); - } - } - - /** - * Called from afterLoad hook. - * - * @param Model $model - */ - public function afterLoad($model) - { - // we need to collect ID - $this->id = $model->data[$this->master_field]; - if (!$this->id) { - return; - } - - try { - $data = $model->persistence->load($model, $this->id, $this->foreign_table); - } catch (Exception $e) { - throw (new Exception('Unable to load joined record', $e->getCode(), $e)) - ->addMoreInfo('table', $this->foreign_table) - ->addMoreInfo('id', $this->id); - } - $model->data = array_merge($data, $model->data); - } - - /** - * Called from beforeInsert hook. - * - * @param Model $model - * @param array $data - */ - public function beforeInsert($model, &$data) - { - if ($this->weak) { - return; - } - - if ($model->hasField($this->master_field) && $model->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->owner->persistence; - - $this->id = $persistence->insert( - $model, - $this->save_buffer, - $this->foreign_table - ); - - $data[$this->master_field] = $this->id; - - //$this->owner->set($this->master_field, $this->id); - } - - /** - * Called from afterInsert hook. - * - * @param Model $model - * @param mixed $id - */ - public function afterInsert($model, $id) - { - if ($this->weak) { - return; - } - - $this->save_buffer[$this->foreign_field] = isset($this->join) ? $this->join->id : $id; - - $persistence = $this->persistence ?: $this->owner->persistence; - - $this->id = $persistence->insert( - $model, - $this->save_buffer, - $this->foreign_table - ); - } - - /** - * Called from beforeUpdate hook. - * - * @param Model $model - * @param array $data - */ - public function beforeUpdate($model, &$data) - { - if ($this->weak) { - return; - } - - $persistence = $this->persistence ?: $this->owner->persistence; - - $this->id = $persistence->update( - $model, - $this->id, - $this->save_buffer, - $this->foreign_table - ); - } - - /** - * Called from beforeDelete and afterDelete hooks. - * - * @param Model $model - * @param mixed $id - */ - public function doDelete($model, $id) - { - if ($this->weak) { - return; - } - - $persistence = $this->persistence ?: $this->owner->persistence; - - $persistence->delete( - $model, - $this->id, - $this->foreign_table - ); - - $this->id = null; - } } diff --git a/src/Join/Sql.php b/src/Join/Sql.php index f118cbca67..9da9d04d25 100644 --- a/src/Join/Sql.php +++ b/src/Join/Sql.php @@ -4,284 +4,13 @@ namespace atk4\data\Join; -use atk4\data\Join; -use atk4\data\Model; -use atk4\data\Persistence; +if (!class_exists(\SebastianBergmann\CodeCoverage\CodeCoverage::class, false)) { + 'trigger_error'('Class atk4\data\Join\Sql is deprecated. Use atk4\data\Persistence\Sql\Join instead', E_USER_DEPRECATED); +} /** - * Join\Sql class. - * - * @property Persistence\Sql $persistence - * @property Sql $join + * @deprecated use \atk4\data\Persistence\Sql\Join instead - will be removed in dec-2020 */ -class Sql extends Join implements \atk4\dsql\Expressionable +class Sql extends \atk4\data\Persistence\Sql\Join { - /** - * By default we create ON expression ourselves, but if you want to specify - * it, use the 'on' property. - * - * @var \atk4\dsql\Expression - */ - protected $on; - - /** - * Will use either foreign_alias or create #join_
. - */ - public function getDesiredName(): string - { - return '_' . ($this->foreign_alias ?: $this->foreign_table[0]); - } - - /** - * Returns DSQL Expression. - * - * @param \atk4\dsql\Expression $q - * - * @return \atk4\dsql\Expression - */ - public function getDsqlExpression($q) - { - /* - // If our Model has expr() method (inherited from Persistence\Sql) then use it - if ($this->owner->hasMethod('expr')) { - return $this->owner->expr('{}.{}', [$this->foreign_alias, $this->foreign_field]); - } - - // Otherwise call it from expression itself - return $q->expr('{}.{}', [$this->foreign_alias, $this->foreign_field]); - */ - - // Romans: Join\Sql shouldn't even be called if expr is undefined. I think we should leave it here to produce error. - return $this->owner->expr('{}.{}', [$this->foreign_alias, $this->foreign_field]); - } - - /** - * This method is to figure out stuff. - */ - public function init(): void - { - parent::init(); - - $this->owner->persistence_data['use_table_prefixes'] = true; - - // If kind is not specified, figure out join type - if (!isset($this->kind)) { - $this->kind = $this->weak ? 'left' : 'inner'; - } - - // Our short name will be unique - if (!$this->foreign_alias) { - $this->foreign_alias = ($this->owner->table_alias ?: '') . $this->short_name; - } - - $this->owner->onHook(Persistence\Sql::HOOK_INIT_SELECT_QUERY, \Closure::fromCallable([$this, 'initSelectQuery'])); - - // Add necessary hooks - if ($this->reverse) { - $this->owner->onHook(Model::HOOK_AFTER_INSERT, \Closure::fromCallable([$this, 'afterInsert'])); - $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); - $this->owner->onHook(Model::HOOK_BEFORE_DELETE, \Closure::fromCallable([$this, 'doDelete']), [], -5); - $this->owner->onHook(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 (is_string($this->master_field)) { - if (!$this->owner->hasField($this->master_field)) { - if ($this->join) { - $f = $this->join->addField($this->master_field, ['system' => true, 'read_only' => true]); - } else { - $f = $this->owner->addField($this->master_field, ['system' => true, 'read_only' => true]); - } - $this->master_field = $f->short_name; - } - } - - $this->owner->onHook(Model::HOOK_BEFORE_INSERT, \Closure::fromCallable([$this, 'beforeInsert']), [], -5); - $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); - $this->owner->onHook(Model::HOOK_AFTER_DELETE, \Closure::fromCallable([$this, 'doDelete'])); - $this->owner->onHook(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); - } - } - - /** - * Returns DSQL query. - * - * @return \atk4\dsql\Query - */ - public function dsql() - { - $dsql = $this->owner->persistence->initQuery($this->owner); - $dsql->reset('table'); - $dsql->table($this->foreign_table, $this->foreign_alias); - - return $dsql; - } - - /** - * Before query is executed, this method will be called. - * - * @param Model $model - * @param \atk4\dsql\Query $query - */ - public function initSelectQuery($model, $query) - { - // if ON is set, we don't have to worry about anything - if ($this->on) { - $query->join( - $this->foreign_table, - $this->on instanceof \atk4\dsql\Expression ? $this->on : $model->expr($this->on), - $this->kind, - $this->foreign_alias - ); - - return; - } - - $query->join( - $this->foreign_table, - $model->expr('{{}}.{} = {}', [ - ($this->foreign_alias ?: $this->foreign_table), - $this->foreign_field, - $this->owner->getField($this->master_field), - ]), - $this->kind, - $this->foreign_alias - ); - - /* - if ($this->reverse) { - $query->field([$this->short_name => ($this->join ?: - ( - ($this->owner->table_alias ?: $this->owner->table) - .'.'.$this->master_field) - )]); - } else { - $query->field([$this->short_name => $this->foreign_alias.'.'.$this->foreign_field]); - } - */ - } - - /** - * Called from afterLoad hook. - * - * @param Model $model - */ - public function afterLoad($model) - { - // we need to collect ID - if (isset($model->data[$this->short_name])) { - $this->id = $model->data[$this->short_name]; - unset($model->data[$this->short_name]); - } - } - - /** - * Called from beforeInsert hook. - * - * @param Model $model - * @param array $data - */ - public function beforeInsert($model, &$data) - { - if ($this->weak) { - return; - } - - // The value for the master_field is set, so we are going to use existing record anyway - if ($model->hasField($this->master_field) && $model->get($this->master_field)) { - return; - } - - $insert = $this->dsql(); - $insert->mode('insert'); - $insert->set($model->persistence->typecastSaveRow($model, $this->save_buffer)); - $this->save_buffer = []; - $insert->set($this->foreign_field, null); - $insert->insert(); - $this->id = $this->owner->persistence->lastInsertId($this->owner); - - if ($this->join) { - $this->join->set($this->master_field, $this->id); - } else { - $data[$this->master_field] = $this->id; - } - } - - /** - * Called from afterInsert hook. - * - * @param Model $model - * @param mixed $id - */ - public function afterInsert($model, $id) - { - if ($this->weak) { - return; - } - - $insert = $this->dsql(); - $insert->set($model->persistence->typecastSaveRow($model, $this->save_buffer)); - $this->save_buffer = []; - $insert - ->set( - $this->foreign_field, - isset($this->join) ? $this->join->id : $id - ); - $insert->insert(); - $this->id = $this->owner->persistence->lastInsertId($this->owner); - } - - /** - * Called from beforeUpdate hook. - * - * @param Model $model - * @param array $data - */ - public function beforeUpdate($model, &$data) - { - if ($this->weak) { - return; - } - - if (!$this->save_buffer) { - return; - } - - $update = $this->dsql(); - $update->set($model->persistence->typecastSaveRow($model, $this->save_buffer)); - $this->save_buffer = []; - - if ($this->reverse) { - $update->where($this->foreign_field, $model->id); - } else { - $update->where($this->foreign_field, $model->get($this->master_field)); - } - - $update->update(); - } - - /** - * Called from beforeDelete and afterDelete hooks. - * - * @param Model $model - * @param mixed $id - */ - public function doDelete($model, $id) - { - if ($this->weak) { - return; - } - - $delete = $this->dsql(); - if ($this->reverse) { - $delete->where($this->foreign_field, $this->owner->id); - } else { - $delete->where($this->foreign_field, $this->owner->get($this->master_field)); - } - - $delete->delete()->execute(); - } } diff --git a/src/Model.php b/src/Model.php index 46a6e91e98..eb2ea75360 100644 --- a/src/Model.php +++ b/src/Model.php @@ -34,6 +34,7 @@ class Model implements \IteratorAggregate use CollectionTrait; use ReadableCaptionTrait; use Model\ReferencesTrait; + use Model\JoinsTrait; use Model\UserActionsTrait; /** @const string */ @@ -93,13 +94,6 @@ class Model implements \IteratorAggregate */ public $_default_seed_addExpression = [Field\Callback::class]; - /** - * The class used by join() method. - * - * @var string|array - */ - public $_default_seed_join = [Join::class]; - /** * @var array Collection containing Field Objects - using key as the field system name */ @@ -1896,56 +1890,6 @@ public function action($mode, $args = []) // }}} - // {{{ Join support - - /** - * Creates an objects that describes relationship between multiple tables (or collections). - * - * When object is loaded, then instead of pulling all the data from a single table, - * join will also query $foreign_table in order to find additional fields. When inserting - * the record will be also added inside $foreign_table and relationship will be maintained. - * - * @param array $defaults - * - * @return Join - */ - public function join(string $foreign_table, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = ['master_field' => $defaults]; - } elseif (isset($defaults[0])) { - $defaults['master_field'] = $defaults[0]; - unset($defaults[0]); - } - - $defaults[0] = $foreign_table; - - $c = $this->_default_seed_join; - - return $this->add($this->factory($c, $defaults)); - } - - /** - * Left Join support. - * - * @see join() - * - * @param array $defaults - * - * @return Join - */ - public function leftJoin(string $foreign_table, $defaults = []) - { - if (!is_array($defaults)) { - $defaults = ['master_field' => $defaults]; - } - $defaults['weak'] = true; - - return $this->join($foreign_table, $defaults); - } - - // }}} - // {{{ Expressions /** diff --git a/src/Model/Join.php b/src/Model/Join.php new file mode 100644 index 0000000000..6bec7fd451 --- /dev/null +++ b/src/Model/Join.php @@ -0,0 +1,470 @@ +persistence. + * + * @var \atk4\data\Persistence + */ + protected $persistence; + + /** + * ID used by a joined table. + * + * @var mixed + */ + protected $id; + + /** + * 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'] + * as second argument to join(). + * + * @var string + */ + protected $kind; + + /** + * Is our join weak? Weak join will stop you from touching foreign table. + * + * @var bool + */ + protected $weak = false; + + /** + * Normally the foreign table is saved first, then it's ID is used in the + * primary table. When deleting, the primary table record is deleted first + * which is followed by the foreign table record. + * + * If you are using the following syntax: + * + * $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 + * will be set to `true`. You can specify value of this property. + * + * @var bool + */ + protected $reverse; + + /** + * Field to be used for matching inside master field. 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'. + * + * @var string + */ + protected $foreign_field; + + /** + * A short symbolic name that will be used as an alias for the joined table. + * + * @var string + */ + public $foreign_alias; + + /** + * When $prefix is set, then all the fields generated through + * our wrappers will be automatically prefixed inside the model. + * + * @var string + */ + protected $prefix = ''; + + /** + * Data which is populated here as the save/insert progresses. + * + * @var array + */ + protected $save_buffer = []; + + /** + * When join is done on another join. + * + * @var Join + */ + protected $join; + + /** + * Default constructor. Will copy argument into properties. + * + * @param array $defaults + */ + public function __construct($foreign_table = null) + { + if ($foreign_table !== null) { + $this->foreign_table = $foreign_table; + } + } + + /** + * Will use either foreign_alias or create #join_
. + */ + public function getDesiredName(): string + { + return '#join_' . $this->foreign_table; + } + + /** + * Initialization. + */ + public function init(): void + { + $this->_init(); + + // handle foreign table containing a dot + if (is_string($this->foreign_table) && strpos($this->foreign_table, '.') !== false) { + if (!isset($this->reverse)) { + $this->reverse = true; + if (isset($this->master_field)) { + // both master and foreign fields are set + + // master_field exists, no we will use that + // if (!is_object($this->master_field) && !$this->owner->hasField($this->master_field)) { + throw (new Exception('You are trying to link tables on non-id fields. This is not implemented yet')) + ->addMoreInfo('condition', $this->owner->table . '.' . $this->master_field . ' = ' . $this->foreign_table); + // } $this->reverse = 'link'; + } + } + + // split by LAST dot in foreign_table name + [$this->foreign_table, $this->foreign_field] = preg_split('/\.+(?=[^\.]+$)/', $this->foreign_table); + + if (!$this->master_field) { + $this->master_field = 'id'; + } + } else { + $this->reverse = false; + $id_field = $this->owner->id_field ?: 'id'; + if (!$this->master_field) { + $this->master_field = $this->foreign_table . '_' . $id_field; + } + + if (!$this->foreign_field) { + $this->foreign_field = $id_field; + } + } + + $this->owner->onHook(Model::HOOK_AFTER_UNLOAD, \Closure::fromCallable([$this, 'afterUnload'])); + } + + /** + * Adding field into join will automatically associate that field + * with this join. That means it won't be loaded from $table, but + * form the join instead. + * + * @param string $name + * @param array $seed + * + * @return \atk4\data\Field + */ + public function addField($name, $seed = []) + { + if ($seed && !is_array($seed)) { + $seed = [$seed]; + } + $seed['join'] = $this; + + return $this->owner->addField($this->prefix . $name, $seed); + } + + /** + * Adds multiple fields. + * + * @param array $fields + * + * @return $this + */ + public function addFields($fields = []) + { + foreach ($fields as $field) { + if (is_array($field)) { + $name = $field[0]; + unset($field[0]); + $this->addField($name, $field); + } else { + $this->addField($field); + } + } + + return $this; + } + + /** + * Adds any object to owner model. + */ + public function add(object $object, array $defaults = []): object + { + if (!is_array($defaults)) { + $defaults = ['name' => $defaults]; + } + + $defaults['join'] = $this; + + return $this->owner->add($object, $defaults); + } + + /** + * Another join will be attached to a current join. + * + * @param array $defaults + * + * @return static + */ + public function join(string $foreign_table, $defaults = []) + { + if (!is_array($defaults)) { + $defaults = ['master_field' => $defaults]; + } + $defaults['join'] = $this; + + return $this->owner->join($foreign_table, $defaults); + } + + /** + * Another leftJoin will be attached to a current join. + * + * @param array $defaults + * + * @return Join + */ + public function leftJoin(string $foreign_table, $defaults = []) + { + if (!is_array($defaults)) { + $defaults = ['master_field' => $defaults]; + } + $defaults['join'] = $this; + + return $this->owner->leftJoin($foreign_table, $defaults); + } + + /** + * weakJoin will be attached to a current join. + * + * @todo NOT IMPLEMENTED! weakJoin method does not exist! + * + * @param array $defaults + * + * @return + */ + /* + public function weakJoin($defaults = []) + { + $defaults['join'] = $this; + + return $this->owner->weakJoin($defaults); + } + */ + + /** + * Creates reference based on a field from the join. + * + * @param string $link + * @param array $defaults + * + * @return Reference\HasOne + */ + public function hasOne($link, $defaults = []) + { + if (!is_array($defaults)) { + $defaults = ['model' => $defaults ?: 'Model_' . $link]; + } + + $defaults['join'] = $this; + + return $this->owner->hasOne($link, $defaults); + } + + /** + * Creates reference based on the field from the join. + * + * @param string $link + * @param array $defaults + * + * @return Reference\HasMany + */ + public function hasMany($link, $defaults = []) + { + if (!is_array($defaults)) { + $defaults = ['model' => $defaults ?: 'Model_' . $link]; + } + + $defaults = array_merge([ + 'our_field' => $this->id_field, + 'their_field' => $this->owner->table . '_' . $this->id_field, + ], $defaults); + + return $this->owner->hasMany($link, $defaults); + } + + /** + * Wrapper for containsOne that will associate field + * with join. + * + * @todo NOT IMPLEMENTED ! + * + * @param Model $model + * @param array $defaults + * + * @return ??? + */ + /* + public function containsOne($model, $defaults = []) + { + if (!is_array($defaults)) { + $defaults = [$defaults]; + } + + if (is_string($defaults[0])) { + $defaults[0] = $this->addField($defaults[0], ['system' => true]); + } + + return parent::containsOne($model, $defaults); + } + */ + + /** + * Wrapper for containsMany that will associate field + * with join. + * + * @todo NOT IMPLEMENTED ! + * + * @param Model $model + * @param array $defaults + * + * @return ??? + */ + /* + public function containsMany($model, $defaults = []) + { + if (!is_array($defaults)) { + $defaults = [$defaults]; + } + + if (is_string($defaults[0])) { + $defaults[0] = $this->addField($defaults[0], ['system' => true]); + } + + return parent::containsMany($model, $defaults); + } + */ + + /** + * Will iterate through this model by pulling + * - fields + * - references + * - conditions. + * + * and then will apply them locally. If you think that any fields + * could clash, then use ['prefix'=>'m2'] which will be pre-pended + * to all the fields. Conditions will be automatically mapped. + * + * @todo NOT IMPLEMENTED ! + * + * @param Model $model + * @param array $defaults + */ + /* + public function importModel($model, $defaults = []) + { + // not implemented yet !!! + } + */ + + /** + * Joins with the primary table of the model and + * then import all of the data into our model. + * + * @todo NOT IMPLEMENTED! + * + * @param Model $model + * @param array $fields + */ + /* + public function weakJoinModel($model, $fields = []) + { + if (!is_object($model)) { + $model = $this->owner->connection->add($model); + } + $j = $this->join($model->table); + + $j->importModel($model); + + return $j; + } + */ + + /** + * Set value. + * + * @param string $field + * @param mixed $value + * + * @return $this + */ + public function set($field, $value) + { + $this->save_buffer[$field] = $value; + + return $this; + } + + /** + * Clears id and save buffer. + */ + protected function afterUnload() + { + $this->id = null; + $this->save_buffer = []; + } +} diff --git a/src/Model/JoinsTrait.php b/src/Model/JoinsTrait.php new file mode 100644 index 0000000000..5bbe54d6c5 --- /dev/null +++ b/src/Model/JoinsTrait.php @@ -0,0 +1,58 @@ + $defaults]; + } elseif (isset($defaults[0])) { + $defaults['master_field'] = $defaults[0]; + unset($defaults[0]); + } + + $defaults[0] = $foreignTable; + + return $this->add($this->factory($this->_default_seed_join, $defaults)); + } + + /** + * Left Join support. + * + * @see join() + * + * @param array $defaults + */ + public function leftJoin(string $foreignTable, $defaults = []): Join + { + if (!is_array($defaults)) { + $defaults = ['master_field' => $defaults]; + } + $defaults['weak'] = true; + + return $this->join($foreignTable, $defaults); + } +} diff --git a/src/Persistence/Array_.php b/src/Persistence/Array_.php index 421760ee59..dbd8a006cf 100644 --- a/src/Persistence/Array_.php +++ b/src/Persistence/Array_.php @@ -53,7 +53,7 @@ public function add(Model $model, array $defaults = []): Model } $defaults = array_merge([ - '_default_seed_join' => [\atk4\data\Join\Array_::class], + '_default_seed_join' => [Array_\Join::class], ], $defaults); $model = parent::add($model, $defaults); diff --git a/src/Persistence/Array_/Join.php b/src/Persistence/Array_/Join.php new file mode 100644 index 0000000000..bfe628ed6d --- /dev/null +++ b/src/Persistence/Array_/Join.php @@ -0,0 +1,163 @@ +kind)) { + $this->kind = $this->weak ? 'left' : 'inner'; + } + + // Add necessary hooks + if ($this->reverse) { + $this->owner->onHook(Model::HOOK_AFTER_INSERT, \Closure::fromCallable([$this, 'afterInsert']), [], -5); + $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate']), [], -5); + $this->owner->onHook(Model::HOOK_BEFORE_DELETE, \Closure::fromCallable([$this, 'doDelete']), [], -5); + } else { + $this->owner->onHook(Model::HOOK_BEFORE_INSERT, \Closure::fromCallable([$this, 'beforeInsert'])); + $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); + $this->owner->onHook(Model::HOOK_AFTER_DELETE, \Closure::fromCallable([$this, 'doDelete'])); + $this->owner->onHook(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); + } + } + + /** + * Called from afterLoad hook. + * + * @param Model $model + */ + public function afterLoad($model) + { + // we need to collect ID + $this->id = $model->data[$this->master_field]; + if (!$this->id) { + return; + } + + try { + $data = $model->persistence->load($model, $this->id, $this->foreign_table); + } catch (Exception $e) { + throw (new Exception('Unable to load joined record', $e->getCode(), $e)) + ->addMoreInfo('table', $this->foreign_table) + ->addMoreInfo('id', $this->id); + } + $model->data = array_merge($data, $model->data); + } + + /** + * Called from beforeInsert hook. + * + * @param Model $model + * @param array $data + */ + public function beforeInsert($model, &$data) + { + if ($this->weak) { + return; + } + + if ($model->hasField($this->master_field) && $model->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->owner->persistence; + + $this->id = $persistence->insert( + $model, + $this->save_buffer, + $this->foreign_table + ); + + $data[$this->master_field] = $this->id; + + //$this->owner->set($this->master_field, $this->id); + } + + /** + * Called from afterInsert hook. + * + * @param Model $model + * @param mixed $id + */ + public function afterInsert($model, $id) + { + if ($this->weak) { + return; + } + + $this->save_buffer[$this->foreign_field] = isset($this->join) ? $this->join->id : $id; + + $persistence = $this->persistence ?: $this->owner->persistence; + + $this->id = $persistence->insert( + $model, + $this->save_buffer, + $this->foreign_table + ); + } + + /** + * Called from beforeUpdate hook. + * + * @param Model $model + * @param array $data + */ + public function beforeUpdate($model, &$data) + { + if ($this->weak) { + return; + } + + $persistence = $this->persistence ?: $this->owner->persistence; + + $this->id = $persistence->update( + $model, + $this->id, + $this->save_buffer, + $this->foreign_table + ); + } + + /** + * Called from beforeDelete and afterDelete hooks. + * + * @param Model $model + * @param mixed $id + */ + public function doDelete($model, $id) + { + if ($this->weak) { + return; + } + + $persistence = $this->persistence ?: $this->owner->persistence; + + $persistence->delete( + $model, + $this->id, + $this->foreign_table + ); + + $this->id = null; + } +} diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index e628a6a06f..4fdbac4b26 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -71,7 +71,7 @@ class Sql extends Persistence * * @var string */ - public $_default_seed_join = [\atk4\data\Join\Sql::class]; + public $_default_seed_join = [Sql\Join::class]; /** * Constructor. diff --git a/src/Persistence/Sql/Join.php b/src/Persistence/Sql/Join.php new file mode 100644 index 0000000000..94461292e0 --- /dev/null +++ b/src/Persistence/Sql/Join.php @@ -0,0 +1,272 @@ +. + */ + public function getDesiredName(): string + { + return '_' . ($this->foreign_alias ?: $this->foreign_table[0]); + } + + /** + * Returns DSQL Expression. + * + * @param \atk4\dsql\Expression $q + * + * @return \atk4\dsql\Expression + */ + public function getDsqlExpression($q) + { + /* + // If our Model has expr() method (inherited from Persistence\Sql) then use it + if ($this->owner->hasMethod('expr')) { + return $this->owner->expr('{}.{}', [$this->foreign_alias, $this->foreign_field]); + } + + // Otherwise call it from expression itself + return $q->expr('{}.{}', [$this->foreign_alias, $this->foreign_field]); + */ + + // Romans: Join\Sql shouldn't even be called if expr is undefined. I think we should leave it here to produce error. + return $this->owner->expr('{}.{}', [$this->foreign_alias, $this->foreign_field]); + } + + /** + * This method is to figure out stuff. + */ + public function init(): void + { + parent::init(); + + $this->owner->persistence_data['use_table_prefixes'] = true; + + // If kind is not specified, figure out join type + if (!isset($this->kind)) { + $this->kind = $this->weak ? 'left' : 'inner'; + } + + // Our short name will be unique + if (!$this->foreign_alias) { + $this->foreign_alias = ($this->owner->table_alias ?: '') . $this->short_name; + } + + $this->owner->onHook(Persistence\Sql::HOOK_INIT_SELECT_QUERY, \Closure::fromCallable([$this, 'initSelectQuery'])); + + // Add necessary hooks + if ($this->reverse) { + $this->owner->onHook(Model::HOOK_AFTER_INSERT, \Closure::fromCallable([$this, 'afterInsert'])); + $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); + $this->owner->onHook(Model::HOOK_BEFORE_DELETE, \Closure::fromCallable([$this, 'doDelete']), [], -5); + $this->owner->onHook(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 (is_string($this->master_field)) { + if (!$this->owner->hasField($this->master_field)) { + if ($this->join) { + $f = $this->join->addField($this->master_field, ['system' => true, 'read_only' => true]); + } else { + $f = $this->owner->addField($this->master_field, ['system' => true, 'read_only' => true]); + } + $this->master_field = $f->short_name; + } + } + + $this->owner->onHook(Model::HOOK_BEFORE_INSERT, \Closure::fromCallable([$this, 'beforeInsert']), [], -5); + $this->owner->onHook(Model::HOOK_BEFORE_UPDATE, \Closure::fromCallable([$this, 'beforeUpdate'])); + $this->owner->onHook(Model::HOOK_AFTER_DELETE, \Closure::fromCallable([$this, 'doDelete'])); + $this->owner->onHook(Model::HOOK_AFTER_LOAD, \Closure::fromCallable([$this, 'afterLoad'])); + } + } + + /** + * Returns DSQL query. + * + * @return \atk4\dsql\Query + */ + public function dsql() + { + $dsql = $this->owner->persistence->initQuery($this->owner); + + return $dsql->reset('table')->table($this->foreign_table, $this->foreign_alias); + } + + /** + * Before query is executed, this method will be called. + * + * @param Model $model + * @param \atk4\dsql\Query $query + */ + public function initSelectQuery(Model $model, Query $query) + { + // if ON is set, we don't have to worry about anything + if ($this->on) { + $query->join( + $this->foreign_table, + $this->on instanceof \atk4\dsql\Expression ? $this->on : $model->expr($this->on), + $this->kind, + $this->foreign_alias + ); + + return; + } + + $query->join( + $this->foreign_table, + $model->expr('{{}}.{} = {}', [ + ($this->foreign_alias ?: $this->foreign_table), + $this->foreign_field, + $this->owner->getField($this->master_field), + ]), + $this->kind, + $this->foreign_alias + ); + + /* + if ($this->reverse) { + $query->field([$this->short_name => ($this->join ?: + ( + ($this->owner->table_alias ?: $this->owner->table) + .'.'.$this->master_field) + )]); + } else { + $query->field([$this->short_name => $this->foreign_alias.'.'.$this->foreign_field]); + } + */ + } + + /** + * Called from afterLoad hook. + * + * @param Model $model + */ + public function afterLoad(Model $model) + { + // we need to collect ID + if (isset($model->data[$this->short_name])) { + $this->id = $model->data[$this->short_name]; + unset($model->data[$this->short_name]); + } + } + + /** + * Called from beforeInsert hook. + * + * @param Model $model + * @param array $data + */ + public function beforeInsert(Model $model, &$data) + { + if ($this->weak) { + return; + } + + // The value for the master_field is set, so we are going to use existing record anyway + if ($model->hasField($this->master_field) && $model->get($this->master_field)) { + return; + } + + $query = $this->dsql(); + $query->mode('insert'); + $query->set($model->persistence->typecastSaveRow($model, $this->save_buffer)); + $this->save_buffer = []; + $query->set($this->foreign_field, null); + $query->insert(); + $this->id = $this->owner->persistence->lastInsertId($this->owner); + + if ($this->join) { + $this->join->set($this->master_field, $this->id); + } else { + $data[$this->master_field] = $this->id; + } + } + + /** + * Called from afterInsert hook. + * + * @param Model $model + * @param mixed $id + */ + public function afterInsert(Model $model, $id) + { + if ($this->weak) { + return; + } + + $query = $this->dsql(); + $query->set($model->persistence->typecastSaveRow($model, $this->save_buffer)); + $this->save_buffer = []; + $query->set($this->foreign_field, $this->join->id ?? $id); + $query->insert(); + $this->id = $this->owner->persistence->lastInsertId($this->owner); + } + + /** + * Called from beforeUpdate hook. + * + * @param Model $model + * @param array $data + */ + public function beforeUpdate(Model $model, &$data) + { + if ($this->weak) { + return; + } + + if (!$this->save_buffer) { + return; + } + + $query = $this->dsql(); + $query->set($model->persistence->typecastSaveRow($model, $this->save_buffer)); + $this->save_buffer = []; + + $id = $this->reverse ? $model->id : $model->get($this->master_field); + + $query->where($this->foreign_field, $id)->update(); + } + + /** + * Called from beforeDelete and afterDelete hooks. + * + * @param Model $model + * @param mixed $id + */ + public function doDelete(Model $model, $id) + { + if ($this->weak) { + return; + } + + $id = $this->reverse ? $this->owner->id : $this->owner->get($this->master_field); + + $this->dsql()->where($this->foreign_field, $id)->delete(); + } +}