From e1e0ae7947f60c268245e51ca12eafa5a45810dd Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Tue, 21 Jul 2020 23:50:47 +0200 Subject: [PATCH 01/57] [feature] introduce basic model scope functionality --- docs/conditions.rst | 177 +++++++++++++- src/Action/Iterator.php | 217 +++++++++++++---- src/Model.php | 187 ++++++--------- src/Model/Scope.php | 47 ++++ src/Model/Scope/AbstractCondition.php | 105 +++++++++ src/Model/Scope/BasicCondition.php | 327 ++++++++++++++++++++++++++ src/Model/Scope/CompoundCondition.php | 209 ++++++++++++++++ src/Persistence/Array_.php | 289 ++++++++++------------- src/Persistence/Sql.php | 102 +++----- src/Persistence/Static_.php | 12 +- tests/ConditionSqlTest.php | 4 +- tests/ConditionTest.php | 7 +- tests/LocaleTest.php | 2 +- tests/LookupSqlTest.php | 4 +- tests/PersistentArrayTest.php | 225 ++++++++++++++---- tests/PersistentSqlTest.php | 4 + tests/ReferenceSqlTest.php | 2 +- tests/ScopeTest.php | 319 +++++++++++++++++++++++++ 18 files changed, 1793 insertions(+), 446 deletions(-) create mode 100644 src/Model/Scope.php create mode 100644 src/Model/Scope/AbstractCondition.php create mode 100644 src/Model/Scope/BasicCondition.php create mode 100644 src/Model/Scope/CompoundCondition.php create mode 100644 tests/ScopeTest.php diff --git a/docs/conditions.rst b/docs/conditions.rst index 50a474c78..d5c11af5e 100644 --- a/docs/conditions.rst +++ b/docs/conditions.rst @@ -2,6 +2,8 @@ .. _DataSet: .. _conditions: +.. php:namespace:: atk4\data + ====================== Conditions and DataSet ====================== @@ -51,7 +53,7 @@ Operations Most database drivers will support the following additional operations:: - >, <, >=, <=, !=, in, not in + >, <, >=, <=, !=, in, not in, like, not like, regexp, not regexp The operation must be specified as second argument:: @@ -139,7 +141,7 @@ There are many other ways to set conditions, but you must always check if they are supported by the driver that you are using. Field Matching -------------- +-------------- Supported by: SQL (planned for Array, Mongo) @@ -164,7 +166,7 @@ inside [blah] should correspond to field names. SQL Expression Matching -------------------- +----------------------- .. php:method:: expr($expression, $arguments = []) @@ -249,3 +251,172 @@ do not need actual record by are only looking to traverse:: $u = new Model_User($db); $books = $u->withId(20)->ref('Books'); +Advanced Usage +============== + + +Model Scope +----------- + +Using the Model::addCondition method is the basic way to limit the model scope of records. Under the hood +Agile Data utilizes a special set of classes (BasicCondition and CompoundCondition) to apply the conditions as filters on records retrieved. +These classes can be used directly and independently from Model class. + +.. php:method:: scope() + +This method provides access to the model scope enablind conditions to be added:: + + $contact->scope()->add($condition); // adding condition to a model + +.. php:namespace:: atk4\data\Model\Scope + +.. php:class:: BasicCondition + +BasicCondition represents a simple condition in a form [field, operation, value], similar to the functionality of the +Model::addCondition method + +.. php:method:: __construct($key, $operator = null, $value = null); + +Creates condition object based on provided arguments. It acts similar to Model::addCondition + +$key can be Model field name, Field object, Expression object, FALSE (interpreted as Expression('false')), TRUE (interpreted as empty condition) or an array in the form of [$key, $operator, $value] +$operator can be one of the supported operators >, <, >=, <=, !=, in, not in, like, not like, regexp, not regexp +$value can be Field object, Expression object, array (interpreted as 'any of the values') or other scalar value + +If $value is omitted as argument then $operator is considered as $value and '=' is used as operator + +.. php:method:: negate(); + +Negates the condition, e.g:: + + // results in 'name is not John' + $condition = (new BasicCondition('name', 'John'))->negate(); + +.. php:method:: on(Model $model); + +Sets the model of BasicCondition to a clone of $model to avoid changes to the original object.:: + + // uses the $contact model to conver the condition to human readable words + $condition->on($contact)->toWords(); + +.. php:method:: toWords($asHtml = false); + +Converts the condition object to human readable words. Model must be set first. Recommended is use of Condition::on method to set the model +as it clones the model object first:: + + // results in 'Contact where Name is John' + (new BasicCondition('name', 'John'))->on($contactModel)->toWords(); + +.. php:class:: CompoundCondition + +CompoundCondition object has a single defined junction (AND or OR) and can contain multiple nested BasicCondition and/or CompoundCondition objects referred to as nested conditions. +This makes creating Model scopes with deep nested conditions possible, +e.g ((Name like 'ABC%' and Country = 'US') or (Name like 'CDE%' and (Country = 'DE' or Surname = 'XYZ'))) + +CompoundCondition can be created using new CompoundCondition() statement from an array or joining BasicCondition objects:: + + // $condition1 will be used as child-component + $condition1 = new BasicCondition('name', 'like', 'ABC%'); + + // $condition1 will be used as child-component + $condition2 = new BasicCondition('country', 'US'); + + // $compoundCondition1 is created using AND as junction and $condition1 and $condition2 as nested conditions + $compoundCondition1 = CompoundCondition::mergeAnd($condition1, $condition2); + + $condition3 = new BasicCondition('country', 'DE'); + $condition4 = new BasicCondition('surname', 'XYZ'); + + // $compoundCondition2 is created using OR as junction and $condition3 and $condition4 as nested conditions + $compoundCondition2 = CompoundCondition::mergeOr($condition3, $condition4); + + $condition5 = new BasicCondition('name', 'like', 'CDE%'); + + // $compoundCondition3 is created using AND as junction and $condition5 and $compoundCondition2 as nested conditions + $compoundCondition3 = CompoundCondition::mergeAnd($condition5, $compoundCondition2); + + // $compoundCondition is created using OR as junction and $compoundCondition1 and $compoundCondition3 as nested conditions + $compoundCondition = CompoundCondition::mergeOr($compoundCondition1, $compoundCondition3); + + +CompoundCondition is an independent object not related to any model. Applying scope to model is using the Model::scope()->add($condition) method:: + + $contact->scope()->add($condition); // adding condition to a model + $contact->scope()->add($conditionXYZ); // adding more conditions + +.. php:method:: __construct($nestedConditions = [], $junction = CompoundCondition::AND); + +Creates a CompoundCondition object from an array:: + + // below will create 2 conditions and nest them in a compound conditions with AND junction + $compoundCondition1 = new CompoundCondition([ + ['name', 'like', 'ABC%'], + ['country', 'US'] + ]); + +.. php:method:: negate(); + +Negate method has behind the full map of conditions so any condition object can be negated, e.g negating '>=' results in '<', etc. +For compound conditionss this method is using De Morgan's laws, e.g:: + + // using $compoundCondition1 defined above + // results in "(Name not like 'ABC%') or (Country does not equal 'US')" + $compoundCondition1->negate(); + +.. php:method:: mergeAnd(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null); + +Merge number of conditions using AND as junction. Returns the resulting CompoundCondition object. + +.. php:method:: mergeOr(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null); + +Merge number of conditions using OR as junction. Returns the resulting CompoundCondition object. + +.. php:method:: simplify(); + +Peels off single nested conditions. Useful for converting (((field = value))) to field = value. + +.. php:method:: clear(); + +Clears the condition from nested conditions. + +.. php:method:: isOr(); + +Checks if scope components are joined by OR + +.. php:method:: isAnd(); + +Checks if scope components are joined by AND + +Conditions on Referenced Models +------------------------------- + +Agile Data allows for adding conditions on related models for retrieval of type 'model has references where'. + +Setting conditions on references can be done utilizing the Model::refLink method but there is a shorthand format +directly integrated with addCondition method using "/" to chain the reference names:: + + $contact->addCondition('company/country', 'US'); + +This will limit the $contact model to those whose company is in US. +'company' is the name of the reference in $contact model and 'country' is a field in the referenced model. + +If a condition must be set directly on the existence or number of referenced records the special symbol "#" can be +utilized to indicate the condition is on the number of records:: + + $contact->addCondition('company/tickets/#', '>', 3); + +This will limit the $contact model to those whose company have more than 3 tickets. +'company' and 'tickets' are the name of the chained references ('company' is a reference in the $contact model and +'tickets' is a reference in Company model) + +For applying conditions on existence of records the '?' (has any) and '!' (doesn't have any) special symbols can be used. +Although it is similar in functionality to checking ('company/tickets/#', '>', 0) or ('company/tickets/#', '=', 0) +'?' and '!' special symbols use optimized query and are much faster:: + + // Contact whose company has any tickets + $contact->addCondition('company/tickets/?'); + + // Contact whose company doesn't have any tickets + $contact->addCondition('company/tickets/!'); + + diff --git a/src/Action/Iterator.php b/src/Action/Iterator.php index 0efff92c9..6e30cf69b 100644 --- a/src/Action/Iterator.php +++ b/src/Action/Iterator.php @@ -4,7 +4,9 @@ namespace atk4\data\Action; -use Guzzle\Iterator\FilterIterator; +use atk4\data\Exception; +use atk4\data\Field; +use atk4\data\Model; /** * Class Array_ is returned by $model->action(). Compatible with DSQL to a certain point as it implements @@ -33,71 +35,175 @@ public function __construct(array $data) * * @return $this */ - public function where($field, $value) + public function filter(Model\Scope\AbstractCondition $condition) { - $this->generator = new \CallbackFilterIterator($this->generator, function ($row) use ($field, $value) { - // skip row. does not have field at all - if (!array_key_exists($field, $row)) { - return false; - } - - // has row and it matches - if ($row[$field] == $value) { - return true; - } - - return false; - }); + if (!$condition->isEmpty()) { + $this->generator = new \CallbackFilterIterator($this->generator, function ($row) use ($condition) { + return $this->match($row, $condition); + }); + } return $this; } /** - * Applies FilterIterator condition imitating the sql LIKE operator - $field LIKE %$value% | $value% | %$value. + * Calculates SUM|AVG|MIN|MAX aggragate values for $field. * + * @param string $fx * @param string $field - * @param string $value + * @param bool $coalesce * - * @return $this + * @return \atk4\data\Action\Iterator + */ + public function aggregate($fx, $field, $coalesce = false) + { + $result = 0; + $column = array_column($this->get(), $field); + + switch (strtoupper($fx)) { + case 'SUM': + $result = array_sum($column); + + break; + case 'AVG': + $column = $coalesce ? $column : array_filter($column, function ($value) { + return $value !== null; + }); + + $result = array_sum($column) / count($column); + + break; + case 'MAX': + $result = max($column); + + break; + case 'MIN': + $result = min($column); + + break; + default: + throw (new Exception('Persistence\Array_ driver action unsupported format')) + ->addMoreInfo('action', $fx); + } + + $this->generator = new \ArrayIterator([[$result]]); + + return $this; + } + + /** + * Checks if $row matches $condition. + * + * @return bool */ - public function like($field, $value) + protected function match(array $row, Model\Scope\AbstractCondition $condition) { - $this->generator = new \CallbackFilterIterator($this->generator, function ($row) use ($field, $value) { - // skip row. does not have field at all - if (!array_key_exists($field, $row)) { - return false; + $match = false; + + // simple condition + if ($condition instanceof Model\Scope\BasicCondition) { + $args = $condition->toArray(); + + $field = $args[0]; + $operator = $args[1] ?? null; + $value = $args[2] ?? null; + if (count($args) == 2) { + $value = $operator; + + $operator = '='; } - $fieldValStr = (string) $row[$field]; + if (!is_a($field, Field::class)) { + throw (new Exception('Persistence\Array_ driver condition unsupported format')) + ->addMoreInfo('reason', 'Unsupported object instance ' . get_class($field)) + ->addMoreInfo('condition', $condition); + } - $value = trim($value); - $clean_value = trim($value, '%'); - // the row field exists check the position of the "%"(s) - switch ($value) { - // case "%str%" - case substr($value, -1, 1) === '%' && substr($value, 0, 1) === '%': - return strpos($fieldValStr, $clean_value) !== false; + if (isset($row[$field->short_name])) { + $match = $this->evaluateIf($row[$field->short_name], $operator, $value); + } + } - break; - // case "str%" - case substr($value, -1, 1) === '%': - return substr($fieldValStr, 0, strlen($clean_value)) === $clean_value; + // nested conditions + if ($condition instanceof Model\Scope\CompoundCondition) { + $matches = []; - break; - // case "%str" - case substr($value, 0, 1) === '%': - return substr($fieldValStr, -strlen($clean_value)) === $clean_value; + foreach ($condition->getNestedConditions() as $nestedCondition) { + $matches[] = $subMatch = (bool) $this->match($row, $nestedCondition); + // do not check all conditions if any match required + if ($condition->isOr() && $subMatch) { break; - // full match - default: - return $fieldValStr == $clean_value; + } } - return false; - }); + // any matches && all matches the same (if all required) + $match = array_filter($matches) && ($condition->isAnd() ? count(array_unique($matches)) === 1 : true); + } - return $this; + return $match; + } + + protected function evaluateIf($v1, $operator, $v2): bool + { + switch (strtoupper((string) $operator)) { + case '=': + $result = is_array($v2) ? $this->evaluateIf($v1, 'IN', $v2) : $v1 == $v2; + + break; + case '>': + $result = $v1 > $v2; + + break; + case '>=': + $result = $v1 >= $v2; + + break; + case '<': + $result = $v1 < $v2; + + break; + case '<=': + $result = $v1 <= $v2; + + break; + case '!=': + case '<>': + $result = !$this->evaluateIf($v1, '=', $v2); + + break; + case 'LIKE': + $pattern = str_ireplace('%', '(.*?)', preg_quote($v2)); + + $result = (bool) preg_match('/^' . $pattern . '$/', (string) $v1); + + break; + case 'NOT LIKE': + $result = !$this->evaluateIf($v1, 'LIKE', $v2); + + break; + case 'IN': + $result = is_array($v2) ? in_array($v1, $v2, true) : $this->evaluateIf($v1, '=', $v2); + + break; + case 'NOT IN': + $result = !$this->evaluateIf($v1, 'IN', $v2); + + break; + case 'REGEXP': + $result = (bool) preg_match('/' . $v2 . '/', $v1); + + break; + case 'NOT REGEXP': + $result = !$this->evaluateIf($v1, 'REGEXP', $v2); + + break; + default: + throw (new Exception('Unsupported operator')) + ->addMoreInfo('operator', $operator); + } + + return $result; } /** @@ -116,7 +222,6 @@ public function order($fields) foreach ($fields as [$field, $desc]) { $args[] = array_column($data, $field); $args[] = $desc ? SORT_DESC : SORT_ASC; - //$args[] = SORT_STRING; // SORT_STRING | SORT_NUMERIC | SORT_REGULAR } $args[] = &$data; @@ -132,14 +237,14 @@ public function order($fields) /** * Limit Iterator. * - * @param int $cnt - * @param int $shift + * @param int $length + * @param int $offset * * @return $this */ - public function limit($cnt, $shift = 0) + public function limit($length, $offset = 0) { - $data = array_slice($this->get(), $shift, $cnt, true); + $data = array_slice($this->get(), $offset, $length, true); // put data back in generator $this->generator = new \ArrayIterator($data); @@ -159,6 +264,18 @@ public function count() return $this; } + /** + * Checks if iterator has any rows. + * + * @return $this + */ + public function exists() + { + $this->generator = new \ArrayIterator([[$this->generator->valid() ? 1 : 0]]); + + return $this; + } + /** * Return all data inside array. */ diff --git a/src/Model.php b/src/Model.php index 6fcbf047b..7f635b7bc 100644 --- a/src/Model.php +++ b/src/Model.php @@ -192,6 +192,9 @@ class Model implements \IteratorAggregate */ public $conditions = []; + /** @var Model\Scope */ + protected $scope; + /** * Array of limit set. * @@ -366,6 +369,8 @@ class Model implements \IteratorAggregate */ public function __construct($persistence = null, $defaults = []) { + $this->scope = new Model\Scope(); + if ((is_string($persistence) || is_array($persistence)) && func_num_args() === 1) { $defaults = $persistence; $persistence = null; @@ -392,6 +397,7 @@ public function __construct($persistence = null, $defaults = []) */ public function __clone() { + $this->scope = (clone $this->scope)->setModel($this); $this->_cloneCollection('elements'); $this->_cloneCollection('fields'); $this->_cloneCollection('userActions'); @@ -949,6 +955,15 @@ public function _unset(string $name) * ->addCondition('my_field', $expr); * ->addCondition($expr); * + * Conditions on referenced models are also supported: + * $contact->addCondition('company/country', 'US'); + * where 'company' is the name of the reference + * This will limit scope of $contact model to contacts whose company country is set to 'US' + * + * Using # in conditions on referenced model will apply the condition on the number of records: + * $contact->addCondition('tickets/#', '>', 5); + * This will limit scope of $contact model to contacts that have more than 5 tickets + * * To use those, you should consult with documentation of your * persistence driver. * @@ -960,49 +975,21 @@ public function _unset(string $name) */ public function addCondition($field, $operator = null, $value = null) { - if (is_array($field)) { - $this->conditions[] = [$field]; - - return $this; - /* - $or = $this->persistence->orExpr(); - - foreach ($field as list($field, $operator, $value)) { - if (is_string($field)) { - $f = $this->getField($field); - } elseif ($field instanceof Field) { - $f = $field; - } - - $or->where($f, $operator, $value); - } - - return $this; - */ - } - - if (is_string($field)) { - $f = $this->getField($field); - } else { - $f = $field; - } - - if ($f instanceof Field) { - if ($operator === '=' || func_num_args() === 2) { - $v = ($operator === '=' ? $value : $operator); - - if (!is_object($v) && !is_array($v)) { - $f->system = true; - $f->default = $v; - } - } - } - - $this->conditions[] = func_get_args(); + $this->scope()->add(new Model\Scope\CompoundCondition([func_get_args()])); return $this; } + /** + * Get the scope object of the Model. + * + * @return Model\Scope + */ + public function scope() + { + return $this->scope->setModel($this); + } + /** * Shortcut for using addCondition(id_field, $id). * @@ -1349,13 +1336,7 @@ public function withPersistence($persistence, $id = null, string $class = null) */ public function tryLoad($id) { - if (!$this->persistence) { - throw new Exception('Model is not associated with any database'); - } - - if (!$this->persistence->hasMethod('tryLoad')) { - throw new Exception('Persistence does not support tryLoad()'); - } + $this->checkPersistence('tryLoad'); if ($this->loaded()) { $this->unload(); @@ -1385,13 +1366,7 @@ public function tryLoad($id) */ public function loadAny() { - if (!$this->persistence) { - throw new Exception('Model is not associated with any database'); - } - - if (!$this->persistence->hasMethod('loadAny')) { - throw new Exception('Persistence does not support loadAny()'); - } + $this->checkPersistence('loadAny'); if ($this->loaded()) { $this->unload(); @@ -1424,13 +1399,7 @@ public function loadAny() */ public function tryLoadAny() { - if (!$this->persistence) { - throw new Exception('Model is not associated with any database'); - } - - if (!$this->persistence->hasMethod('tryLoadAny')) { - throw new Exception('Persistence does not support tryLoadAny()'); - } + $this->checkPersistence('tryLoadAny'); if ($this->loaded()) { $this->unload(); @@ -1465,32 +1434,28 @@ public function tryLoadAny() * * @return $this */ - public function loadBy(string $field_name, $value) + public function loadBy(string $fieldName, $value) { - // store - $field = $this->getField($field_name); - $system = $field->system; - $default = $field->default; + $field = $this->getField($fieldName); - // add condition and load record - $this->addCondition($field_name, $value); + // backup + $scopeBak = $this->scope; + $systemBak = $field->system; + $defaultBak = $field->default; try { + // add condition to cloned scope and try to load record + $this->scope = clone $this->scope; + $this->addCondition($field, $value); + $this->loadAny(); - } catch (\Exception $e) { + } finally { // restore - array_pop($this->conditions); - $field->system = $system; - $field->default = $default; - - throw $e; + $this->scope = $scopeBak; + $field->system = $systemBak; + $field->default = $defaultBak; } - // restore - array_pop($this->conditions); - $field->system = $system; - $field->default = $default; - return $this; } @@ -1502,35 +1467,47 @@ public function loadBy(string $field_name, $value) * * @return $this */ - public function tryLoadBy(string $field_name, $value) + public function tryLoadBy(string $fieldName, $value) { - // store - $field_name = $this->getField($field_name); - $system = $field_name->system; - $default = $field_name->default; + $field = $this->getField($fieldName); - // add condition and try to load record - $this->addCondition($field_name, $value); + // backup + $scopeBak = $this->scope; + $systemBak = $field->system; + $defaultBak = $field->default; try { + // add condition to cloned scope and try to load record + $this->scope = clone $this->scope; + $this->addCondition($field, $value); + $this->tryLoadAny(); - } catch (\Exception $e) { + } finally { // restore - array_pop($this->conditions); - $field_name->system = $system; - $field_name->default = $default; - - throw $e; + $this->scope = $scopeBak; + $field->system = $systemBak; + $field->default = $defaultBak; } - // restore - array_pop($this->conditions); - $field_name->system = $system; - $field_name->default = $default; - return $this; } + /** + * Check if model has persistence with specified method. + * + * @param string $method + */ + public function checkPersistence(string $method = null) + { + if (!$this->persistence) { + throw new Exception('Model is not associated with any persistence'); + } + + if ($method && !$this->persistence->hasMethod($method)) { + throw new Exception("Persistence does not support {$method} method"); + } + } + /** * Save record. * @@ -1744,9 +1721,7 @@ public function import(array $rows) */ public function export(array $fields = null, $key_field = null, $typecast_data = true): array { - if (!$this->persistence->hasMethod('export')) { - throw new Exception('Persistence does not support export()'); - } + $this->checkPersistence('export'); // no key field - then just do export if ($key_field === null) { @@ -1966,13 +1941,7 @@ public function atomic($f, Persistence $persistence = null) */ public function action($mode, $args = []) { - if (!$this->persistence) { - throw new Exception('action() requires model to be associated with db'); - } - - if (!$this->persistence->hasMethod('action')) { - throw new Exception('Persistence does not support action()'); - } + $this->checkPersistence('action'); return $this->persistence->action($this, $mode, $args); } @@ -2257,12 +2226,10 @@ public function addCalculatedField(string $name, $expression) */ public function __debugInfo(): array { - $arr = [ + return [ 'id' => $this->id, - 'conditions' => $this->conditions, + 'scope' => $this->scope()->toWords(), ]; - - return $arr; } // }}} diff --git a/src/Model/Scope.php b/src/Model/Scope.php new file mode 100644 index 000000000..215bcfd49 --- /dev/null +++ b/src/Model/Scope.php @@ -0,0 +1,47 @@ +junction = self::AND; + } + + public function setModel(Model $model) + { + $this->model = $model; + + $this->onChangeModel(); + + return $this; + } + + public function getModel() + { + return $this->model; + } + + public function negate() + { + throw new Exception('Model Scope cannot be negated!'); + } +} diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php new file mode 100644 index 000000000..0fa727b3a --- /dev/null +++ b/src/Model/Scope/AbstractCondition.php @@ -0,0 +1,105 @@ +owner is the Scope object. + */ + public function init(): void + { + if (!$this->owner instanceof self) { + throw new Exception('Scope can only be added as element to scope'); + } + + $this->_init(); + + $this->onChangeModel(); + } + + abstract public function onChangeModel(); + + /** + * Temporarily assign a model to the condition. + * + * @return static + */ + final public function on(Model $model) + { + $clone = clone $this; + + $clone->owner = null; + + (clone $model)->scope()->add($clone); + + return $clone; + } + + public function getModel() + { + return $this->owner ? $this->owner->getModel() : null; + } + + /** + * Empty the scope object. + * + * @return static + */ + abstract public function clear(); + + /** + * Negate the scope object + * e.g from 'is' to 'is not'. + * + * @return static + */ + abstract public function negate(); + + /** + * Return if scope has any conditions. + */ + abstract public function isEmpty(): bool; + + /** + * Convert the scope to human readable words when applied on $model. + * + * @return bool + */ + abstract public function toWords(bool $asHtml = false): string; + + /** + * Simplifies by peeling off nested group conditions with single contained component. + * Useful for converting (((field = value))) to field = value. + * + * @return AbstractCondition + */ + public function simplify() + { + return $this; + } + + /** + * Returns if scope contains several conditions. + */ + public function isCompound(): bool + { + return false; + } +} diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php new file mode 100644 index 000000000..62c196adb --- /dev/null +++ b/src/Model/Scope/BasicCondition.php @@ -0,0 +1,327 @@ + '!=', + '!=' => '=', + '<' => '>=', + '>' => '<=', + '>=' => '<', + '<=' => '>', + 'LIKE' => 'NOT LIKE', + 'NOT LIKE' => 'LIKE', + 'IN' => 'NOT IN', + 'NOT IN' => 'IN', + 'REGEXP' => 'NOT REGEXP', + 'NOT REGEXP' => 'REGEXP', + ]; + + protected static $dictionary = [ + '=' => 'is equal to', + '!=' => 'is not equal to', + '<' => 'is smaller than', + '>' => 'is greater than', + '>=' => 'is greater or equal to', + '<=' => 'is smaller or equal to', + 'LIKE' => 'is like', + 'NOT LIKE' => 'is not like', + 'IN' => 'is one of', + 'NOT IN' => 'is not one of', + 'REGEXP' => 'is regular expression', + 'NOT REGEXP' => 'is not regular expression', + ]; + + protected static $skipValueTypecast = [ + 'LIKE', + 'NOT LIKE', + 'REGEXP', + 'NOT REGEXP', + ]; + + public function __construct($key, $operator = null, $value = null) + { + if (func_num_args() == 2) { + $value = $operator; + $operator = '='; + } + + if (is_bool($key)) { + if ($key) { + return; + } + + $key = new Expression('false'); + } + + $this->key = $key; + $this->operator = $operator; + $this->value = $value; + } + + public function onChangeModel(): void + { + if ($model = $this->getModel()) { + // if we have a definitive scalar value for a field + // sets it as default value for field and locks it + // new records will automatically get this value assigned for the field + // @todo: consider this when condition is part of OR scope + if ($this->operator === '=' && !is_object($this->value) && !is_array($this->value)) { + // key containing '/' means chained references and it is handled in toArray method + if (is_string($field = $this->key) && stripos($field, '/') === false) { + $field = $model->getField($field); + } + + if ($field instanceof Field) { + $field->system = true; + $field->default = $this->value; + } + } + } + } + + public function toArray(): array + { + // make sure clones are used to avoid changes + $condition = clone $this; + + $field = $condition->key; + $operator = $condition->operator; + $value = $condition->value; + + if ($condition->isEmpty()) { + return []; + } + + if ($model = $condition->getModel()) { + if (is_string($field)) { + // shorthand for adding conditions on references + // use chained reference names separated by "/" + if (stripos($field, '/') !== false) { + $references = explode('/', $field); + + $field = array_pop($references); + + foreach ($references as $link) { + $model = $model->refLink($link); + } + + // '#' -> has # referenced records + // '?' -> has any referenced records + // '!' -> does not have any referenced records + if (in_array($field, ['#', '!', '?'], true)) { + // if no operator consider '#' as 'any record exists' + if ($field == '#' && !$operator) { + $field = '?'; + } + + if (in_array($field, ['!', '?'], true)) { + $operator = '='; + $value = $field == '?' ? 1 : 0; + } + } else { + // otherwise add the condition to the referenced model + // and check if any records exist matching the criteria + $model->addCondition($field, $operator, $value); + $operator = '='; + $value = 1; + } + + // if not counting we check for existence only + $field = $field == '#' ? $model->action('count') : $model->action('exists'); + } else { + $field = $model->getField($field); + } + } + + // @todo: value is array + // convert the value using the typecasting of persistence + if ($field instanceof Field && $model->persistence && !in_array(strtoupper((string) $operator), self::$skipValueTypecast, true)) { + $value = $model->persistence->typecastSaveField($field, $value); + } + + // only expression contained in $field + if (!$operator) { + return [$field]; + } + + // skip explicitly using '=' as in some cases it is transformed to 'in' + // for instance in dsql so let exact operator be handled by Persistence + if ($operator === '=') { + return [$field, $value]; + } + } + + return [$field, $operator, $value]; + } + + public function isEmpty(): bool + { + return array_filter([$this->key, $this->operator, $this->value]) ? false : true; + } + + public function clear() + { + $this->key = $this->operator = $this->value = null; + + return $this; + } + + public function negate() + { + if ($this->operator && isset(self::$opposites[$this->operator])) { + $this->operator = self::$opposites[$this->operator]; + } else { + throw new Exception('Negation of condition is not supported for ' . ($this->operator ?: 'no') . ' operator'); + } + + return $this; + } + + public function toWords(bool $asHtml = false): string + { + if (!$this->getModel()) { + throw new Exception('Condition must be associated with Model to convert to words'); + } + + // make sure clones are used to avoid changes + $condition = clone $this; + + $key = $condition->keyToWords($asHtml); + + $operator = $condition->operatorToWords($asHtml); + + $value = $condition->valueToWords($condition->value, $asHtml); + + $ret = trim("{$key} {$operator} {$value}"); + + return $asHtml ? $ret : html_entity_decode($ret); + } + + protected function keyToWords(bool $asHtml = false): string + { + $model = $this->getModel(); + + $words = []; + + if (is_string($field = $this->key)) { + if (stripos($field, '/') !== false) { + $references = explode('/', $field); + + $words[] = $model->getModelCaption(); + + $field = array_pop($references); + + foreach ($references as $link) { + $words[] = "that has reference {$this->readableCaption($link)}"; + + $model = $model->refLink($link); + } + + $words[] = 'where'; + + if ($field === '#') { + $words[] = $this->operator ? 'number of records' : 'any referenced record exists'; + $field = ''; + } elseif ($field === '?') { + $words[] = 'any referenced record exists'; + $field = ''; + } elseif ($field === '!') { + $words[] = 'no referenced records exist'; + $field = ''; + } + } + + $field = $model->hasField($field) ? $model->getField($field) : null; + } + + if ($field instanceof Field) { + $words[] = $field->getCaption(); + } elseif ($field instanceof Expression) { + $words[] = "expression '{$field->getDebugQuery()}'"; + } + + $string = implode(' ', array_filter($words)); + + return $asHtml ? "{$string}" : $string; + } + + protected function operatorToWords(bool $asHtml = false): string + { + return $this->operator ? (self::$dictionary[strtoupper((string) $this->operator)] ?? 'is equal to') : ''; + } + + protected function valueToWords($value, bool $asHtml = false): string + { + $model = $this->getModel(); + + if ($value === null) { + return $this->operator ? 'empty' : ''; + } + + if (is_array($values = $value)) { + $ret = []; + foreach ($values as $value) { + $ret[] = $this->valueToWords($value, $asHtml); + } + + return implode(' or ', $ret); + } + + if (is_object($value)) { + if ($value instanceof Field) { + return $value->owner->getModelCaption() . ' ' . $value->getCaption(); + } + + if ($value instanceof Expression || $value instanceof Expressionable) { + return "expression '{$value->getDebugQuery()}'"; + } + + return 'object ' . print_r($value, true); + } + + // handling of scope on references + if (is_string($field = $this->key)) { + if (stripos($field, '/') !== false) { + $references = explode('/', $field); + + $field = array_pop($references); + + foreach ($references as $link) { + $model = $model->refLink($link); + } + } + + $field = $model->hasField($field) ? $model->getField($field) : null; + } + + // use the referenced model title if such exists + if ($field && ($field->reference ?? false)) { + // make sure we set the value in the Model parent of the reference + // it should be same class as $model but $model might be a clone + $field->reference->owner->set($field->short_name, $value); + + $value = $field->reference->ref()->getTitle() ?: $value; + } + + return "'" . (string) $value . "'"; + } +} diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php new file mode 100644 index 000000000..232436e92 --- /dev/null +++ b/src/Model/Scope/CompoundCondition.php @@ -0,0 +1,209 @@ +junction = in_array($junction, self::JUNCTIONS, true) ? $junction : self::JUNCTIONS[$junction ? 1 : 0]; + + foreach ($nestedConditions as $nestedCondition) { + $nestedCondition = is_string($nestedCondition) ? new BasicCondition($nestedCondition) : $nestedCondition; + + if (is_array($nestedCondition)) { + // array of OR nested conditions + if (count($nestedCondition) === 1 && isset($nestedCondition[0]) && is_array($nestedCondition[0])) { + $nestedCondition = new self($nestedCondition[0], self::OR); + } else { + $nestedCondition = new BasicCondition(...$nestedCondition); + } + } + + if ($nestedCondition->isEmpty()) { + continue; + } + + $this->add(clone $nestedCondition); + } + } + + public function __clone() + { + foreach ($this->elements as $k => $nestedCondition) { + $this->elements[$k] = clone $nestedCondition; + $this->elements[$k]->owner = $this; + } + } + + /** + * Return array of nested conditions. + * + * @return AbstractCondition[] + */ + public function getNestedConditions() + { + return $this->elements; + } + + public function onChangeModel(): void + { + foreach ($this->elements as $nestedCondition) { + $nestedCondition->onChangeModel(); + } + } + + public function isEmpty(): bool + { + return empty($this->elements); + } + + public function isCompound(): bool + { + return count($this->elements) > 1; + } + + /** + * @return self::AND|self::OR + */ + public function getJunction(): string + { + return $this->junction; + } + + /** + * Checks if junction is OR. + */ + public function isOr(): bool + { + return $this->junction === self::OR; + } + + /** + * Checks if junction is AND. + */ + public function isAnd(): bool + { + return $this->junction === self::AND; + } + + /** + * Clears the group from nested conditions. + * + * @return static + */ + public function clear() + { + foreach ($this->elements as $nestedCondition) { + $nestedCondition->destroy(); + } + + return $this; + } + + public function simplify() + { + if (count($this->elements) != 1) { + return $this; + } + + /** + * @var AbstractCondition $component + */ + $component = reset($this->elements); + + return $component->simplify(); + } + + /** + * Use De Morgan's laws to negate. + * + * @return static + */ + public function negate() + { + $this->junction = $this->junction == self::OR ? self::AND : self::OR; + + foreach ($this->elements as $nestedCondition) { + $nestedCondition->negate(); + } + + return $this; + } + + public function toWords(bool $asHtml = false): string + { + if ($this->isEmpty()) { + return ''; + } + + $parts = []; + foreach ($this->elements as $nestedCondition) { + $words = $nestedCondition->toWords($asHtml); + + $parts[] = $this->isCompound() && $nestedCondition->isCompound() ? "({$words})" : $words; + } + + $glue = ' ' . strtolower($this->junction) . ' '; + + return implode($glue, $parts); + } + + /** + * Merge number of conditions using AND as junction. + * + * @param AbstractCondition $_ + * + * @return static + */ + public static function mergeAnd(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null) + { + return new self(func_get_args(), self::AND); + } + + /** + * Merge number of conditions using OR as junction. + * + * @param AbstractCondition $_ + * + * @return static + */ + public static function mergeOr(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null) + { + return new self(func_get_args(), self::OR); + } +} diff --git a/src/Persistence/Array_.php b/src/Persistence/Array_.php index bef8cb090..3f5b8dc39 100644 --- a/src/Persistence/Array_.php +++ b/src/Persistence/Array_.php @@ -5,7 +5,6 @@ namespace atk4\data\Persistence; use atk4\data\Exception; -use atk4\data\Field; use atk4\data\Model; use atk4\data\Persistence; @@ -25,11 +24,13 @@ public function __construct(array $data = []) } /** - * @deprecated TODO temporary for these: - * - https://github.com/atk4/data/blob/90ab68ac063b8fc2c72dcd66115f1bd3f70a3a92/src/Reference/ContainsOne.php#L119 - * - https://github.com/atk4/data/blob/90ab68ac063b8fc2c72dcd66115f1bd3f70a3a92/src/Reference/ContainsMany.php#L66 - * remove once fixed/no longer needed + * Array of last inserted ids per table. + * Last inserted ID for any table is stored under '$' key. + * + * @var array */ + protected $lastInsertIds = []; + public function getRawDataByTable(string $table): array { return $this->data[$table]; @@ -38,10 +39,10 @@ public function getRawDataByTable(string $table): array /** * {@inheritdoc} */ - public function add(Model $m, array $defaults = []): Model + public function add(Model $model, array $defaults = []): Model { if (isset($defaults[0])) { - $m->table = $defaults[0]; + $model->table = $defaults[0]; unset($defaults[0]); } @@ -49,10 +50,10 @@ public function add(Model $m, array $defaults = []): Model '_default_seed_join' => [\atk4\data\Join\Array_::class], ], $defaults); - $m = parent::add($m, $defaults); + $model = parent::add($model, $defaults); - if ($m->id_field && $m->hasField($m->id_field)) { - $f = $m->getField($m->id_field); + if ($model->id_field && $model->hasField($model->id_field)) { + $f = $model->getField($model->id_field); if (!$f->type) { $f->type = 'integer'; } @@ -60,19 +61,19 @@ public function add(Model $m, array $defaults = []): Model // if there is no model table specified, then create fake one named 'data' // and put all persistence data in there - if (!$m->table) { - $m->table = 'data'; // fake table name 'data' - if (!isset($this->data[$m->table]) || count($this->data) !== 1) { - $this->data = [$m->table => $this->data]; + if (!$model->table) { + $model->table = 'data'; // fake table name 'data' + if (!isset($this->data[$model->table]) || count($this->data) !== 1) { + $this->data = [$model->table => $this->data]; } } // if there is no such table in persistence, then create empty one - if (!isset($this->data[$m->table])) { - $this->data[$m->table] = []; + if (!isset($this->data[$model->table])) { + $this->data[$model->table] = []; } - return $m; + return $model; } /** @@ -80,19 +81,19 @@ public function add(Model $m, array $defaults = []): Model * * @param mixed $id */ - public function load(Model $m, $id, string $table = null): array + public function load(Model $model, $id, string $table = null): array { - if (isset($m->table) && !isset($this->data[$m->table])) { + if (isset($model->table) && !isset($this->data[$model->table])) { throw (new Exception('Table was not found in the array data source')) - ->addMoreInfo('table', $m->table); + ->addMoreInfo('table', $model->table); } - if (!isset($this->data[$table ?? $m->table][$id])) { + if (!isset($this->data[$table ?? $model->table][$id])) { throw (new Exception('Record with specified ID was not found', 404)) ->addMoreInfo('id', $id); } - return $this->tryLoad($m, $id, $table); + return $this->tryLoad($model, $id, $table); } /** @@ -101,17 +102,15 @@ public function load(Model $m, $id, string $table = null): array * * @param mixed $id */ - public function tryLoad(Model $m, $id, string $table = null): ?array + public function tryLoad(Model $model, $id, string $table = null): ?array { - if ($table === null) { - $table = $m->table; - } + $table = $table ?? $model->table; if (!isset($this->data[$table][$id])) { return null; } - return $this->typecastLoadRow($m, $this->data[$table][$id]); + return $this->typecastLoadRow($model, $this->data[$table][$id]); } /** @@ -120,21 +119,19 @@ public function tryLoad(Model $m, $id, string $table = null): ?array * * @param mixed $table */ - public function tryLoadAny(Model $m, string $table = null): ?array + public function tryLoadAny(Model $model, string $table = null): ?array { - if ($table === null) { - $table = $m->table; - } + $table = $table ?? $model->table; if (!$this->data[$table]) { return null; } reset($this->data[$table]); - $key = key($this->data[$table]); + $id = key($this->data[$table]); - $row = $this->load($m, $key, $table); - $m->id = $key; + $row = $this->load($model, $id, $table); + $model->id = $id; return $row; } @@ -146,17 +143,15 @@ public function tryLoadAny(Model $m, string $table = null): ?array * * @return mixed */ - public function insert(Model $m, $data, string $table = null) + public function insert(Model $model, $data, string $table = null) { - if ($table === null) { - $table = $m->table; - } + $table = $table ?? $model->table; - $data = $this->typecastSaveRow($m, $data); + $data = $this->typecastSaveRow($model, $data); - $id = $this->generateNewId($m, $table); - if ($m->id_field) { - $data[$m->id_field] = $id; + $id = $this->generateNewId($model, $table); + if ($model->id_field) { + $data[$model->id_field] = $id; } $this->data[$table][$id] = $data; @@ -171,19 +166,13 @@ public function insert(Model $m, $data, string $table = null) * * @return mixed */ - public function update(Model $m, $id, $data, string $table = null) + public function update(Model $model, $id, $data, string $table = null) { - if ($table === null) { - $table = $m->table; - } + $table = $table ?? $model->table; - $data = $this->typecastSaveRow($m, $data); + $data = $this->typecastSaveRow($model, $data); - $this->data[$table][$id] = - array_merge( - $this->data[$table][$id] ?? [], - $data - ); + $this->data[$table][$id] = array_merge($this->data[$table][$id] ?? [], $data); return $id; } @@ -193,11 +182,9 @@ public function update(Model $m, $id, $data, string $table = null) * * @param mixed $id */ - public function delete(Model $m, $id, string $table = null) + public function delete(Model $model, $id, string $table = null) { - if ($table === null) { - $table = $m->table; - } + $table = $table ?? $model->table; unset($this->data[$table][$id]); } @@ -205,41 +192,58 @@ public function delete(Model $m, $id, string $table = null) /** * Generates new record ID. * - * @param Model $m + * @param Model $model * * @return string */ - public function generateNewId($m, string $table = null) + public function generateNewId($model, string $table = null) { - if ($table === null) { - $table = $m->table; - } + $table = $table ?? $model->table; - if ($m->id_field) { - $ids = array_keys($this->data[$table]); - $type = $m->getField($m->id_field)->type; - } else { - $ids = [count($this->data[$table])]; // use ids starting from 1 - $type = 'integer'; - } + $type = $model->id_field ? $model->getField($model->id_field)->type : 'integer'; switch ($type) { case 'integer': - return count($ids) === 0 ? 1 : (max($ids) + 1); + $ids = $model->id_field ? array_keys($this->data[$table]) : [count($this->data[$table])]; + + $id = $ids ? max($ids) + 1 : 1; + + break; case 'string': - return uniqid(); + $id = uniqid(); + + break; default: throw (new Exception('Unsupported id field type. Array supports type=integer or type=string only')) ->addMoreInfo('type', $type); } + + return $this->lastInsertIds[$table] = $this->lastInsertIds['$'] = $id; + } + + /** + * Last ID inserted. + * Last inserted ID for any table is stored under '$' key. + * + * @param Model $model + * + * @return mixed + */ + public function lastInsertId(Model $model = null) + { + if ($model) { + return $this->lastInsertIds[$model->table] ?? null; + } + + return $this->lastInsertIds['$'] ?? null; } /** * Prepare iterator. */ - public function prepareIterator(Model $m): iterable + public function prepareIterator(Model $model): iterable { - return $m->action('select')->get(); + return $model->action('select')->get(); } /** @@ -247,13 +251,13 @@ public function prepareIterator(Model $m): iterable * * @param bool $typecast_data Should we typecast exported data */ - public function export(Model $m, array $fields = null, $typecast_data = true): array + public function export(Model $model, array $fields = null, $typecast = true): array { - $data = $m->action('select', [$fields])->get(); + $data = $model->action('select', [$fields])->get(); - if ($typecast_data) { - $data = array_map(function ($r) use ($m) { - return $this->typecastLoadRow($m, $r); + if ($typecast) { + $data = array_map(function ($row) use ($model) { + return $this->typecastLoadRow($model, $row); }, $data); } @@ -267,15 +271,15 @@ public function export(Model $m, array $fields = null, $typecast_data = true): a * * @return \atk4\data\Action\Iterator */ - public function initAction(Model $m, $fields = null) + public function initAction(Model $model, $fields = null) { - $keys = $fields ? array_flip($fields) : null; + $data = $this->data[$model->table]; - $data = array_map(function ($r) use ($m, $keys) { - // typecasting moved to export() method - //return $this->typecastLoadRow($m, $keys ? array_intersect_key($r, $keys) : $r); - return $keys ? array_intersect_key($r, $keys) : $r; - }, $this->data[$m->table]); + if ($keys = array_flip((array) $fields)) { + $data = array_map(function ($row) use ($model, $keys) { + return array_intersect_key($row, $keys); + }, $data); + } return new \atk4\data\Action\Iterator($data); } @@ -283,112 +287,59 @@ public function initAction(Model $m, $fields = null) /** * Will set limit defined inside $m onto data. */ - protected function setLimitOrder(Model $m, \atk4\data\Action\Iterator $action) + protected function setLimitOrder(Model $model, \atk4\data\Action\Iterator $action) { // first order by - if ($m->order) { - $action->order($m->order); + if ($model->order) { + $action->order($model->order); } // then set limit - if ($m->limit && ($m->limit[0] || $m->limit[1])) { - $cnt = $m->limit[0] ?? 0; - $shift = $m->limit[1] ?? 0; - - $action->limit($cnt, $shift); + if ($model->limit && ($model->limit[0] || $model->limit[1])) { + $action->limit($model->limit[0] ?? 0, $model->limit[1] ?? 0); } } /** - * Will apply conditions defined inside $m onto query $q. - * - * @param Model $m - * @param \atk4\data\Action\Iterator $q + * Will apply conditions defined inside $model onto $iterator. * * @return \atk4\data\Action\Iterator|null */ - public function applyConditions(Model $model, \atk4\data\Action\Iterator $iterator) + public function applyScope(Model $model, \atk4\data\Action\Iterator $iterator) { - if (empty($model->conditions)) { - // no conditions are set in the model - return $iterator; - } - - foreach ($model->conditions as $cond) { - // assume the action is "where" if we have only 2 parameters - if (count($cond) === 2) { - array_splice($cond, -1, 1, ['where', $cond[1]]); - } - - // condition must have 3 params at this point - if (count($cond) !== 3) { - // condition can have up to three params - throw (new Exception('Persistence\Array_ driver condition unsupported format')) - ->addMoreInfo('reason', 'condition can have two to three params') - ->addMoreInfo('condition', $cond); - } - - // extract - $field = $cond[0]; - $method = strtolower($cond[1]); - $value = $cond[2]; - - // check if the method is supported by the iterator - if (!method_exists($iterator, $method)) { - throw (new Exception('Persistence\Array_ driver condition unsupported method')) - ->addMoreInfo('reason', "method {$method} not implemented for Action\\Iterator") - ->addMoreInfo('condition', $cond); - } - - // get the model field - if (is_string($field)) { - $field = $model->getField($field); - } - - if (!is_a($field, Field::class)) { - throw (new Exception('Persistence\Array_ driver condition unsupported format')) - ->addMoreInfo('reason', 'Unsupported object instance ' . get_class($field)) - ->addMoreInfo('condition', $cond); - } - - // get the field name - $short_name = $field->short_name; - // .. the value - $value = $this->typecastSaveField($field, $value); - // run the (filter) method - $iterator->{$method}($short_name, $value); - } + return $iterator->filter($model->scope()); } /** * Various actions possible here, mostly for compatibility with SQLs. * - * @param Model $m * @param string $type * @param array $args * * @return mixed */ - public function action($m, $type, $args = []) + public function action(Model $model, $type, $args = []) { - if (!is_array($args)) { - throw (new Exception('$args must be an array')) - ->addMoreInfo('args', $args); - } + $args = (array) $args; switch ($type) { case 'select': - $action = $this->initAction($m, $args[0] ?? null); - $this->applyConditions($m, $action); - $this->setLimitOrder($m, $action); + $action = $this->initAction($model, $args[0] ?? null); + $this->applyScope($model, $action); + $this->setLimitOrder($model, $action); return $action; case 'count': - $action = $this->initAction($m, $args[0] ?? null); - $this->applyConditions($m, $action); - $this->setLimitOrder($m, $action); + $action = $this->initAction($model, $args[0] ?? null); + $this->applyScope($model, $action); + $this->setLimitOrder($model, $action); return $action->count(); + case 'exists': + $action = $this->initAction($model, $args[0] ?? null); + $this->applyScope($model, $action); + + return $action->exists(); case 'field': if (!isset($args[0])) { throw (new Exception('This action requires one argument with field name')) @@ -397,13 +348,12 @@ public function action($m, $type, $args = []) $field = is_string($args[0]) ? $args[0] : $args[0][0]; - $action = $this->initAction($m, [$field]); - $this->applyConditions($m, $action); - $this->setLimitOrder($m, $action); + $action = $this->initAction($model, [$field]); + $this->applyScope($model, $action); + $this->setLimitOrder($model, $action); // get first record - $row = $action->getRow(); - if ($row) { + if ($row = $action->getRow()) { if (isset($args['alias']) && array_key_exists($field, $row)) { $row[$args['alias']] = $row[$field]; unset($row[$field]); @@ -411,13 +361,20 @@ public function action($m, $type, $args = []) } return $row; - /* These are not implemented yet case 'fx': case 'fx0': + if (!isset($args[0], $args[1])) { + throw (new Exception('fx action needs 2 arguments, eg: ["sum", "amount"]')) + ->addMoreInfo('action', $type); + } - return $action->aggregate($field->short_name, $fx); - */ + $fx = $args[0]; + $field = $args[1] ?? null; + $action = $this->initAction($model, $args[1] ?? null); + $this->applyScope($model, $action); + $this->setLimitOrder($model, $action); + return $action->aggregate($fx, $field, $type == 'fx0'); default: throw (new Exception('Unsupported action mode')) ->addMoreInfo('type', $type); diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index bcc7dab91..d42bffc19 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -364,60 +364,36 @@ protected function setLimitOrder(Model $m, Query $q) } /** - * Will apply conditions defined inside $m onto query $q. + * Will apply scope defined inside $scope or $model->scope() onto $query. */ - public function initQueryConditions(Model $m, Query $q): Query + public function initQueryConditions(Model $model, Query $query, Model\Scope\AbstractCondition $condition = null): Query { - if (!isset($m->conditions)) { - // no conditions are set in the model - return $q; - } - - foreach ($m->conditions as $cond) { - // Options here are: - // count($cond) == 1, we will pass the only - // parameter inside where() - - if (count($cond) === 1) { - // OR conditions - if (is_array($cond[0])) { - foreach ($cond[0] as &$row) { - if (is_string($row[0])) { - $row[0] = $m->getField($row[0]); - } - - // "like" or "regexp" conditions do not need typecasting to field type! - if ($row[0] instanceof Field && (count($row) === 2 || !in_array(strtolower($row[1]), ['like', 'regexp'], true))) { - $valueKey = count($row) === 2 ? 1 : 2; - $row[$valueKey] = $this->typecastSaveField($row[0], $row[$valueKey]); - } - } - } + $condition = $condition ?? $model->scope(); - $q->where($cond[0]); + if (!$condition || $condition->isEmpty()) { + return $query; + } - continue; - } + // peel off the single nested scopes to convert (((field = value))) to field = value + $condition = $condition->simplify(); - if (is_string($cond[0])) { - $cond[0] = $m->getField($cond[0]); - } + // simple condition + if ($condition instanceof Model\Scope\BasicCondition) { + $query = $query->where(...$condition->toArray()); + } - if (count($cond) === 2) { - if ($cond[0] instanceof Field) { - $cond[1] = $this->typecastSaveField($cond[0], $cond[1]); - } - $q->where($cond[0], $cond[1]); - } else { - // "like" or "regexp" conditions do not need typecasting to field type! - if ($cond[0] instanceof Field && !in_array(strtolower($cond[1]), ['like', 'regexp'], true)) { - $cond[2] = $this->typecastSaveField($cond[0], $cond[2]); - } - $q->where($cond[0], $cond[1], $cond[2]); + // nested conditions + if ($condition instanceof Model\Scope\CompoundCondition) { + $expression = $condition->isOr() ? $query->orExpr() : $query->andExpr(); + + foreach ($condition->getNestedConditions() as $nestedCondition) { + $expression = $this->initQueryConditions($model, $expression, $nestedCondition); } + + $query = $query->where($expression); } - return $q; + return $query; } /** @@ -631,14 +607,14 @@ public function action(Model $m, $type, $args = []) break; case 'count': $this->initQueryConditions($m, $q); - $m->hook(self::HOOK_INIT_SELECT_QUERY, [$q]); - if (isset($args['alias'])) { - $q->reset('field')->field('count(*)', $args['alias']); - } else { - $q->reset('field')->field('count(*)'); - } + $m->hook(self::HOOK_INIT_SELECT_QUERY, [$q, $type]); - return $q; + return $q->reset('field')->field('count(*)', $args['alias'] ?? null); + case 'exists': + $this->initQueryConditions($m, $q); + $m->hook(self::HOOK_INIT_SELECT_QUERY, [$q, $type]); + + return $this->dsql()->mode('select')->option('exists')->field($q); case 'field': if (!isset($args[0])) { throw (new Exception('This action requires one argument with field name')) @@ -725,7 +701,7 @@ public function tryLoad(Model $m, $id): ?array ->addMoreInfo('query', $load->getDebugQuery()) ->addMoreInfo('message', $e->getMessage()) ->addMoreInfo('model', $m) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } if (!isset($data[$m->id_field]) || $data[$m->id_field] === null) { @@ -754,7 +730,7 @@ public function load(Model $m, $id): array throw (new Exception('Record was not found', 404)) ->addMoreInfo('model', $m) ->addMoreInfo('id', $id) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } return $data; @@ -780,7 +756,7 @@ public function tryLoadAny(Model $m): ?array ->addMoreInfo('query', $load->getDebugQuery()) ->addMoreInfo('message', $e->getMessage()) ->addMoreInfo('model', $m) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } if ($m->id_field) { @@ -808,7 +784,7 @@ public function loadAny(Model $m): array if (!$data) { throw (new Exception('No matching records were found', 404)) ->addMoreInfo('model', $m) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } return $data; @@ -840,7 +816,7 @@ public function insert(Model $m, array $data) ->addMoreInfo('query', $insert->getDebugQuery()) ->addMoreInfo('message', $e->getMessage()) ->addMoreInfo('model', $m) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } $m->hook(self::HOOK_AFTER_INSERT_QUERY, [$insert, $st]); @@ -882,7 +858,7 @@ public function prepareIterator(Model $m): iterable ->addMoreInfo('query', $export->getDebugQuery()) ->addMoreInfo('message', $e->getMessage()) ->addMoreInfo('model', $m) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } } @@ -919,7 +895,7 @@ public function update(Model $m, $id, $data) ->addMoreInfo('query', $update->getDebugQuery()) ->addMoreInfo('message', $e->getMessage()) ->addMoreInfo('model', $m) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } if ($m->id_field && isset($data[$m->id_field]) && $m->dirty[$m->id_field]) { @@ -961,7 +937,7 @@ public function delete(Model $m, $id) ->addMoreInfo('query', $delete->getDebugQuery()) ->addMoreInfo('message', $e->getMessage()) ->addMoreInfo('model', $m) - ->addMoreInfo('conditions', $m->conditions); + ->addMoreInfo('scope', $m->scope()->toWords()); } } @@ -997,14 +973,14 @@ public function getFieldSqlExpression(Field $field, Expression $expression) * * @return mixed */ - public function lastInsertId(Model $m) + public function lastInsertId(Model $model) { - $seq = $m->sequence ?: null; + $seq = $model->sequence ?: null; // PostgreSQL PDO always requires sequence name in lastInsertId method as parameter // So let's use its default one if no specific is set if ($this->connection instanceof \atk4\dsql\Postgresql\Connection && $seq === null) { - $seq = $m->table . '_' . $m->id_field . '_seq'; + $seq = $model->table . '_' . $model->id_field . '_seq'; } return $this->connection->lastInsertId($seq); diff --git a/src/Persistence/Static_.php b/src/Persistence/Static_.php index bac8a93a4..0b661936f 100644 --- a/src/Persistence/Static_.php +++ b/src/Persistence/Static_.php @@ -137,20 +137,22 @@ public function __construct($data = null) /** * Automatically adds missing model fields. * Called from AfterAdd hook. + * + * @param Static_ $persistence */ - public function afterAdd(self $p, Model $m) + public function afterAdd(self $persistence, Model $model) { - if ($p->titleForModel) { - $m->title_field = $p->titleForModel; + if ($persistence->titleForModel) { + $model->title_field = $persistence->titleForModel; } foreach ($this->fieldsForModel as $field => $def) { - if ($m->hasField($field)) { + if ($model->hasField($field)) { continue; } // add new field - $m->addField($field, $def); + $model->addField($field, $def); } } } diff --git a/tests/ConditionSqlTest.php b/tests/ConditionSqlTest.php index f86665141..527a2ba4f 100644 --- a/tests/ConditionSqlTest.php +++ b/tests/ConditionSqlTest.php @@ -391,12 +391,12 @@ public function testLoadBy() $u = (new Model($this->db, 'user'))->addFields(['name']); $u->loadBy('name', 'John'); - $this->assertSame([], $u->conditions); // should be no conditions + $this->assertTrue($u->scope()->isEmpty()); // should be no conditions $this->assertFalse($u->getField('name')->system); // should not set field as system $this->assertNull($u->getField('name')->default); // should not set field default value $u->tryLoadBy('name', 'John'); - $this->assertSame([], $u->conditions); // should be no conditions + $this->assertTrue($u->scope()->isEmpty()); // should be no conditions $this->assertFalse($u->getField('name')->system); // should not set field as system $this->assertNull($u->getField('name')->default); // should not set field default value } diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index 006fe1323..7a0c0d2f9 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -32,14 +32,14 @@ public function testBasicDiscrimination() $m->addCondition('gender', 'M'); - $this->assertSame(1, count($m->conditions)); + $this->assertEquals(1, count($m->scope()->getNestedConditions())); $m->addCondition('gender', 'F'); - $this->assertSame(2, count($m->conditions)); + $this->assertEquals(2, count($m->scope()->getNestedConditions())); $m->addCondition([['gender', 'F'], ['foo', 'bar']]); - $this->assertSame(3, count($m->conditions)); + $this->assertEquals(3, count($m->scope()->getNestedConditions())); } public function testEditableAfterCondition() @@ -47,6 +47,7 @@ public function testEditableAfterCondition() $m = new Model(); $m->addField('name'); $m->addField('gender'); + $m->addCondition('gender', 'M'); $this->assertTrue($m->getField('gender')->system); diff --git a/tests/LocaleTest.php b/tests/LocaleTest.php index 591dd7518..3a80c37db 100644 --- a/tests/LocaleTest.php +++ b/tests/LocaleTest.php @@ -22,7 +22,7 @@ public function testException() public function testGetPath() { $rootDir = realpath(dirname(__DIR__) . '/src/..'); - $this->assertSame($rootDir . \DIRECTORY_SEPARATOR . 'locale', realpath(Locale::getPath())); + $this->assertEquals($rootDir . \DIRECTORY_SEPARATOR . 'locale', realpath(Locale::getPath())); } public function testLocaleIntegration() diff --git a/tests/LookupSqlTest.php b/tests/LookupSqlTest.php index 3ee505f90..b09235438 100644 --- a/tests/LookupSqlTest.php +++ b/tests/LookupSqlTest.php @@ -70,8 +70,8 @@ public function init(): void ->withTitle() ->addFields(['country_code' => 'code', 'is_eu']); - $this->hasMany('Friends', new LFriend()) - ->addField('friend_names', ['field' => 'friend_name', 'concat' => ',']); +// $this->hasMany('Friends', new LFriend()) +// ->addField('friend_names', ['field' => 'friend_name', 'concat' => ',']); } } diff --git a/tests/PersistentArrayTest.php b/tests/PersistentArrayTest.php index 416650e97..079ec245d 100644 --- a/tests/PersistentArrayTest.php +++ b/tests/PersistentArrayTest.php @@ -180,6 +180,8 @@ public function testInsert() 3 => ['name' => 'Foo', 'surname' => 'Bar', 'id' => 3], ], ], $this->getInternalPersistenceData($p)); + + $this->assertEquals(3, $p->lastInsertID()); } public function testIterator() @@ -354,13 +356,13 @@ public function testLike() $p = new Persistence\Array_($a); $m = new Model($p, 'countries'); - $m->addField('code', ['type' => 'int']); + $m->addField('code', ['type' => 'integer']); $m->addField('country'); $m->addField('active', ['type' => 'boolean']); // if no condition we should get all the data back $iterator = $m->action('select'); - $result = $m->persistence->applyConditions($m, $iterator); + $result = $m->persistence->applyScope($m, $iterator); $this->assertInstanceOf(\atk4\data\Action\Iterator::class, $result); $m->unload(); unset($iterator); @@ -376,8 +378,21 @@ public function testLike() unset($result); $m->unload(); + // case : str% NOT LIKE + $m->scope()->clear(); + $m->addCondition('country', 'NOT LIKE', 'La%'); + $result = $m->action('select')->get(); + $this->assertEquals(6, count($m->export())); + $this->assertEquals($a['countries'][1], $result[1]); + $this->assertEquals($a['countries'][2], $result[2]); + $this->assertEquals($a['countries'][4], $result[4]); + $this->assertEquals($a['countries'][5], $result[5]); + $this->assertEquals($a['countries'][6], $result[6]); + $this->assertEquals($a['countries'][8], $result[8]); + unset($result); + // case : %str - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('country', 'LIKE', '%ia'); $result = $m->action('select')->get(); $this->assertSame(4, count($result)); @@ -389,7 +404,7 @@ public function testLike() $m->unload(); // case : %str% - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('country', 'LIKE', '%a%'); $result = $m->action('select')->get(); $this->assertSame(7, count($result)); @@ -404,31 +419,188 @@ public function testLike() $m->unload(); // case : boolean field - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('active', 'LIKE', '0'); $this->assertSame(4, count($m->export())); - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('active', 'LIKE', '1'); $this->assertSame(5, count($m->export())); - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%0%'); $this->assertSame(4, count($m->export())); - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%1%'); $this->assertSame(5, count($m->export())); - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%999%'); $this->assertSame(0, count($m->export())); - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%ABC%'); $this->assertSame(0, count($m->export())); } + /** + * Test Model->addCondition operator REGEXP. + */ + public function testConditions() + { + $a = ['countries' => [ + 1 => ['id' => 1, 'name' => 'ABC9', 'code' => 11, 'country' => 'Ireland', 'active' => 1], + 2 => ['id' => 2, 'name' => 'ABC8', 'code' => 12, 'country' => 'Ireland', 'active' => 0], + 3 => ['id' => 3, 'code' => 13, 'country' => 'Latvia', 'active' => 1], + 4 => ['id' => 4, 'name' => 'ABC6', 'code' => 14, 'country' => 'UK', 'active' => 0], + 5 => ['id' => 5, 'name' => 'ABC5', 'code' => 15, 'country' => 'UK', 'active' => 0], + 6 => ['id' => 6, 'name' => 'ABC4', 'code' => 16, 'country' => 'Ireland', 'active' => 1], + 7 => ['id' => 7, 'name' => 'ABC3', 'code' => 17, 'country' => 'Latvia', 'active' => 0], + 8 => ['id' => 8, 'name' => 'ABC2', 'code' => 18, 'country' => 'Russia', 'active' => 1], + 9 => ['id' => 9, 'code' => 19, 'country' => 'Latvia', 'active' => 1], + ]]; + + $p = new Persistence\Array_($a); + $m = new Model($p, 'countries'); + $m->addField('code', ['type' => 'integer']); + $m->addField('country'); + $m->addField('active', ['type' => 'boolean']); + + // if no condition we should get all the data back + $iterator = $m->action('select'); + $result = $m->persistence->applyScope($m, $iterator); + $this->assertInstanceOf(\atk4\data\Action\Iterator::class, $result); + $m->unload(); + unset($iterator); + unset($result); + + $m->scope()->clear(); + $m->addCondition('country', 'REGEXP', 'Ireland|UK'); + $result = $m->action('select')->get(); + $this->assertEquals(5, count($result)); + $this->assertEquals($a['countries'][1], $result[1]); + $this->assertEquals($a['countries'][2], $result[2]); + $this->assertEquals($a['countries'][4], $result[4]); + $this->assertEquals($a['countries'][5], $result[5]); + $this->assertEquals($a['countries'][6], $result[6]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('country', 'NOT REGEXP', 'Ireland|UK|Latvia'); + $result = $m->action('select')->get(); + $this->assertEquals(1, count($result)); + $this->assertEquals($a['countries'][8], $result[8]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('code', '>', 18); + $result = $m->action('select')->get(); + $this->assertEquals(1, count($result)); + $this->assertEquals($a['countries'][9], $result[9]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('code', '>=', 18); + $result = $m->action('select')->get(); + $this->assertEquals(2, count($result)); + $this->assertEquals($a['countries'][8], $result[8]); + $this->assertEquals($a['countries'][9], $result[9]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('code', '<', 12); + $result = $m->action('select')->get(); + $this->assertEquals(1, count($result)); + $this->assertEquals($a['countries'][1], $result[1]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('code', '<=', 12); + $result = $m->action('select')->get(); + $this->assertEquals(2, count($result)); + $this->assertEquals($a['countries'][1], $result[1]); + $this->assertEquals($a['countries'][2], $result[2]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('code', [11, 12]); + $result = $m->action('select')->get(); + $this->assertEquals(2, count($result)); + $this->assertEquals($a['countries'][1], $result[1]); + $this->assertEquals($a['countries'][2], $result[2]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('code', 'NOT IN', [11, 12, 13, 14, 15, 16, 17]); + $result = $m->action('select')->get(); + $this->assertEquals(2, count($result)); + $this->assertEquals($a['countries'][8], $result[8]); + $this->assertEquals($a['countries'][9], $result[9]); + unset($result); + $m->unload(); + + $m->scope()->clear(); + $m->addCondition('code', '!=', [11, 12, 13, 14, 15, 16, 17]); + $result = $m->action('select')->get(); + $this->assertEquals(2, count($result)); + $this->assertEquals($a['countries'][8], $result[8]); + $this->assertEquals($a['countries'][9], $result[9]); + unset($result); + $m->unload(); + } + + public function testAggregates() + { + $a = ['invoices' => [ + 1 => ['id' => 1, 'number' => 'ABC9', 'items' => 11, 'active' => 1], + 2 => ['id' => 2, 'number' => 'ABC8', 'items' => 12, 'active' => 0], + 3 => ['id' => 3, 'items' => 13, 'active' => 1], + 4 => ['id' => 4, 'number' => 'ABC6', 'items' => 14, 'active' => 0], + 5 => ['id' => 5, 'number' => 'ABC5', 'items' => 15, 'active' => 0], + 6 => ['id' => 6, 'number' => 'ABC4', 'items' => 16, 'active' => 1], + 7 => ['id' => 7, 'number' => 'ABC3', 'items' => 17, 'active' => 0], + 8 => ['id' => 8, 'number' => 'ABC2', 'items' => 18, 'active' => 1], + 9 => ['id' => 9, 'items' => 19, 'active' => 1], + 10 => ['id' => 10, 'items' => 0, 'active' => 1], + 11 => ['id' => 11, 'items' => null, 'active' => 1], + ]]; + + $p = new Persistence\Array_($a); + $m = new Model($p, 'invoices'); + $m->addField('items', ['type' => 'integer']); + + $this->assertEquals(13.5, $m->action('fx', ['avg', 'items'])->getOne()); + $this->assertEquals(12.272727272727273, $m->action('fx0', ['avg', 'items'])->getOne()); + $this->assertEquals(0, $m->action('fx', ['min', 'items'])->getOne()); + $this->assertEquals(19, $m->action('fx', ['max', 'items'])->getOne()); + $this->assertEquals(135, $m->action('fx', ['sum', 'items'])->getOne()); + } + + public function testExists() + { + $a = ['invoices' => [ + 1 => ['id' => 1, 'number' => 'ABC9', 'items' => 11, 'active' => 1], + ]]; + + $p = new Persistence\Array_($a); + $m = new Model($p, 'invoices'); + $m->addField('items', ['type' => 'integer']); + + $this->assertEquals(1, $m->action('exists')->getOne()); + + $m->delete(1); + + $this->assertEquals(0, $m->action('exists')->getOne()); + } + /** * Returns exported data, but will use get() instead of export(). */ @@ -597,14 +769,15 @@ public function testUnsupportedAction() $m->action('foo'); } - public function testBadActionArgs() + public function testUnsupportedAggregate() { $a = [1 => ['name' => 'John']]; $p = new Persistence\Array_($a); $m = new Model($p); $m->addField('name'); + $this->expectException(Exception::class); - $m->action('select', 'foo'); // args should be array + $m->action('fx', ['UNSUPPORTED', 'name']); } public function testUnsupportedCondition1() @@ -619,34 +792,6 @@ public function testUnsupportedCondition1() } public function testUnsupportedCondition2() - { - $a = [1 => ['name' => 'John']]; - $p = new Persistence\Array_($a); - $m = new Model($p); - $m->addField('name'); - $m->addCondition('name', '<>', 'John'); - $this->expectException(Exception::class); - $m->export(); - } - - /** - * unsupported format - 4th param. - */ - public function testUnsupportedCondition3() - { - $a = [1 => ['name' => 'John']]; - $p = new Persistence\Array_($a); - $m = new Model($p); - $m->addField('name'); - $m->addCondition('name', 'like', '%o%', 'CASE_INSENSITIVE'); - $this->expectException(Exception::class); - $m->export(); - } - - /** - * unsupported format - param[0] not Field::class. - */ - public function testUnsupportedCondition5() { $a = [1 => ['name' => 'John']]; $p = new Persistence\Array_($a); diff --git a/tests/PersistentSqlTest.php b/tests/PersistentSqlTest.php index 8da3c951b..29a2b72e7 100644 --- a/tests/PersistentSqlTest.php +++ b/tests/PersistentSqlTest.php @@ -139,8 +139,12 @@ public function testModelInsertRows() $m->addField('name'); $m->addField('surname'); + $this->assertEquals(0, $m->action('exists')->getOne()); + $m->import($a['user']); // import data + $this->assertEquals(1, $m->action('exists')->getOne()); + $this->assertEquals(2, $m->action('count')->getOne()); } diff --git a/tests/ReferenceSqlTest.php b/tests/ReferenceSqlTest.php index 6aec843fb..e368b5400 100644 --- a/tests/ReferenceSqlTest.php +++ b/tests/ReferenceSqlTest.php @@ -156,7 +156,7 @@ public function testBasicOne() $e = $this->getEscapeChar(); $this->assertSame( - str_replace('"', $e, 'select "id","name" from "user" where "id" in (select "user_id" from "order" where "amount" > :a and "amount" < :b)'), + str_replace('"', $e, 'select "id","name" from "user" where "id" in (select "user_id" from "order" where ("amount" > :a and "amount" < :b))'), $o->ref('user_id')->action('select')->render() ); } diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php new file mode 100644 index 000000000..c5e56bc17 --- /dev/null +++ b/tests/ScopeTest.php @@ -0,0 +1,319 @@ +addField('name'); + $this->addField('code'); + + $this->addField('is_eu', ['type' => 'boolean', 'default' => false]); + + $this->hasMany('Users', new SUser()) + ->addField('user_names', ['field' => 'name', 'concat' => ',']); + } +} + +class SUser extends Model +{ + public $table = 'user'; + + public $caption = 'User'; + + public function init(): void + { + parent::init(); + + $this->addField('name'); + $this->addField('surname'); + $this->addField('is_vip', ['type' => 'boolean', 'default' => false]); + + $this->hasOne('country_id', new SCountry()) + ->withTitle() + ->addFields(['country_code' => 'code', 'is_eu']); + } +} + +class ScopeTest extends \atk4\schema\PhpunitTestCase +{ + protected $user; + protected $country; + + protected function setUp(): void + { + parent::setUp(); + + $this->country = new SCountry($this->db); + + $this->getMigrator($this->country)->drop()->create(); + + // Specifying hasMany here will perform input + $this->country->import([ + ['name' => 'Canada', 'code' => 'CA'], + ['name' => 'Latvia', 'code' => 'LV'], + ['name' => 'Japan', 'code' => 'JP'], + ['name' => 'Lithuania', 'code' => 'LT', 'is_eu' => true], + ['name' => 'Russia', 'code' => 'RU'], + ['name' => 'France', 'code' => 'FR'], + ['name' => 'Brazil', 'code' => 'BR'], + ]); + + $this->user = new SUser($this->db); + + $this->getMigrator($this->user)->drop()->create(); + + $this->user->import([ + ['name' => 'John', 'surname' => 'Smith', 'country_code' => 'CA'], + ['name' => 'Jane', 'surname' => 'Doe', 'country_code' => 'LV'], + ['name' => 'Alain', 'surname' => 'Prost', 'country_code' => 'FR'], + ['name' => 'Aerton', 'surname' => 'Senna', 'country_code' => 'BR'], + ['name' => 'Rubens', 'surname' => 'Barichello', 'country_code' => 'BR'], + ]); + } + + public function testCondition() + { + $user = clone $this->user; + + $condition = new BasicCondition('name', 'John'); + + $user->scope()->add($condition); + + $user->loadAny(); + + $this->assertEquals('Smith', $user->get('surname')); + } + + public function testContitionToWords() + { + $user = clone $this->user; + + $condition = new BasicCondition(new Expression('false')); + + $this->assertEquals('expression \'false\'', $condition->on($user)->toWords()); + + $condition = new BasicCondition('country_id/code', 'US'); + + $this->assertEquals('User that has reference Country Id where Code is equal to \'US\'', $condition->on($user)->toWords()); + + $condition = new BasicCondition('country_id', 2); + + $this->assertEquals('Country Id is equal to \'Latvia\'', $condition->on($user)->toWords()); + + if ($this->driverType == 'sqlite') { + $condition = new BasicCondition('name', $user->expr('[surname]')); + + $this->assertEquals('Name is equal to expression \'"surname"\'', $condition->on($user)->toWords()); + } + + $condition = new BasicCondition('country_id', null); + + $this->assertEquals('Country Id is equal to empty', $condition->on($user)->toWords()); + + $condition = new BasicCondition('name', '>', 'Test'); + + $this->assertEquals('Name is greater than \'Test\'', $condition->on($user)->toWords()); + + $condition = (new BasicCondition('country_id', 2))->negate(); + + $this->assertEquals('Country Id is not equal to \'Latvia\'', $condition->on($user)->toWords()); + + $condition = new BasicCondition($user->getField('surname'), $user->getField('name')); + + $this->assertEquals('Surname is equal to User Name', $condition->on($user)->toWords()); + + $country = clone $this->country; + + $country->addCondition('Users/#'); + + $this->assertEquals('Country that has reference Users where any referenced record exists', $country->scope()->toWords()); + + $country = clone $this->country; + + $country->addCondition('Users/!'); + + $this->assertEquals('Country that has reference Users where no referenced records exist', $country->scope()->toWords()); + } + + public function testContitionOnReferencedRecords() + { + $user = clone $this->user; + + $user->addCondition('country_id/code', 'LV'); + + $this->assertEquals(1, $user->action('count')->getOne()); + + foreach ($user as $u) { + $this->assertEquals('LV', $u->get('country_code')); + } + + $country = clone $this->country; + + // countries with no users + $country->addCondition('Users/!'); + + foreach ($country as $c) { + $this->assertEmpty($c->get('user_names')); + } + + $country = clone $this->country; + + // countries with any user + $country->addCondition('Users/?'); + + foreach ($country as $c) { + $this->assertNotEmpty($c->get('user_names')); + } + + $country = clone $this->country; + + // countries with more than one user + $country->addCondition('Users/#', '>', 1); + + foreach ($country as $c) { + $this->assertEquals('BR', $c->get('code')); + } + } + + public function testScope() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'John'); + $condition2 = new BasicCondition('country_code', 'CA'); + + $condition3 = new BasicCondition('surname', 'Doe'); + $condition4 = new BasicCondition('country_code', 'LV'); + + $compoundCondition1 = CompoundCondition::mergeAnd($condition1, $condition2); + $compoundCondition2 = CompoundCondition::mergeAnd($condition3, $condition4); + + $compoundCondition = CompoundCondition::mergeOr($compoundCondition1, $compoundCondition2); + + $this->assertEquals(CompoundCondition::OR, $compoundCondition->getJunction()); + + $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\')', $compoundCondition->on($user)->toWords()); + + $user->scope()->add($compoundCondition); + + $this->assertSame($user, $compoundCondition->getModel()); + + $this->assertEquals(2, count($user->export())); + + $this->assertEquals($compoundCondition->on($user)->toWords(), $user->scope()->toWords()); + + $condition5 = new BasicCondition('country_code', 'BR'); + + $compoundCondition = CompoundCondition::mergeOr($compoundCondition1, $compoundCondition2, $condition5); + + $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\') or Code is equal to \'BR\'', $compoundCondition->on($user)->toWords()); + + $user = clone $this->user; + + $user->scope()->add($compoundCondition); + + $this->assertEquals(4, count($user->export())); + } + + public function testScopeToWords() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'CA'); + + $compoundCondition1 = CompoundCondition::mergeAnd($condition1, $condition2); + $condition3 = (new BasicCondition('surname', 'Prost'))->negate(); + + $compoundCondition = CompoundCondition::mergeAnd($compoundCondition1, $condition3); + + $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'CA\') and Surname is not equal to \'Prost\'', $compoundCondition->on($user)->toWords()); + } + + public function testNegate() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', '!=', 'Alain'); + $condition2 = new BasicCondition('country_code', '!=', 'FR'); + + $condition = CompoundCondition::mergeOr($condition1, $condition2)->negate(); + + $user->scope()->add($condition); + + foreach ($user as $u) { + $this->assertTrue($u->get('name') == 'Alain' && $u->get('country_code') == 'FR'); + } + } + + public function testAnd() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'FR'); + + $compoundCondition = CompoundCondition::mergeAnd($condition1, $condition2); + + $compoundCondition = CompoundCondition::mergeOr($compoundCondition, new BasicCondition('name', 'John')); + + $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'FR\') or Name is equal to \'John\'', $compoundCondition->on($user)->toWords()); + } + + public function testOr() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'FR'); + + $compoundCondition = CompoundCondition::mergeOr($condition1, $condition2); + + $compoundCondition = CompoundCondition::mergeAnd($compoundCondition, new BasicCondition('name', 'John')); + + $this->assertEquals('(Name is equal to \'Alain\' or Code is equal to \'FR\') and Name is equal to \'John\'', $compoundCondition->on($user)->toWords()); + } + + public function testMerge() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'FR'); + + $compoundCondition = CompoundCondition::mergeAnd($condition1, $condition2); + + $this->assertEquals('Name is equal to \'Alain\' and Code is equal to \'FR\'', $compoundCondition->on($user)->toWords()); + } + + public function testDestroyEmpty() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'FR'); + + $compoundCondition = CompoundCondition::mergeAnd($condition1, $condition2); + + $compoundCondition->clear(); + + $this->assertTrue($compoundCondition->isEmpty()); + + $this->assertEmpty($compoundCondition->on($user)->toWords()); + } +} From c7e2c85720ab5fe8328a15b3e23121cf5665bb09 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 08:50:30 +0200 Subject: [PATCH 02/57] [fix] remove Model::$conditions property --- src/Model.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Model.php b/src/Model.php index 7f635b7bc..d5705d654 100644 --- a/src/Model.php +++ b/src/Model.php @@ -182,16 +182,6 @@ class Model implements \IteratorAggregate */ public $persistence_data = []; - /** - * Conditions list several conditions that must be met by all the - * records in the associated DataSet. Conditions are stored as - * elements of array of 1 to 3. Use addCondition() to add new - * conditions. - * - * @var array - */ - public $conditions = []; - /** @var Model\Scope */ protected $scope; From 55e527b8acbd8c316a6263441bca8064faf84893 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 08:58:46 +0200 Subject: [PATCH 03/57] [fix] code comments --- src/Model.php | 8 +------- src/Model/Scope/BasicCondition.php | 13 +++++++++++++ src/Model/Scope/CompoundCondition.php | 4 +--- src/Persistence/Array_.php | 6 ++++++ 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/Model.php b/src/Model.php index d5705d654..74e1d303f 100644 --- a/src/Model.php +++ b/src/Model.php @@ -972,10 +972,8 @@ public function addCondition($field, $operator = null, $value = null) /** * Get the scope object of the Model. - * - * @return Model\Scope */ - public function scope() + public function scope(): Model\Scope { return $this->scope->setModel($this); } @@ -1428,7 +1426,6 @@ public function loadBy(string $fieldName, $value) { $field = $this->getField($fieldName); - // backup $scopeBak = $this->scope; $systemBak = $field->system; $defaultBak = $field->default; @@ -1440,7 +1437,6 @@ public function loadBy(string $fieldName, $value) $this->loadAny(); } finally { - // restore $this->scope = $scopeBak; $field->system = $systemBak; $field->default = $defaultBak; @@ -1461,7 +1457,6 @@ public function tryLoadBy(string $fieldName, $value) { $field = $this->getField($fieldName); - // backup $scopeBak = $this->scope; $systemBak = $field->system; $defaultBak = $field->default; @@ -1473,7 +1468,6 @@ public function tryLoadBy(string $fieldName, $value) $this->tryLoadAny(); } finally { - // restore $this->scope = $scopeBak; $field->system = $systemBak; $field->default = $defaultBak; diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 62c196adb..6307cc875 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -14,10 +14,23 @@ class BasicCondition extends AbstractCondition { use ReadableCaptionTrait; + /** + * Stores the condition key. + * + * @var string|Field|Expression + */ public $key; + /** + * Stores the condition operator. + * + * @var string + */ public $operator; + /** + * Stores the condition value. + */ public $value; protected static $opposites = [ diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 232436e92..2e1d60287 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -141,9 +141,7 @@ public function simplify() return $this; } - /** - * @var AbstractCondition $component - */ + /** @var AbstractCondition $component */ $component = reset($this->elements); return $component->simplify(); diff --git a/src/Persistence/Array_.php b/src/Persistence/Array_.php index 3f5b8dc39..5e7c7e4e9 100644 --- a/src/Persistence/Array_.php +++ b/src/Persistence/Array_.php @@ -31,6 +31,12 @@ public function __construct(array $data = []) */ protected $lastInsertIds = []; + /** + * @deprecated TODO temporary for these: + * - https://github.com/atk4/data/blob/90ab68ac063b8fc2c72dcd66115f1bd3f70a3a92/src/Reference/ContainsOne.php#L119 + * - https://github.com/atk4/data/blob/90ab68ac063b8fc2c72dcd66115f1bd3f70a3a92/src/Reference/ContainsMany.php#L66 + * remove once fixed/no longer needed + */ public function getRawDataByTable(string $table): array { return $this->data[$table]; From b25b01e8b4047baad79e331472e036cfe3749ff7 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 09:08:11 +0200 Subject: [PATCH 04/57] [update] require strictly correct junction value --- src/Model/Scope/CompoundCondition.php | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 2e1d60287..009dc8c86 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -5,6 +5,7 @@ namespace atk4\data\Model\Scope; use atk4\core\ContainerTrait; +use atk4\data\Exception; /** * @property AbstractCondition[] $elements @@ -17,16 +18,6 @@ class CompoundCondition extends AbstractCondition public const OR = 'OR'; public const AND = 'AND'; - /** - * Array of valid junctions. - * - * @var array - */ - public const JUNCTIONS = [ - self::AND, - self::OR, - ]; - /** * Junction to use in case more than one element. * @@ -36,11 +27,16 @@ class CompoundCondition extends AbstractCondition /** * Create a CompoundCondition from array of condition objects or condition arrays. + * + * @param AbstractCondition[]|array[] $nestedConditions */ - public function __construct(array $nestedConditions = [], $junction = self::AND) + public function __construct(array $nestedConditions = [], string $junction = self::AND) { - // use one of JUNCTIONS values, otherwise $junction is truish means OR, falsish means AND - $this->junction = in_array($junction, self::JUNCTIONS, true) ? $junction : self::JUNCTIONS[$junction ? 1 : 0]; + if (!in_array($junction, [self::OR, self::AND], true)) { + throw new Exception($junction . ' is not a valid CompondCondition junction'); + } + + $this->junction = $junction; foreach ($nestedConditions as $nestedCondition) { $nestedCondition = is_string($nestedCondition) ? new BasicCondition($nestedCondition) : $nestedCondition; From 64ac1cab4bca2871e819e8e7a16ce36963f034c7 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 09:08:39 +0200 Subject: [PATCH 05/57] [fix] use assertSame in tests --- tests/ConditionTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index 7a0c0d2f9..c9e40bbbe 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -32,14 +32,14 @@ public function testBasicDiscrimination() $m->addCondition('gender', 'M'); - $this->assertEquals(1, count($m->scope()->getNestedConditions())); + $this->assertSame(1, count($m->scope()->getNestedConditions())); $m->addCondition('gender', 'F'); - $this->assertEquals(2, count($m->scope()->getNestedConditions())); + $this->assertSame(2, count($m->scope()->getNestedConditions())); $m->addCondition([['gender', 'F'], ['foo', 'bar']]); - $this->assertEquals(3, count($m->scope()->getNestedConditions())); + $this->assertSame(3, count($m->scope()->getNestedConditions())); } public function testEditableAfterCondition() From e57df59ed4914a5dbc07543e8b479ffb88467562 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 09:36:21 +0200 Subject: [PATCH 06/57] [update] remove unnecessary code --- src/Model/Scope/CompoundCondition.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 009dc8c86..4c88678b7 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -161,10 +161,6 @@ public function negate() public function toWords(bool $asHtml = false): string { - if ($this->isEmpty()) { - return ''; - } - $parts = []; foreach ($this->elements as $nestedCondition) { $words = $nestedCondition->toWords($asHtml); From 0e0a040a2d11a1548935ce43e06f5b09392b8df0 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 09:38:09 +0200 Subject: [PATCH 07/57] [update] use limit as param name --- src/Action/Iterator.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Action/Iterator.php b/src/Action/Iterator.php index 6e30cf69b..91a68b190 100644 --- a/src/Action/Iterator.php +++ b/src/Action/Iterator.php @@ -237,14 +237,14 @@ public function order($fields) /** * Limit Iterator. * - * @param int $length + * @param int $limit * @param int $offset * * @return $this */ - public function limit($length, $offset = 0) + public function limit($limit, $offset = 0) { - $data = array_slice($this->get(), $offset, $length, true); + $data = array_slice($this->get(), $offset, $limit, true); // put data back in generator $this->generator = new \ArrayIterator($data); From aae0f5d0312eaba36048bb347a3ebecc9d1be048 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 09:41:50 +0200 Subject: [PATCH 08/57] [update] shorter way to ensure AND junction in Scope --- src/Model/Scope.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Model/Scope.php b/src/Model/Scope.php index 215bcfd49..af5cdbda6 100644 --- a/src/Model/Scope.php +++ b/src/Model/Scope.php @@ -19,11 +19,9 @@ class Scope extends Scope\CompoundCondition */ protected $model; - public function __construct() + public function __construct(array $nestedConditions = []) { - parent::__construct(...func_get_args()); - - $this->junction = self::AND; + parent::__construct($nestedConditions, self::AND); } public function setModel(Model $model) From a233b348672ea212356d3828c810e5c3e6661eb1 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 10:08:19 +0200 Subject: [PATCH 09/57] [update] use str_contains --- src/Model/Scope/BasicCondition.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 6307cc875..9bb36cac1 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -99,7 +99,7 @@ public function onChangeModel(): void // @todo: consider this when condition is part of OR scope if ($this->operator === '=' && !is_object($this->value) && !is_array($this->value)) { // key containing '/' means chained references and it is handled in toArray method - if (is_string($field = $this->key) && stripos($field, '/') === false) { + if (is_string($field = $this->key) && !str_contains($field, '/')) { $field = $model->getField($field); } From 6ff4b737d073723c2a0eac43e2f40f369dbc9166 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 11:35:10 +0200 Subject: [PATCH 10/57] [fix] change more assertEquals to assertSame --- tests/LocaleTest.php | 2 +- tests/PersistentArrayTest.php | 86 +++++++++++++++++------------------ 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/LocaleTest.php b/tests/LocaleTest.php index 3a80c37db..591dd7518 100644 --- a/tests/LocaleTest.php +++ b/tests/LocaleTest.php @@ -22,7 +22,7 @@ public function testException() public function testGetPath() { $rootDir = realpath(dirname(__DIR__) . '/src/..'); - $this->assertEquals($rootDir . \DIRECTORY_SEPARATOR . 'locale', realpath(Locale::getPath())); + $this->assertSame($rootDir . \DIRECTORY_SEPARATOR . 'locale', realpath(Locale::getPath())); } public function testLocaleIntegration() diff --git a/tests/PersistentArrayTest.php b/tests/PersistentArrayTest.php index 079ec245d..f8b4e524f 100644 --- a/tests/PersistentArrayTest.php +++ b/tests/PersistentArrayTest.php @@ -181,7 +181,7 @@ public function testInsert() ], ], $this->getInternalPersistenceData($p)); - $this->assertEquals(3, $p->lastInsertID()); + $this->assertSame(3, $p->lastInsertID()); } public function testIterator() @@ -382,13 +382,13 @@ public function testLike() $m->scope()->clear(); $m->addCondition('country', 'NOT LIKE', 'La%'); $result = $m->action('select')->get(); - $this->assertEquals(6, count($m->export())); - $this->assertEquals($a['countries'][1], $result[1]); - $this->assertEquals($a['countries'][2], $result[2]); - $this->assertEquals($a['countries'][4], $result[4]); - $this->assertEquals($a['countries'][5], $result[5]); - $this->assertEquals($a['countries'][6], $result[6]); - $this->assertEquals($a['countries'][8], $result[8]); + $this->assertSame(6, count($m->export())); + $this->assertSame($a['countries'][1], $result[1]); + $this->assertSame($a['countries'][2], $result[2]); + $this->assertSame($a['countries'][4], $result[4]); + $this->assertSame($a['countries'][5], $result[5]); + $this->assertSame($a['countries'][6], $result[6]); + $this->assertSame($a['countries'][8], $result[8]); unset($result); // case : %str @@ -478,81 +478,81 @@ public function testConditions() $m->scope()->clear(); $m->addCondition('country', 'REGEXP', 'Ireland|UK'); $result = $m->action('select')->get(); - $this->assertEquals(5, count($result)); - $this->assertEquals($a['countries'][1], $result[1]); - $this->assertEquals($a['countries'][2], $result[2]); - $this->assertEquals($a['countries'][4], $result[4]); - $this->assertEquals($a['countries'][5], $result[5]); - $this->assertEquals($a['countries'][6], $result[6]); + $this->assertSame(5, count($result)); + $this->assertSame($a['countries'][1], $result[1]); + $this->assertSame($a['countries'][2], $result[2]); + $this->assertSame($a['countries'][4], $result[4]); + $this->assertSame($a['countries'][5], $result[5]); + $this->assertSame($a['countries'][6], $result[6]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('country', 'NOT REGEXP', 'Ireland|UK|Latvia'); $result = $m->action('select')->get(); - $this->assertEquals(1, count($result)); - $this->assertEquals($a['countries'][8], $result[8]); + $this->assertSame(1, count($result)); + $this->assertSame($a['countries'][8], $result[8]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('code', '>', 18); $result = $m->action('select')->get(); - $this->assertEquals(1, count($result)); - $this->assertEquals($a['countries'][9], $result[9]); + $this->assertSame(1, count($result)); + $this->assertSame($a['countries'][9], $result[9]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('code', '>=', 18); $result = $m->action('select')->get(); - $this->assertEquals(2, count($result)); - $this->assertEquals($a['countries'][8], $result[8]); - $this->assertEquals($a['countries'][9], $result[9]); + $this->assertSame(2, count($result)); + $this->assertSame($a['countries'][8], $result[8]); + $this->assertSame($a['countries'][9], $result[9]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('code', '<', 12); $result = $m->action('select')->get(); - $this->assertEquals(1, count($result)); - $this->assertEquals($a['countries'][1], $result[1]); + $this->assertSame(1, count($result)); + $this->assertSame($a['countries'][1], $result[1]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('code', '<=', 12); $result = $m->action('select')->get(); - $this->assertEquals(2, count($result)); - $this->assertEquals($a['countries'][1], $result[1]); - $this->assertEquals($a['countries'][2], $result[2]); + $this->assertSame(2, count($result)); + $this->assertSame($a['countries'][1], $result[1]); + $this->assertSame($a['countries'][2], $result[2]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('code', [11, 12]); $result = $m->action('select')->get(); - $this->assertEquals(2, count($result)); - $this->assertEquals($a['countries'][1], $result[1]); - $this->assertEquals($a['countries'][2], $result[2]); + $this->assertSame(2, count($result)); + $this->assertSame($a['countries'][1], $result[1]); + $this->assertSame($a['countries'][2], $result[2]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('code', 'NOT IN', [11, 12, 13, 14, 15, 16, 17]); $result = $m->action('select')->get(); - $this->assertEquals(2, count($result)); - $this->assertEquals($a['countries'][8], $result[8]); - $this->assertEquals($a['countries'][9], $result[9]); + $this->assertSame(2, count($result)); + $this->assertSame($a['countries'][8], $result[8]); + $this->assertSame($a['countries'][9], $result[9]); unset($result); $m->unload(); $m->scope()->clear(); $m->addCondition('code', '!=', [11, 12, 13, 14, 15, 16, 17]); $result = $m->action('select')->get(); - $this->assertEquals(2, count($result)); - $this->assertEquals($a['countries'][8], $result[8]); - $this->assertEquals($a['countries'][9], $result[9]); + $this->assertSame(2, count($result)); + $this->assertSame($a['countries'][8], $result[8]); + $this->assertSame($a['countries'][9], $result[9]); unset($result); $m->unload(); } @@ -577,11 +577,11 @@ public function testAggregates() $m = new Model($p, 'invoices'); $m->addField('items', ['type' => 'integer']); - $this->assertEquals(13.5, $m->action('fx', ['avg', 'items'])->getOne()); - $this->assertEquals(12.272727272727273, $m->action('fx0', ['avg', 'items'])->getOne()); - $this->assertEquals(0, $m->action('fx', ['min', 'items'])->getOne()); - $this->assertEquals(19, $m->action('fx', ['max', 'items'])->getOne()); - $this->assertEquals(135, $m->action('fx', ['sum', 'items'])->getOne()); + $this->assertSame(13.5, $m->action('fx', ['avg', 'items'])->getOne()); + $this->assertSame(12.272727272727273, $m->action('fx0', ['avg', 'items'])->getOne()); + $this->assertSame(0, $m->action('fx', ['min', 'items'])->getOne()); + $this->assertSame(19, $m->action('fx', ['max', 'items'])->getOne()); + $this->assertSame(135, $m->action('fx', ['sum', 'items'])->getOne()); } public function testExists() @@ -594,11 +594,11 @@ public function testExists() $m = new Model($p, 'invoices'); $m->addField('items', ['type' => 'integer']); - $this->assertEquals(1, $m->action('exists')->getOne()); + $this->assertSame(1, $m->action('exists')->getOne()); $m->delete(1); - $this->assertEquals(0, $m->action('exists')->getOne()); + $this->assertSame(0, $m->action('exists')->getOne()); } /** @@ -684,7 +684,7 @@ public function testOrder() ['f1' => 'A', 'f2' => 'C', 'id' => 4], ['f1' => 'A', 'f2' => 'B', 'id' => 1], ], $d); - $this->assertEquals($d, array_values($m->export(['f1', 'f2', 'id']))); // array_values to get rid of keys + $this->assertSame($d, array_values($m->export(['f1', 'f2', 'id']))); // array_values to get rid of keys } /** From e3783e8af60690e7b8600a784b2d52a1c0a8d2ec Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 11:40:52 +0200 Subject: [PATCH 11/57] [update] flexibility for bool only when 1 argument --- src/Model/Scope/BasicCondition.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 9bb36cac1..851892901 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -72,12 +72,7 @@ class BasicCondition extends AbstractCondition public function __construct($key, $operator = null, $value = null) { - if (func_num_args() == 2) { - $value = $operator; - $operator = '='; - } - - if (is_bool($key)) { + if (func_num_args() == 1 && is_bool($key)) { if ($key) { return; } @@ -85,6 +80,11 @@ public function __construct($key, $operator = null, $value = null) $key = new Expression('false'); } + if (func_num_args() == 2) { + $value = $operator; + $operator = '='; + } + $this->key = $key; $this->operator = $operator; $this->value = $value; From 371d2faa1fad1ca3501f3f32d5245bd3506ba188 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 11:48:23 +0200 Subject: [PATCH 12/57] [update] revert commented out code --- tests/LookupSqlTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/LookupSqlTest.php b/tests/LookupSqlTest.php index b09235438..3ee505f90 100644 --- a/tests/LookupSqlTest.php +++ b/tests/LookupSqlTest.php @@ -70,8 +70,8 @@ public function init(): void ->withTitle() ->addFields(['country_code' => 'code', 'is_eu']); -// $this->hasMany('Friends', new LFriend()) -// ->addField('friend_names', ['field' => 'friend_name', 'concat' => ',']); + $this->hasMany('Friends', new LFriend()) + ->addField('friend_names', ['field' => 'friend_name', 'concat' => ',']); } } From e0fd02c8cd4dd314c7516781075dd08f43aacbd2 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 14:28:09 +0200 Subject: [PATCH 13/57] [update] set getModel return type --- src/Model/Scope.php | 2 +- src/Model/Scope/AbstractCondition.php | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Model/Scope.php b/src/Model/Scope.php index af5cdbda6..6b65e5591 100644 --- a/src/Model/Scope.php +++ b/src/Model/Scope.php @@ -33,7 +33,7 @@ public function setModel(Model $model) return $this; } - public function getModel() + public function getModel(): ?Model { return $this->model; } diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php index 0fa727b3a..2baaf0384 100644 --- a/src/Model/Scope/AbstractCondition.php +++ b/src/Model/Scope/AbstractCondition.php @@ -52,7 +52,10 @@ final public function on(Model $model) return $clone; } - public function getModel() + /** + * Get the model this condition is associated with. + */ + public function getModel(): ?Model { return $this->owner ? $this->owner->getModel() : null; } From faef583ec781caf16f43fe9a3e50754f74ae2bea Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Wed, 22 Jul 2020 14:42:22 +0200 Subject: [PATCH 14/57] [update] use str_contains --- src/Model/Scope/BasicCondition.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 851892901..a20e9d619 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -128,7 +128,7 @@ public function toArray(): array if (is_string($field)) { // shorthand for adding conditions on references // use chained reference names separated by "/" - if (stripos($field, '/') !== false) { + if (str_contains($field, '/')) { $references = explode('/', $field); $field = array_pop($references); @@ -236,7 +236,7 @@ protected function keyToWords(bool $asHtml = false): string $words = []; if (is_string($field = $this->key)) { - if (stripos($field, '/') !== false) { + if (str_contains($field, '/')) { $references = explode('/', $field); $words[] = $model->getModelCaption(); @@ -313,7 +313,7 @@ protected function valueToWords($value, bool $asHtml = false): string // handling of scope on references if (is_string($field = $this->key)) { - if (stripos($field, '/') !== false) { + if (str_contains($field, '/')) { $references = explode('/', $field); $field = array_pop($references); From 88ab0f602e57a3fc89e6ae343f31203f57da31f9 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 11:53:46 +0200 Subject: [PATCH 15/57] [update] remove ! and ? special symbols --- docs/conditions.rst | 10 ---------- src/Model/Scope/BasicCondition.php | 32 +++++++++--------------------- 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/docs/conditions.rst b/docs/conditions.rst index d5c11af5e..5f772cf1a 100644 --- a/docs/conditions.rst +++ b/docs/conditions.rst @@ -409,14 +409,4 @@ This will limit the $contact model to those whose company have more than 3 ticke 'company' and 'tickets' are the name of the chained references ('company' is a reference in the $contact model and 'tickets' is a reference in Company model) -For applying conditions on existence of records the '?' (has any) and '!' (doesn't have any) special symbols can be used. -Although it is similar in functionality to checking ('company/tickets/#', '>', 0) or ('company/tickets/#', '=', 0) -'?' and '!' special symbols use optimized query and are much faster:: - - // Contact whose company has any tickets - $contact->addCondition('company/tickets/?'); - - // Contact whose company doesn't have any tickets - $contact->addCondition('company/tickets/!'); - diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index a20e9d619..94651c129 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -130,36 +130,22 @@ public function toArray(): array // use chained reference names separated by "/" if (str_contains($field, '/')) { $references = explode('/', $field); - $field = array_pop($references); + $refModels = []; foreach ($references as $link) { - $model = $model->refLink($link); + $refModels[] = $model = $model->refLink($link); } - // '#' -> has # referenced records - // '?' -> has any referenced records - // '!' -> does not have any referenced records - if (in_array($field, ['#', '!', '?'], true)) { - // if no operator consider '#' as 'any record exists' - if ($field == '#' && !$operator) { - $field = '?'; - } - - if (in_array($field, ['!', '?'], true)) { - $operator = '='; - $value = $field == '?' ? 1 : 0; + foreach (array_reverse($refModels) as $refModel) { + if ($field === '#') { + $field = $value ? $refModel->action('count') : $refModel->action('exists'); + } else { + $refModel->addCondition($field, $operator, $value); + $field = $refModel->action('exists'); + $operator = $value = null; } - } else { - // otherwise add the condition to the referenced model - // and check if any records exist matching the criteria - $model->addCondition($field, $operator, $value); - $operator = '='; - $value = 1; } - - // if not counting we check for existence only - $field = $field == '#' ? $model->action('count') : $model->action('exists'); } else { $field = $model->getField($field); } From 845ca31219e746cf2c97fb7fe947c7daee3bc610 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 11:55:18 +0200 Subject: [PATCH 16/57] [update] introduce multi-level reference condition testing --- tests/ScopeTest.php | 84 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index c5e56bc17..3d45bf70b 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -46,6 +46,26 @@ public function init(): void $this->hasOne('country_id', new SCountry()) ->withTitle() ->addFields(['country_code' => 'code', 'is_eu']); + + $this->hasMany('Tickets', [new STicket(), 'their_field' => 'user']); + } +} + +class STicket extends Model +{ + public $table = 'ticket'; + + public $caption = 'Ticket'; + + public function init(): void + { + parent::init(); + + $this->addField('number'); + $this->addField('venue'); + $this->addField('is_vip', ['type' => 'boolean', 'default' => false]); + + $this->hasOne('user', new SUser()); } } @@ -53,6 +73,7 @@ class ScopeTest extends \atk4\schema\PhpunitTestCase { protected $user; protected $country; + protected $ticket; protected function setUp(): void { @@ -84,6 +105,16 @@ protected function setUp(): void ['name' => 'Aerton', 'surname' => 'Senna', 'country_code' => 'BR'], ['name' => 'Rubens', 'surname' => 'Barichello', 'country_code' => 'BR'], ]); + + $this->ticket = new STicket($this->db); + + $this->getMigrator($this->ticket)->drop()->create(); + + $this->ticket->import([ + ['number' => '001', 'venue' => 'Best Stadium', 'user' => 1], + ['number' => '002', 'venue' => 'Best Stadium', 'user' => 2], + ['number' => '003', 'venue' => 'Best Stadium', 'user' => 2], + ]); } public function testCondition() @@ -162,31 +193,64 @@ public function testContitionOnReferencedRecords() $this->assertEquals('LV', $u->get('country_code')); } + $user = clone $this->user; + + // users that have no ticket + $user->addCondition('Tickets/#', 0); + + $this->assertEquals(3, $user->action('count')->getOne()); + + foreach ($user as $u) { + $this->assertTrue(in_array($u->get('name'), ['Alain', 'Aerton', 'Rubens'])); + } + $country = clone $this->country; - // countries with no users - $country->addCondition('Users/!'); + // countries with more than one user + $country->addCondition('Users/#', '>', 1); foreach ($country as $c) { - $this->assertEmpty($c->get('user_names')); + $this->assertEquals('BR', $c->get('code')); } - + $country = clone $this->country; - // countries with any user - $country->addCondition('Users/?'); + // countries with users that have ticket number 001 + $country->addCondition('Users/Tickets/number', '001'); foreach ($country as $c) { - $this->assertNotEmpty($c->get('user_names')); + $this->assertEquals('CA', $c->get('code')); } + + $country = clone $this->country; + // countries with users that have more than one ticket + $country->addCondition('Users/Tickets/#', '>', 1); + + foreach ($country as $c) { + $this->assertEquals('LV', $c->get('code')); + } + $country = clone $this->country; - // countries with more than one user - $country->addCondition('Users/#', '>', 1); + // countries with users that have any tickets + $country->addCondition('Users/Tickets/#'); + + $this->assertEquals(2, $country->action('count')->getOne()); + + foreach ($country as $c) { + $this->assertTrue(in_array($c->get('code'), ['LV', 'CA'])); + } + + $country = clone $this->country; + + // countries with users that have no tickets + $country->addCondition('Users/Tickets/#', 0); + $this->assertEquals(2, $country->action('count')->getOne()); + foreach ($country as $c) { - $this->assertEquals('BR', $c->get('code')); + $this->assertTrue(in_array($c->get('code'), ['FR', 'BR'])); } } From f7f0d132357e913fc568d01012daf18fdbd2a33f Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 11:59:56 +0200 Subject: [PATCH 17/57] [update] remove unnecessary check --- src/Persistence/Sql.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index d42bffc19..5df216966 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -370,7 +370,7 @@ public function initQueryConditions(Model $model, Query $query, Model\Scope\Abst { $condition = $condition ?? $model->scope(); - if (!$condition || $condition->isEmpty()) { + if ($condition->isEmpty()) { return $query; } From 08be267a05def6013d050c872a2b3d2a02b9818f Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:01:38 +0200 Subject: [PATCH 18/57] [fix] code comments --- src/Persistence/Sql.php | 2 +- tests/ConditionSqlTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index 5df216966..766ee4bb7 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -364,7 +364,7 @@ protected function setLimitOrder(Model $m, Query $q) } /** - * Will apply scope defined inside $scope or $model->scope() onto $query. + * Will apply a condition defined inside $condition or $model->scope() onto $query. */ public function initQueryConditions(Model $model, Query $query, Model\Scope\AbstractCondition $condition = null): Query { diff --git a/tests/ConditionSqlTest.php b/tests/ConditionSqlTest.php index 527a2ba4f..1795d49ca 100644 --- a/tests/ConditionSqlTest.php +++ b/tests/ConditionSqlTest.php @@ -391,12 +391,12 @@ public function testLoadBy() $u = (new Model($this->db, 'user'))->addFields(['name']); $u->loadBy('name', 'John'); - $this->assertTrue($u->scope()->isEmpty()); // should be no conditions + $this->assertTrue($u->scope()->isEmpty()); $this->assertFalse($u->getField('name')->system); // should not set field as system $this->assertNull($u->getField('name')->default); // should not set field default value $u->tryLoadBy('name', 'John'); - $this->assertTrue($u->scope()->isEmpty()); // should be no conditions + $this->assertTrue($u->scope()->isEmpty()); $this->assertFalse($u->getField('name')->system); // should not set field as system $this->assertNull($u->getField('name')->default); // should not set field default value } From f5051e6b7940117ce01aad3eaef3d402b906e8d6 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:05:42 +0200 Subject: [PATCH 19/57] [update] improve CS --- src/Model.php | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Model.php b/src/Model.php index 74e1d303f..6d536f826 100644 --- a/src/Model.php +++ b/src/Model.php @@ -780,12 +780,10 @@ public function setNull(string $field) }, [], PHP_INT_MIN); try { - $this->set($field, null); + return $this->set($field, null); } finally { $this->removeHook(self::HOOK_NORMALIZE, $hookIndex, true); } - - return $this; } /** @@ -1435,14 +1433,12 @@ public function loadBy(string $fieldName, $value) $this->scope = clone $this->scope; $this->addCondition($field, $value); - $this->loadAny(); + return $this->loadAny(); } finally { $this->scope = $scopeBak; $field->system = $systemBak; $field->default = $defaultBak; } - - return $this; } /** @@ -1466,14 +1462,12 @@ public function tryLoadBy(string $fieldName, $value) $this->scope = clone $this->scope; $this->addCondition($field, $value); - $this->tryLoadAny(); + return $this->tryLoadAny(); } finally { $this->scope = $scopeBak; $field->system = $systemBak; $field->default = $defaultBak; } - - return $this; } /** From 9c497a521ed5c0ea231d6f7428fe2df0c30308b2 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:14:16 +0200 Subject: [PATCH 20/57] [update] use spread operator in condition merging --- src/Model/Scope/CompoundCondition.php | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 4c88678b7..5577a8588 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -176,24 +176,20 @@ public function toWords(bool $asHtml = false): string /** * Merge number of conditions using AND as junction. * - * @param AbstractCondition $_ - * * @return static */ - public static function mergeAnd(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null) + public static function mergeAnd(...$conditions) { - return new self(func_get_args(), self::AND); + return new self($conditions, self::AND); } /** * Merge number of conditions using OR as junction. * - * @param AbstractCondition $_ - * * @return static */ - public static function mergeOr(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null) + public static function mergeOr(...$conditions) { - return new self(func_get_args(), self::OR); + return new self($conditions, self::OR); } } From a1c17c1eaf7a6b3969dfc63dfc9f5858e59a3a5f Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:27:33 +0200 Subject: [PATCH 21/57] [update] introduce return type for simplify --- src/Model/Scope/AbstractCondition.php | 4 +--- src/Model/Scope/CompoundCondition.php | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php index 2baaf0384..8ba804c6b 100644 --- a/src/Model/Scope/AbstractCondition.php +++ b/src/Model/Scope/AbstractCondition.php @@ -90,10 +90,8 @@ abstract public function toWords(bool $asHtml = false): string; /** * Simplifies by peeling off nested group conditions with single contained component. * Useful for converting (((field = value))) to field = value. - * - * @return AbstractCondition */ - public function simplify() + public function simplify(): AbstractCondition { return $this; } diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 5577a8588..597ac819e 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -131,9 +131,9 @@ public function clear() return $this; } - public function simplify() + public function simplify(): AbstractCondition { - if (count($this->elements) != 1) { + if (count($this->elements) !== 1) { return $this; } From b9895f7f2d1ecc813db08596722a6c7599c0b8f1 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:28:09 +0200 Subject: [PATCH 22/57] [update] introduce test for empty IN array --- tests/PersistentArrayTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PersistentArrayTest.php b/tests/PersistentArrayTest.php index f8b4e524f..c372d241d 100644 --- a/tests/PersistentArrayTest.php +++ b/tests/PersistentArrayTest.php @@ -538,6 +538,13 @@ public function testConditions() unset($result); $m->unload(); + $m->scope()->clear(); + $m->addCondition('code', 'IN', []); + $result = $m->action('select')->get(); + $this->assertSame(0, count($result)); + unset($result); + $m->unload(); + $m->scope()->clear(); $m->addCondition('code', 'NOT IN', [11, 12, 13, 14, 15, 16, 17]); $result = $m->action('select')->get(); From 72286a118ed73c4bc102359323aa35c6cbc23645 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:35:02 +0200 Subject: [PATCH 23/57] [fix] CS fixer --- src/Model/Scope/AbstractCondition.php | 2 +- tests/ScopeTest.php | 38 +++++++++++++-------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php index 8ba804c6b..558c2e77c 100644 --- a/src/Model/Scope/AbstractCondition.php +++ b/src/Model/Scope/AbstractCondition.php @@ -91,7 +91,7 @@ abstract public function toWords(bool $asHtml = false): string; * Simplifies by peeling off nested group conditions with single contained component. * Useful for converting (((field = value))) to field = value. */ - public function simplify(): AbstractCondition + public function simplify(): self { return $this; } diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index 3d45bf70b..b3d7fc800 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -46,7 +46,7 @@ public function init(): void $this->hasOne('country_id', new SCountry()) ->withTitle() ->addFields(['country_code' => 'code', 'is_eu']); - + $this->hasMany('Tickets', [new STicket(), 'their_field' => 'user']); } } @@ -64,7 +64,7 @@ public function init(): void $this->addField('number'); $this->addField('venue'); $this->addField('is_vip', ['type' => 'boolean', 'default' => false]); - + $this->hasOne('user', new SUser()); } } @@ -105,7 +105,7 @@ protected function setUp(): void ['name' => 'Aerton', 'surname' => 'Senna', 'country_code' => 'BR'], ['name' => 'Rubens', 'surname' => 'Barichello', 'country_code' => 'BR'], ]); - + $this->ticket = new STicket($this->db); $this->getMigrator($this->ticket)->drop()->create(); @@ -194,16 +194,16 @@ public function testContitionOnReferencedRecords() } $user = clone $this->user; - + // users that have no ticket $user->addCondition('Tickets/#', 0); - + $this->assertEquals(3, $user->action('count')->getOne()); - + foreach ($user as $u) { - $this->assertTrue(in_array($u->get('name'), ['Alain', 'Aerton', 'Rubens'])); + $this->assertTrue(in_array($u->get('name'), ['Alain', 'Aerton', 'Rubens'], true)); } - + $country = clone $this->country; // countries with more than one user @@ -212,7 +212,7 @@ public function testContitionOnReferencedRecords() foreach ($country as $c) { $this->assertEquals('BR', $c->get('code')); } - + $country = clone $this->country; // countries with users that have ticket number 001 @@ -221,36 +221,36 @@ public function testContitionOnReferencedRecords() foreach ($country as $c) { $this->assertEquals('CA', $c->get('code')); } - + $country = clone $this->country; // countries with users that have more than one ticket $country->addCondition('Users/Tickets/#', '>', 1); - + foreach ($country as $c) { $this->assertEquals('LV', $c->get('code')); } - + $country = clone $this->country; // countries with users that have any tickets $country->addCondition('Users/Tickets/#'); - + $this->assertEquals(2, $country->action('count')->getOne()); - + foreach ($country as $c) { - $this->assertTrue(in_array($c->get('code'), ['LV', 'CA'])); + $this->assertTrue(in_array($c->get('code'), ['LV', 'CA'], true)); } - + $country = clone $this->country; - + // countries with users that have no tickets $country->addCondition('Users/Tickets/#', 0); $this->assertEquals(2, $country->action('count')->getOne()); - + foreach ($country as $c) { - $this->assertTrue(in_array($c->get('code'), ['FR', 'BR'])); + $this->assertTrue(in_array($c->get('code'), ['FR', 'BR'], true)); } } From 01b9052e37748874c4b49bb41af1781480aceafb Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:39:23 +0200 Subject: [PATCH 24/57] [update] remove unnecessary check --- src/Persistence/Array_.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Persistence/Array_.php b/src/Persistence/Array_.php index 5e7c7e4e9..421760ee5 100644 --- a/src/Persistence/Array_.php +++ b/src/Persistence/Array_.php @@ -375,8 +375,8 @@ public function action(Model $model, $type, $args = []) } $fx = $args[0]; - $field = $args[1] ?? null; - $action = $this->initAction($model, $args[1] ?? null); + $field = $args[1]; + $action = $this->initAction($model, $field); $this->applyScope($model, $action); $this->setLimitOrder($model, $action); From c395540519bb146279db0470037cec04cd9ee261 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:42:00 +0200 Subject: [PATCH 25/57] [update] simplify CompoundCondition::clear --- src/Model/Scope/CompoundCondition.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 597ac819e..2193a67ef 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -118,15 +118,13 @@ public function isAnd(): bool } /** - * Clears the group from nested conditions. + * Clears the compound condition from nested conditions. * * @return static */ public function clear() { - foreach ($this->elements as $nestedCondition) { - $nestedCondition->destroy(); - } + $this->elements = []; return $this; } From 99ec94eb340a5b6cd3bfd9d03127658400715fe5 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 12:45:47 +0200 Subject: [PATCH 26/57] [update] trigger onModelChange only if not same model --- src/Model/Scope.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Model/Scope.php b/src/Model/Scope.php index 6b65e5591..75d96febf 100644 --- a/src/Model/Scope.php +++ b/src/Model/Scope.php @@ -26,9 +26,11 @@ public function __construct(array $nestedConditions = []) public function setModel(Model $model) { - $this->model = $model; + if ($this->model !== $model) { + $this->model = $model; - $this->onChangeModel(); + $this->onChangeModel(); + } return $this; } From a5760b7cc79e4d05b91afd27f5d413e39991f78b Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 13:03:37 +0200 Subject: [PATCH 27/57] [update] initQueryConditions returns void --- src/Persistence/Sql.php | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index 766ee4bb7..b54402a39 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -366,34 +366,30 @@ protected function setLimitOrder(Model $m, Query $q) /** * Will apply a condition defined inside $condition or $model->scope() onto $query. */ - public function initQueryConditions(Model $model, Query $query, Model\Scope\AbstractCondition $condition = null): Query + public function initQueryConditions(Model $model, Query $query, Model\Scope\AbstractCondition $condition = null): void { $condition = $condition ?? $model->scope(); - if ($condition->isEmpty()) { - return $query; - } + if (!$condition->isEmpty()) { + // peel off the single nested scopes to convert (((field = value))) to field = value + $condition = $condition->simplify(); - // peel off the single nested scopes to convert (((field = value))) to field = value - $condition = $condition->simplify(); + // simple condition + if ($condition instanceof Model\Scope\BasicCondition) { + $query = $query->where(...$condition->toArray()); + } - // simple condition - if ($condition instanceof Model\Scope\BasicCondition) { - $query = $query->where(...$condition->toArray()); - } + // nested conditions + if ($condition instanceof Model\Scope\CompoundCondition) { + $expression = $condition->isOr() ? $query->orExpr() : $query->andExpr(); - // nested conditions - if ($condition instanceof Model\Scope\CompoundCondition) { - $expression = $condition->isOr() ? $query->orExpr() : $query->andExpr(); + foreach ($condition->getNestedConditions() as $nestedCondition) { + $this->initQueryConditions($model, $expression, $nestedCondition); + } - foreach ($condition->getNestedConditions() as $nestedCondition) { - $expression = $this->initQueryConditions($model, $expression, $nestedCondition); + $query = $query->where($expression); } - - $query = $query->where($expression); } - - return $query; } /** From 2438abcbe8c4f87d4196d5a62ead92f9e5214e06 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 14:50:15 +0200 Subject: [PATCH 28/57] [update] include repeating model reference testing --- tests/ScopeTest.php | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index b3d7fc800..b45a56879 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -114,6 +114,8 @@ protected function setUp(): void ['number' => '001', 'venue' => 'Best Stadium', 'user' => 1], ['number' => '002', 'venue' => 'Best Stadium', 'user' => 2], ['number' => '003', 'venue' => 'Best Stadium', 'user' => 2], + ['number' => '004', 'venue' => 'Best Stadium', 'user' => 4], + ['number' => '005', 'venue' => 'Best Stadium', 'user' => 5], ]); } @@ -198,7 +200,7 @@ public function testContitionOnReferencedRecords() // users that have no ticket $user->addCondition('Tickets/#', 0); - $this->assertEquals(3, $user->action('count')->getOne()); + $this->assertEquals(1, $user->action('count')->getOne()); foreach ($user as $u) { $this->assertTrue(in_array($u->get('name'), ['Alain', 'Aerton', 'Rubens'], true)); @@ -236,10 +238,10 @@ public function testContitionOnReferencedRecords() // countries with users that have any tickets $country->addCondition('Users/Tickets/#'); - $this->assertEquals(2, $country->action('count')->getOne()); + $this->assertEquals(3, $country->action('count')->getOne()); foreach ($country as $c) { - $this->assertTrue(in_array($c->get('code'), ['LV', 'CA'], true)); + $this->assertTrue(in_array($c->get('code'), ['LV', 'CA', 'BR'], true)); } $country = clone $this->country; @@ -247,10 +249,20 @@ public function testContitionOnReferencedRecords() // countries with users that have no tickets $country->addCondition('Users/Tickets/#', 0); - $this->assertEquals(2, $country->action('count')->getOne()); + $this->assertEquals(1, $country->action('count')->getOne()); foreach ($country as $c) { - $this->assertTrue(in_array($c->get('code'), ['FR', 'BR'], true)); + $this->assertTrue(in_array($c->get('code'), ['FR'], true)); + } + + $user = clone $this->user; + + $user->addCondition('Tickets/user/country_id/Users/#', '>', 1); + + $this->assertEquals(2, $user->action('count')->getOne()); + + foreach ($user as $u) { + $this->assertTrue(in_array($u->get('name'), ['Aerton', 'Rubens'], true)); } } From c3849a902130792d078a26c0b276df29682647b4 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 15:21:00 +0200 Subject: [PATCH 29/57] [update] toArrat to toQueryArgumentsArray --- src/Model/Scope/BasicCondition.php | 4 ++-- src/Persistence/Sql.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 94651c129..98aaddc3d 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -98,7 +98,7 @@ public function onChangeModel(): void // new records will automatically get this value assigned for the field // @todo: consider this when condition is part of OR scope if ($this->operator === '=' && !is_object($this->value) && !is_array($this->value)) { - // key containing '/' means chained references and it is handled in toArray method + // key containing '/' means chained references and it is handled in toQueryArgumentsArray method if (is_string($field = $this->key) && !str_contains($field, '/')) { $field = $model->getField($field); } @@ -111,7 +111,7 @@ public function onChangeModel(): void } } - public function toArray(): array + public function toQueryArgumentsArray(): array { // make sure clones are used to avoid changes $condition = clone $this; diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index b54402a39..bc19efcaf 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -376,7 +376,7 @@ public function initQueryConditions(Model $model, Query $query, Model\Scope\Abst // simple condition if ($condition instanceof Model\Scope\BasicCondition) { - $query = $query->where(...$condition->toArray()); + $query = $query->where(...$condition->toQueryArgumentsArray()); } // nested conditions From 463e82a9ed214c3c7e19dccdf0aab9a7176cb418 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 15:23:01 +0200 Subject: [PATCH 30/57] [update] isEmpty method --- src/Model/Scope/CompoundCondition.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 2193a67ef..5cd775e8f 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -85,7 +85,7 @@ public function onChangeModel(): void public function isEmpty(): bool { - return empty($this->elements); + return count($this->elements) === 0; } public function isCompound(): bool From acc19e135ae9a44d7a640dd830974e72912588de Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 15:24:47 +0200 Subject: [PATCH 31/57] [update] use single quotes --- src/Model/Scope/CompoundCondition.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 5cd775e8f..03604e966 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -163,7 +163,7 @@ public function toWords(bool $asHtml = false): string foreach ($this->elements as $nestedCondition) { $words = $nestedCondition->toWords($asHtml); - $parts[] = $this->isCompound() && $nestedCondition->isCompound() ? "({$words})" : $words; + $parts[] = $this->isCompound() && $nestedCondition->isCompound() ? '(' . $words . ')' : $words; } $glue = ' ' . strtolower($this->junction) . ' '; From 0c2cd3e3c4f4fe0531b4322d9c979aa2fc265cd4 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 15:28:43 +0200 Subject: [PATCH 32/57] [fix] operator to words falls back to actual operator value --- src/Model/Scope/BasicCondition.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 98aaddc3d..78bcda81a 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -265,7 +265,7 @@ protected function keyToWords(bool $asHtml = false): string protected function operatorToWords(bool $asHtml = false): string { - return $this->operator ? (self::$dictionary[strtoupper((string) $this->operator)] ?? 'is equal to') : ''; + return $this->operator ? (self::$dictionary[strtoupper((string) $this->operator)] ?? $this->operator) : ''; } protected function valueToWords($value, bool $asHtml = false): string From 8f25544477d6ec83f8a32f920e5156e1e7f28663 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 15:32:22 +0200 Subject: [PATCH 33/57] [fix] array persistence query --- src/Action/Iterator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Action/Iterator.php b/src/Action/Iterator.php index 91a68b190..6790e32e7 100644 --- a/src/Action/Iterator.php +++ b/src/Action/Iterator.php @@ -102,7 +102,7 @@ protected function match(array $row, Model\Scope\AbstractCondition $condition) // simple condition if ($condition instanceof Model\Scope\BasicCondition) { - $args = $condition->toArray(); + $args = $condition->toQueryArgumentsArray(); $field = $args[0]; $operator = $args[1] ?? null; From e62780c5ad29f002a8448129e07581f3ba311a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 15:36:55 +0200 Subject: [PATCH 34/57] [update] add complex test for referenced records --- tests/ScopeTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index b45a56879..7d14f4393 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -257,7 +257,14 @@ public function testContitionOnReferencedRecords() $user = clone $this->user; + // users with tickets that have more than two users per country) + // test if a model can be referenced multiple times + // and if generated query has no duplicate column names + // because of counting/# field if added multiple times $user->addCondition('Tickets/user/country_id/Users/#', '>', 1); + $user->addCondition('Tickets/user/country_id/Users/#', '>', 1); + $user->addCondition('Tickets/user/country_id/Users/#', '>=', 2); + $user->addCondition('Tickets/user/country_id/Users/country_id/Users/#', '>', 1); $this->assertEquals(2, $user->action('count')->getOne()); From 3662dc812b9702facd90b4d78ee298912f60b09c Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 16:02:36 +0200 Subject: [PATCH 35/57] [fix] remove ? and ! special symbols in toWords --- src/Model/Scope/BasicCondition.php | 6 ------ tests/ScopeTest.php | 6 ------ 2 files changed, 12 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 78bcda81a..eff68eee2 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -240,12 +240,6 @@ protected function keyToWords(bool $asHtml = false): string if ($field === '#') { $words[] = $this->operator ? 'number of records' : 'any referenced record exists'; $field = ''; - } elseif ($field === '?') { - $words[] = 'any referenced record exists'; - $field = ''; - } elseif ($field === '!') { - $words[] = 'no referenced records exist'; - $field = ''; } } diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index 7d14f4393..33aa598ba 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -175,12 +175,6 @@ public function testContitionToWords() $country->addCondition('Users/#'); $this->assertEquals('Country that has reference Users where any referenced record exists', $country->scope()->toWords()); - - $country = clone $this->country; - - $country->addCondition('Users/!'); - - $this->assertEquals('Country that has reference Users where no referenced records exist', $country->scope()->toWords()); } public function testContitionOnReferencedRecords() From b8b579df8d1f1fd9d93cbc67be442b94b022a400 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 16:29:20 +0200 Subject: [PATCH 36/57] [update] optional model as argument on toWords --- src/Model/Scope/AbstractCondition.php | 4 ++-- src/Model/Scope/BasicCondition.php | 30 +++++++++++-------------- src/Model/Scope/CompoundCondition.php | 5 +++-- tests/ScopeTest.php | 32 +++++++++++++-------------- 4 files changed, 34 insertions(+), 37 deletions(-) diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php index 558c2e77c..55eafc305 100644 --- a/src/Model/Scope/AbstractCondition.php +++ b/src/Model/Scope/AbstractCondition.php @@ -41,7 +41,7 @@ abstract public function onChangeModel(); * * @return static */ - final public function on(Model $model) + final protected function on(Model $model) { $clone = clone $this; @@ -85,7 +85,7 @@ abstract public function isEmpty(): bool; * * @return bool */ - abstract public function toWords(bool $asHtml = false): string; + abstract public function toWords(Model $model = null): string; /** * Simplifies by peeling off nested group conditions with single contained component. diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index eff68eee2..845892fb1 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -7,6 +7,7 @@ use atk4\core\ReadableCaptionTrait; use atk4\data\Exception; use atk4\data\Field; +use atk4\data\Model; use atk4\dsql\Expression; use atk4\dsql\Expressionable; @@ -195,27 +196,24 @@ public function negate() return $this; } - public function toWords(bool $asHtml = false): string + public function toWords(Model $model = null): string { - if (!$this->getModel()) { + if (!$model = $model ?: $this->getModel()) { throw new Exception('Condition must be associated with Model to convert to words'); } - // make sure clones are used to avoid changes - $condition = clone $this; + $condition = $this->on($model); - $key = $condition->keyToWords($asHtml); + $key = $condition->keyToWords(); - $operator = $condition->operatorToWords($asHtml); + $operator = $condition->operatorToWords(); - $value = $condition->valueToWords($condition->value, $asHtml); + $value = $condition->valueToWords($condition->value); - $ret = trim("{$key} {$operator} {$value}"); - - return $asHtml ? $ret : html_entity_decode($ret); + return trim("{$key} {$operator} {$value}"); } - protected function keyToWords(bool $asHtml = false): string + protected function keyToWords(): string { $model = $this->getModel(); @@ -252,17 +250,15 @@ protected function keyToWords(bool $asHtml = false): string $words[] = "expression '{$field->getDebugQuery()}'"; } - $string = implode(' ', array_filter($words)); - - return $asHtml ? "{$string}" : $string; + return implode(' ', array_filter($words)); } - protected function operatorToWords(bool $asHtml = false): string + protected function operatorToWords(): string { return $this->operator ? (self::$dictionary[strtoupper((string) $this->operator)] ?? $this->operator) : ''; } - protected function valueToWords($value, bool $asHtml = false): string + protected function valueToWords($value): string { $model = $this->getModel(); @@ -273,7 +269,7 @@ protected function valueToWords($value, bool $asHtml = false): string if (is_array($values = $value)) { $ret = []; foreach ($values as $value) { - $ret[] = $this->valueToWords($value, $asHtml); + $ret[] = $this->valueToWords($value); } return implode(' or ', $ret); diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 03604e966..e7a70fb49 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -6,6 +6,7 @@ use atk4\core\ContainerTrait; use atk4\data\Exception; +use atk4\data\Model; /** * @property AbstractCondition[] $elements @@ -157,11 +158,11 @@ public function negate() return $this; } - public function toWords(bool $asHtml = false): string + public function toWords(Model $model = null): string { $parts = []; foreach ($this->elements as $nestedCondition) { - $words = $nestedCondition->toWords($asHtml); + $words = $nestedCondition->toWords($model); $parts[] = $this->isCompound() && $nestedCondition->isCompound() ? '(' . $words . ')' : $words; } diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index 33aa598ba..b0f50a55e 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -138,37 +138,37 @@ public function testContitionToWords() $condition = new BasicCondition(new Expression('false')); - $this->assertEquals('expression \'false\'', $condition->on($user)->toWords()); + $this->assertEquals('expression \'false\'', $condition->toWords($user)); $condition = new BasicCondition('country_id/code', 'US'); - $this->assertEquals('User that has reference Country Id where Code is equal to \'US\'', $condition->on($user)->toWords()); + $this->assertEquals('User that has reference Country Id where Code is equal to \'US\'', $condition->toWords($user)); $condition = new BasicCondition('country_id', 2); - $this->assertEquals('Country Id is equal to \'Latvia\'', $condition->on($user)->toWords()); + $this->assertEquals('Country Id is equal to \'Latvia\'', $condition->toWords($user)); if ($this->driverType == 'sqlite') { $condition = new BasicCondition('name', $user->expr('[surname]')); - $this->assertEquals('Name is equal to expression \'"surname"\'', $condition->on($user)->toWords()); + $this->assertEquals('Name is equal to expression \'"surname"\'', $condition->toWords($user)); } $condition = new BasicCondition('country_id', null); - $this->assertEquals('Country Id is equal to empty', $condition->on($user)->toWords()); + $this->assertEquals('Country Id is equal to empty', $condition->toWords($user)); $condition = new BasicCondition('name', '>', 'Test'); - $this->assertEquals('Name is greater than \'Test\'', $condition->on($user)->toWords()); + $this->assertEquals('Name is greater than \'Test\'', $condition->toWords($user)); $condition = (new BasicCondition('country_id', 2))->negate(); - $this->assertEquals('Country Id is not equal to \'Latvia\'', $condition->on($user)->toWords()); + $this->assertEquals('Country Id is not equal to \'Latvia\'', $condition->toWords($user)); $condition = new BasicCondition($user->getField('surname'), $user->getField('name')); - $this->assertEquals('Surname is equal to User Name', $condition->on($user)->toWords()); + $this->assertEquals('Surname is equal to User Name', $condition->toWords($user)); $country = clone $this->country; @@ -284,7 +284,7 @@ public function testScope() $this->assertEquals(CompoundCondition::OR, $compoundCondition->getJunction()); - $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\')', $compoundCondition->on($user)->toWords()); + $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\')', $compoundCondition->toWords($user)); $user->scope()->add($compoundCondition); @@ -292,13 +292,13 @@ public function testScope() $this->assertEquals(2, count($user->export())); - $this->assertEquals($compoundCondition->on($user)->toWords(), $user->scope()->toWords()); + $this->assertEquals($compoundCondition->toWords($user), $user->scope()->toWords()); $condition5 = new BasicCondition('country_code', 'BR'); $compoundCondition = CompoundCondition::mergeOr($compoundCondition1, $compoundCondition2, $condition5); - $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\') or Code is equal to \'BR\'', $compoundCondition->on($user)->toWords()); + $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\') or Code is equal to \'BR\'', $compoundCondition->toWords($user)); $user = clone $this->user; @@ -319,7 +319,7 @@ public function testScopeToWords() $compoundCondition = CompoundCondition::mergeAnd($compoundCondition1, $condition3); - $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'CA\') and Surname is not equal to \'Prost\'', $compoundCondition->on($user)->toWords()); + $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'CA\') and Surname is not equal to \'Prost\'', $compoundCondition->toWords($user)); } public function testNegate() @@ -349,7 +349,7 @@ public function testAnd() $compoundCondition = CompoundCondition::mergeOr($compoundCondition, new BasicCondition('name', 'John')); - $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'FR\') or Name is equal to \'John\'', $compoundCondition->on($user)->toWords()); + $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'FR\') or Name is equal to \'John\'', $compoundCondition->toWords($user)); } public function testOr() @@ -363,7 +363,7 @@ public function testOr() $compoundCondition = CompoundCondition::mergeAnd($compoundCondition, new BasicCondition('name', 'John')); - $this->assertEquals('(Name is equal to \'Alain\' or Code is equal to \'FR\') and Name is equal to \'John\'', $compoundCondition->on($user)->toWords()); + $this->assertEquals('(Name is equal to \'Alain\' or Code is equal to \'FR\') and Name is equal to \'John\'', $compoundCondition->toWords($user)); } public function testMerge() @@ -375,7 +375,7 @@ public function testMerge() $compoundCondition = CompoundCondition::mergeAnd($condition1, $condition2); - $this->assertEquals('Name is equal to \'Alain\' and Code is equal to \'FR\'', $compoundCondition->on($user)->toWords()); + $this->assertEquals('Name is equal to \'Alain\' and Code is equal to \'FR\'', $compoundCondition->toWords($user)); } public function testDestroyEmpty() @@ -391,6 +391,6 @@ public function testDestroyEmpty() $this->assertTrue($compoundCondition->isEmpty()); - $this->assertEmpty($compoundCondition->on($user)->toWords()); + $this->assertEmpty($compoundCondition->toWords($user)); } } From 95d93dfe2f85d31bcce06c286f3bd3120ae0fcbc Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 16:54:29 +0200 Subject: [PATCH 37/57] [fix] operatorToWords --- src/Model/Scope/BasicCondition.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 845892fb1..6a75a9743 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -255,7 +255,17 @@ protected function keyToWords(): string protected function operatorToWords(): string { - return $this->operator ? (self::$dictionary[strtoupper((string) $this->operator)] ?? $this->operator) : ''; + if (!$this->operator) { + return ''; + } + + $operator = strtoupper((string) $this->operator); + + if (!isset(self::$dictionary[$operator])) { + throw new Exception($operator . ' is not supported'); + } + + return self::$dictionary[$operator]; } protected function valueToWords($value): string From 5643d6c5e718fe7edd09cfb8c456da31c5824563 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 17:09:16 +0200 Subject: [PATCH 38/57] [update] rename toQueryArgumentsArray to toQueryArguments --- src/Action/Iterator.php | 2 +- src/Model/Scope/BasicCondition.php | 4 ++-- src/Persistence/Sql.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Action/Iterator.php b/src/Action/Iterator.php index 6790e32e7..054913928 100644 --- a/src/Action/Iterator.php +++ b/src/Action/Iterator.php @@ -102,7 +102,7 @@ protected function match(array $row, Model\Scope\AbstractCondition $condition) // simple condition if ($condition instanceof Model\Scope\BasicCondition) { - $args = $condition->toQueryArgumentsArray(); + $args = $condition->toQueryArguments(); $field = $args[0]; $operator = $args[1] ?? null; diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 6a75a9743..6d1b7c848 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -99,7 +99,7 @@ public function onChangeModel(): void // new records will automatically get this value assigned for the field // @todo: consider this when condition is part of OR scope if ($this->operator === '=' && !is_object($this->value) && !is_array($this->value)) { - // key containing '/' means chained references and it is handled in toQueryArgumentsArray method + // key containing '/' means chained references and it is handled in toQueryArguments method if (is_string($field = $this->key) && !str_contains($field, '/')) { $field = $model->getField($field); } @@ -112,7 +112,7 @@ public function onChangeModel(): void } } - public function toQueryArgumentsArray(): array + public function toQueryArguments(): array { // make sure clones are used to avoid changes $condition = clone $this; diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index bc19efcaf..949e5095c 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -376,7 +376,7 @@ public function initQueryConditions(Model $model, Query $query, Model\Scope\Abst // simple condition if ($condition instanceof Model\Scope\BasicCondition) { - $query = $query->where(...$condition->toQueryArgumentsArray()); + $query = $query->where(...$condition->toQueryArguments()); } // nested conditions From f8e3e4fdccef7cf2f3c3b5648eb4697fa664db17 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 17:12:34 +0200 Subject: [PATCH 39/57] [fix] CS Fixer --- src/Model/Scope/BasicCondition.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 6d1b7c848..5147918c0 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -258,13 +258,13 @@ protected function operatorToWords(): string if (!$this->operator) { return ''; } - + $operator = strtoupper((string) $this->operator); - + if (!isset(self::$dictionary[$operator])) { throw new Exception($operator . ' is not supported'); } - + return self::$dictionary[$operator]; } From 9b3d02c8fab15a91cc45f918f245f51640a7cfec Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 17:57:06 +0200 Subject: [PATCH 40/57] [fix] typo --- tests/ScopeTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index b0f50a55e..5e52b11e0 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -177,7 +177,7 @@ public function testContitionToWords() $this->assertEquals('Country that has reference Users where any referenced record exists', $country->scope()->toWords()); } - public function testContitionOnReferencedRecords() + public function testConditionOnReferencedRecords() { $user = clone $this->user; From 5f3b779136ea508c369c2084c63ff2195469d553 Mon Sep 17 00:00:00 2001 From: Imants Horsts Date: Thu, 23 Jul 2020 19:01:47 +0300 Subject: [PATCH 41/57] typo --- docs/conditions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conditions.rst b/docs/conditions.rst index 5f772cf1a..f1b290bee 100644 --- a/docs/conditions.rst +++ b/docs/conditions.rst @@ -264,7 +264,7 @@ These classes can be used directly and independently from Model class. .. php:method:: scope() -This method provides access to the model scope enablind conditions to be added:: +This method provides access to the model scope enabling conditions to be added:: $contact->scope()->add($condition); // adding condition to a model From caf4c39342084135eb1d31b7d3b81ae72d7a697a Mon Sep 17 00:00:00 2001 From: DarkSide Date: Thu, 23 Jul 2020 20:02:09 +0300 Subject: [PATCH 42/57] typo --- docs/conditions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conditions.rst b/docs/conditions.rst index f1b290bee..2ef173197 100644 --- a/docs/conditions.rst +++ b/docs/conditions.rst @@ -318,7 +318,7 @@ CompoundCondition can be created using new CompoundCondition() statement from an // $condition1 will be used as child-component $condition1 = new BasicCondition('name', 'like', 'ABC%'); - // $condition1 will be used as child-component + // $condition2 will be used as child-component $condition2 = new BasicCondition('country', 'US'); // $compoundCondition1 is created using AND as junction and $condition1 and $condition2 as nested conditions From 719d59ef2cb3726267cb705e5f0b1b5e5fbd8039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 19:38:56 +0200 Subject: [PATCH 43/57] add complex but always true condition --- tests/ScopeTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index 5e52b11e0..4e10ef8bc 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -251,7 +251,7 @@ public function testConditionOnReferencedRecords() $user = clone $this->user; - // users with tickets that have more than two users per country) + // users with tickets that have more than two users per country // test if a model can be referenced multiple times // and if generated query has no duplicate column names // because of counting/# field if added multiple times @@ -259,9 +259,13 @@ public function testConditionOnReferencedRecords() $user->addCondition('Tickets/user/country_id/Users/#', '>', 1); $user->addCondition('Tickets/user/country_id/Users/#', '>=', 2); $user->addCondition('Tickets/user/country_id/Users/country_id/Users/#', '>', 1); + if ($this->driverType !== 'sqlite') { + // not supported because of limitation/issue in Sqlite, the generated query fails + // with error: "parser stack overflow" + $user->addCondition('Tickets/user/country_id/Users/country_id/Users/name', '!=', null); // should be always true + } $this->assertEquals(2, $user->action('count')->getOne()); - foreach ($user as $u) { $this->assertTrue(in_array($u->get('name'), ['Aerton', 'Rubens'], true)); } From accfa6c6c9aa605c2c8d78455d45bcb030c5f02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 20:23:57 +0200 Subject: [PATCH 44/57] make onChangeModel protected --- src/Model/Scope/AbstractCondition.php | 2 +- src/Model/Scope/BasicCondition.php | 2 +- src/Model/Scope/CompoundCondition.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php index 55eafc305..7ef64fab3 100644 --- a/src/Model/Scope/AbstractCondition.php +++ b/src/Model/Scope/AbstractCondition.php @@ -34,7 +34,7 @@ public function init(): void $this->onChangeModel(); } - abstract public function onChangeModel(); + abstract protected function onChangeModel(); /** * Temporarily assign a model to the condition. diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 5147918c0..c4eb5a1e5 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -91,7 +91,7 @@ public function __construct($key, $operator = null, $value = null) $this->value = $value; } - public function onChangeModel(): void + protected function onChangeModel(): void { if ($model = $this->getModel()) { // if we have a definitive scalar value for a field diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index e7a70fb49..62938164f 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -77,7 +77,7 @@ public function getNestedConditions() return $this->elements; } - public function onChangeModel(): void + protected function onChangeModel(): void { foreach ($this->elements as $nestedCondition) { $nestedCondition->onChangeModel(); From b28e39a2b7e447c4268f43f9c22779adf8439224 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 20:22:57 +0200 Subject: [PATCH 45/57] [update] separate exception info --- src/Model/Scope/BasicCondition.php | 6 ++++-- src/Model/Scope/CompoundCondition.php | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index c4eb5a1e5..d65f86347 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -190,7 +190,8 @@ public function negate() if ($this->operator && isset(self::$opposites[$this->operator])) { $this->operator = self::$opposites[$this->operator]; } else { - throw new Exception('Negation of condition is not supported for ' . ($this->operator ?: 'no') . ' operator'); + throw (new Exception('Negation of condition is not supported for this operator')) + ->addMoreInfo('operator', $this->operator ?: 'no operator'); } return $this; @@ -262,7 +263,8 @@ protected function operatorToWords(): string $operator = strtoupper((string) $this->operator); if (!isset(self::$dictionary[$operator])) { - throw new Exception($operator . ' is not supported'); + throw (new Exception('Operator is not supported')) + ->addMoreInfo('operator', $operator); } return self::$dictionary[$operator]; diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 62938164f..ec084f033 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -34,7 +34,8 @@ class CompoundCondition extends AbstractCondition public function __construct(array $nestedConditions = [], string $junction = self::AND) { if (!in_array($junction, [self::OR, self::AND], true)) { - throw new Exception($junction . ' is not a valid CompondCondition junction'); + throw (new Exception('Using invalid CompondCondition junction')) + ->addMoreInfo('junction', $junction); } $this->junction = $junction; From 3aa78206aab4d38ee65bcddbda190cdd1836be3e Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 20:50:30 +0200 Subject: [PATCH 46/57] [fix] condition with NULL value --- src/Action/Iterator.php | 6 ++---- tests/PersistentArrayTest.php | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Action/Iterator.php b/src/Action/Iterator.php index 054913928..e578c408b 100644 --- a/src/Action/Iterator.php +++ b/src/Action/Iterator.php @@ -119,9 +119,7 @@ protected function match(array $row, Model\Scope\AbstractCondition $condition) ->addMoreInfo('condition', $condition); } - if (isset($row[$field->short_name])) { - $match = $this->evaluateIf($row[$field->short_name], $operator, $value); - } + $match = $this->evaluateIf($row[$field->short_name] ?? null, $operator, $value); } // nested conditions @@ -148,7 +146,7 @@ protected function evaluateIf($v1, $operator, $v2): bool { switch (strtoupper((string) $operator)) { case '=': - $result = is_array($v2) ? $this->evaluateIf($v1, 'IN', $v2) : $v1 == $v2; + $result = is_array($v2) ? $this->evaluateIf($v1, 'IN', $v2) : $v1 === $v2; break; case '>': diff --git a/tests/PersistentArrayTest.php b/tests/PersistentArrayTest.php index c372d241d..7fa264e9d 100644 --- a/tests/PersistentArrayTest.php +++ b/tests/PersistentArrayTest.php @@ -352,6 +352,7 @@ public function testLike() 7 => ['id' => 7, 'name' => 'ABC3', 'code' => 17, 'country' => 'Latvia', 'active' => 0], 8 => ['id' => 8, 'name' => 'ABC2', 'code' => 18, 'country' => 'Russia', 'active' => 1], 9 => ['id' => 9, 'code' => 19, 'country' => 'Latvia', 'active' => 1], + 10 => ['id' => 10, 'code' => null, 'country' => 'Germany', 'active' => 1], ]]; $p = new Persistence\Array_($a); @@ -382,7 +383,7 @@ public function testLike() $m->scope()->clear(); $m->addCondition('country', 'NOT LIKE', 'La%'); $result = $m->action('select')->get(); - $this->assertSame(6, count($m->export())); + $this->assertSame(7, count($m->export())); $this->assertSame($a['countries'][1], $result[1]); $this->assertSame($a['countries'][2], $result[2]); $this->assertSame($a['countries'][4], $result[4]); @@ -407,7 +408,7 @@ public function testLike() $m->scope()->clear(); $m->addCondition('country', 'LIKE', '%a%'); $result = $m->action('select')->get(); - $this->assertSame(7, count($result)); + $this->assertSame(8, count($result)); $this->assertSame($a['countries'][1], $result[1]); $this->assertSame($a['countries'][2], $result[2]); $this->assertSame($a['countries'][3], $result[3]); @@ -425,7 +426,7 @@ public function testLike() $m->scope()->clear(); $m->addCondition('active', 'LIKE', '1'); - $this->assertSame(5, count($m->export())); + $this->assertSame(6, count($m->export())); $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%0%'); @@ -433,7 +434,7 @@ public function testLike() $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%1%'); - $this->assertSame(5, count($m->export())); + $this->assertSame(6, count($m->export())); $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%999%'); @@ -442,6 +443,15 @@ public function testLike() $m->scope()->clear(); $m->addCondition('active', 'LIKE', '%ABC%'); $this->assertSame(0, count($m->export())); + + // null value + $m->scope()->clear(); + $m->addCondition('code', '=', null); + $this->assertSame(1, count($m->export())); + + $m->scope()->clear(); + $m->addCondition('code', '!=', null); + $this->assertSame(9, count($m->export())); } /** From 47ca2009b1a847c8d2e88ee9a2529ad45298025c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 21:22:19 +0200 Subject: [PATCH 47/57] no need to clone in BasicCondition::toQueryArguments() --- src/Model/Scope/BasicCondition.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index d65f86347..301b6df5e 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -114,18 +114,15 @@ protected function onChangeModel(): void public function toQueryArguments(): array { - // make sure clones are used to avoid changes - $condition = clone $this; - - $field = $condition->key; - $operator = $condition->operator; - $value = $condition->value; - - if ($condition->isEmpty()) { + if ($this->isEmpty()) { return []; } - if ($model = $condition->getModel()) { + $field = $this->key; + $operator = $this->operator; + $value = $this->value; + + if ($model = $this->getModel()) { if (is_string($field)) { // shorthand for adding conditions on references // use chained reference names separated by "/" @@ -134,8 +131,10 @@ public function toQueryArguments(): array $field = array_pop($references); $refModels = []; + $refModel = $model; foreach ($references as $link) { - $refModels[] = $model = $model->refLink($link); + $refModel = $refModel->refLink($link); + $refModels[] = $refModel; } foreach (array_reverse($refModels) as $refModel) { @@ -144,7 +143,8 @@ public function toQueryArguments(): array } else { $refModel->addCondition($field, $operator, $value); $field = $refModel->action('exists'); - $operator = $value = null; + $operator = null; + $value = null; } } } else { From 08c519187b832f393a10089cbbce05b5f44cab57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 21:26:21 +0200 Subject: [PATCH 48/57] fix toWords() doc --- docs/conditions.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/conditions.rst b/docs/conditions.rst index 2ef173197..3cc040ed1 100644 --- a/docs/conditions.rst +++ b/docs/conditions.rst @@ -297,15 +297,14 @@ Negates the condition, e.g:: Sets the model of BasicCondition to a clone of $model to avoid changes to the original object.:: // uses the $contact model to conver the condition to human readable words - $condition->on($contact)->toWords(); + $condition->toWords($contact); .. php:method:: toWords($asHtml = false); -Converts the condition object to human readable words. Model must be set first. Recommended is use of Condition::on method to set the model -as it clones the model object first:: +Converts the condition object to human readable words. Model must be set first:: // results in 'Contact where Name is John' - (new BasicCondition('name', 'John'))->on($contactModel)->toWords(); + (new BasicCondition('name', 'John'))->toWords($contactModel); .. php:class:: CompoundCondition From f22c8a7bfabbd4849a6a3171dd6c7bd4c84b4549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 21:37:01 +0200 Subject: [PATCH 49/57] remove on() (expensive model clone) --- src/Model/Scope/AbstractCondition.php | 16 ---------------- src/Model/Scope/BasicCondition.php | 17 +++++++++++------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php index 7ef64fab3..e1efa2858 100644 --- a/src/Model/Scope/AbstractCondition.php +++ b/src/Model/Scope/AbstractCondition.php @@ -36,22 +36,6 @@ public function init(): void abstract protected function onChangeModel(); - /** - * Temporarily assign a model to the condition. - * - * @return static - */ - final protected function on(Model $model) - { - $clone = clone $this; - - $clone->owner = null; - - (clone $model)->scope()->add($clone); - - return $clone; - } - /** * Get the model this condition is associated with. */ diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 301b6df5e..6d7320441 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -199,16 +199,21 @@ public function negate() public function toWords(Model $model = null): string { - if (!$model = $model ?: $this->getModel()) { - throw new Exception('Condition must be associated with Model to convert to words'); - } + if ($model === null) { + if ($this->getModel() === null) { + throw new Exception('Condition must be associated with Model to convert to words'); + } - $condition = $this->on($model); + $condition = $this; + } else { + // temporarily assign a model to the condition. + $condition = clone $this; + $condition->owner = null; + (clone $model)->scope()->add($condition); + } $key = $condition->keyToWords(); - $operator = $condition->operatorToWords(); - $value = $condition->valueToWords($condition->value); return trim("{$key} {$operator} {$value}"); From 4464c57f5ce51e8c9e74eafb3fe5d21e5f76cf69 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 22:00:52 +0200 Subject: [PATCH 50/57] [update] simplify toWords further --- src/Model/Scope/BasicCondition.php | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/src/Model/Scope/BasicCondition.php b/src/Model/Scope/BasicCondition.php index 6d7320441..4b1c445dc 100644 --- a/src/Model/Scope/BasicCondition.php +++ b/src/Model/Scope/BasicCondition.php @@ -199,30 +199,21 @@ public function negate() public function toWords(Model $model = null): string { - if ($model === null) { - if ($this->getModel() === null) { - throw new Exception('Condition must be associated with Model to convert to words'); - } + $model = $model ?: $this->getModel(); - $condition = $this; - } else { - // temporarily assign a model to the condition. - $condition = clone $this; - $condition->owner = null; - (clone $model)->scope()->add($condition); + if ($model === null) { + throw new Exception('Condition must be associated with Model to convert to words'); } - $key = $condition->keyToWords(); - $operator = $condition->operatorToWords(); - $value = $condition->valueToWords($condition->value); + $key = $this->keyToWords($model); + $operator = $this->operatorToWords(); + $value = $this->valueToWords($model, $this->value); return trim("{$key} {$operator} {$value}"); } - protected function keyToWords(): string + protected function keyToWords(Model $model): string { - $model = $this->getModel(); - $words = []; if (is_string($field = $this->key)) { @@ -275,10 +266,8 @@ protected function operatorToWords(): string return self::$dictionary[$operator]; } - protected function valueToWords($value): string + protected function valueToWords(Model $model, $value): string { - $model = $this->getModel(); - if ($value === null) { return $this->operator ? 'empty' : ''; } @@ -286,7 +275,7 @@ protected function valueToWords($value): string if (is_array($values = $value)) { $ret = []; foreach ($values as $value) { - $ret[] = $this->valueToWords($value); + $ret[] = $this->valueToWords($model, $value); } return implode(' or ', $ret); From 408e104b6e57c3305021d55210116be714c52779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 22:08:05 +0200 Subject: [PATCH 51/57] rename mergeXx to createXx --- docs/conditions.rst | 12 ++++++------ src/Model/Scope/CompoundCondition.php | 8 ++------ tests/ScopeTest.php | 26 +++++++++++++------------- 3 files changed, 21 insertions(+), 25 deletions(-) diff --git a/docs/conditions.rst b/docs/conditions.rst index 3cc040ed1..50d26b727 100644 --- a/docs/conditions.rst +++ b/docs/conditions.rst @@ -321,21 +321,21 @@ CompoundCondition can be created using new CompoundCondition() statement from an $condition2 = new BasicCondition('country', 'US'); // $compoundCondition1 is created using AND as junction and $condition1 and $condition2 as nested conditions - $compoundCondition1 = CompoundCondition::mergeAnd($condition1, $condition2); + $compoundCondition1 = CompoundCondition::createAnd($condition1, $condition2); $condition3 = new BasicCondition('country', 'DE'); $condition4 = new BasicCondition('surname', 'XYZ'); // $compoundCondition2 is created using OR as junction and $condition3 and $condition4 as nested conditions - $compoundCondition2 = CompoundCondition::mergeOr($condition3, $condition4); + $compoundCondition2 = CompoundCondition::createOr($condition3, $condition4); $condition5 = new BasicCondition('name', 'like', 'CDE%'); // $compoundCondition3 is created using AND as junction and $condition5 and $compoundCondition2 as nested conditions - $compoundCondition3 = CompoundCondition::mergeAnd($condition5, $compoundCondition2); + $compoundCondition3 = CompoundCondition::createAnd($condition5, $compoundCondition2); // $compoundCondition is created using OR as junction and $compoundCondition1 and $compoundCondition3 as nested conditions - $compoundCondition = CompoundCondition::mergeOr($compoundCondition1, $compoundCondition3); + $compoundCondition = CompoundCondition::createOr($compoundCondition1, $compoundCondition3); CompoundCondition is an independent object not related to any model. Applying scope to model is using the Model::scope()->add($condition) method:: @@ -362,11 +362,11 @@ For compound conditionss this method is using De Morgan's laws, e.g:: // results in "(Name not like 'ABC%') or (Country does not equal 'US')" $compoundCondition1->negate(); -.. php:method:: mergeAnd(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null); +.. php:method:: createAnd(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null); Merge number of conditions using AND as junction. Returns the resulting CompoundCondition object. -.. php:method:: mergeOr(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null); +.. php:method:: createOr(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null); Merge number of conditions using OR as junction. Returns the resulting CompoundCondition object. diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index ec084f033..4dc1cc836 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -174,21 +174,17 @@ public function toWords(Model $model = null): string } /** - * Merge number of conditions using AND as junction. - * * @return static */ - public static function mergeAnd(...$conditions) + public static function createAnd(...$conditions) { return new self($conditions, self::AND); } /** - * Merge number of conditions using OR as junction. - * * @return static */ - public static function mergeOr(...$conditions) + public static function createOr(...$conditions) { return new self($conditions, self::OR); } diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index 4e10ef8bc..b812bd67d 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -281,10 +281,10 @@ public function testScope() $condition3 = new BasicCondition('surname', 'Doe'); $condition4 = new BasicCondition('country_code', 'LV'); - $compoundCondition1 = CompoundCondition::mergeAnd($condition1, $condition2); - $compoundCondition2 = CompoundCondition::mergeAnd($condition3, $condition4); + $compoundCondition1 = CompoundCondition::createAnd($condition1, $condition2); + $compoundCondition2 = CompoundCondition::createAnd($condition3, $condition4); - $compoundCondition = CompoundCondition::mergeOr($compoundCondition1, $compoundCondition2); + $compoundCondition = CompoundCondition::createOr($compoundCondition1, $compoundCondition2); $this->assertEquals(CompoundCondition::OR, $compoundCondition->getJunction()); @@ -300,7 +300,7 @@ public function testScope() $condition5 = new BasicCondition('country_code', 'BR'); - $compoundCondition = CompoundCondition::mergeOr($compoundCondition1, $compoundCondition2, $condition5); + $compoundCondition = CompoundCondition::createOr($compoundCondition1, $compoundCondition2, $condition5); $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\') or Code is equal to \'BR\'', $compoundCondition->toWords($user)); @@ -318,10 +318,10 @@ public function testScopeToWords() $condition1 = new BasicCondition('name', 'Alain'); $condition2 = new BasicCondition('country_code', 'CA'); - $compoundCondition1 = CompoundCondition::mergeAnd($condition1, $condition2); + $compoundCondition1 = CompoundCondition::createAnd($condition1, $condition2); $condition3 = (new BasicCondition('surname', 'Prost'))->negate(); - $compoundCondition = CompoundCondition::mergeAnd($compoundCondition1, $condition3); + $compoundCondition = CompoundCondition::createAnd($compoundCondition1, $condition3); $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'CA\') and Surname is not equal to \'Prost\'', $compoundCondition->toWords($user)); } @@ -333,7 +333,7 @@ public function testNegate() $condition1 = new BasicCondition('name', '!=', 'Alain'); $condition2 = new BasicCondition('country_code', '!=', 'FR'); - $condition = CompoundCondition::mergeOr($condition1, $condition2)->negate(); + $condition = CompoundCondition::createOr($condition1, $condition2)->negate(); $user->scope()->add($condition); @@ -349,9 +349,9 @@ public function testAnd() $condition1 = new BasicCondition('name', 'Alain'); $condition2 = new BasicCondition('country_code', 'FR'); - $compoundCondition = CompoundCondition::mergeAnd($condition1, $condition2); + $compoundCondition = CompoundCondition::createAnd($condition1, $condition2); - $compoundCondition = CompoundCondition::mergeOr($compoundCondition, new BasicCondition('name', 'John')); + $compoundCondition = CompoundCondition::createOr($compoundCondition, new BasicCondition('name', 'John')); $this->assertEquals('(Name is equal to \'Alain\' and Code is equal to \'FR\') or Name is equal to \'John\'', $compoundCondition->toWords($user)); } @@ -363,9 +363,9 @@ public function testOr() $condition1 = new BasicCondition('name', 'Alain'); $condition2 = new BasicCondition('country_code', 'FR'); - $compoundCondition = CompoundCondition::mergeOr($condition1, $condition2); + $compoundCondition = CompoundCondition::createOr($condition1, $condition2); - $compoundCondition = CompoundCondition::mergeAnd($compoundCondition, new BasicCondition('name', 'John')); + $compoundCondition = CompoundCondition::createAnd($compoundCondition, new BasicCondition('name', 'John')); $this->assertEquals('(Name is equal to \'Alain\' or Code is equal to \'FR\') and Name is equal to \'John\'', $compoundCondition->toWords($user)); } @@ -377,7 +377,7 @@ public function testMerge() $condition1 = new BasicCondition('name', 'Alain'); $condition2 = new BasicCondition('country_code', 'FR'); - $compoundCondition = CompoundCondition::mergeAnd($condition1, $condition2); + $compoundCondition = CompoundCondition::createAnd($condition1, $condition2); $this->assertEquals('Name is equal to \'Alain\' and Code is equal to \'FR\'', $compoundCondition->toWords($user)); } @@ -389,7 +389,7 @@ public function testDestroyEmpty() $condition1 = new BasicCondition('name', 'Alain'); $condition2 = new BasicCondition('country_code', 'FR'); - $compoundCondition = CompoundCondition::mergeAnd($condition1, $condition2); + $compoundCondition = CompoundCondition::createAnd($condition1, $condition2); $compoundCondition->clear(); From 1ed512984a39c3a27857fb3504ce122b49145f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 22:11:18 +0200 Subject: [PATCH 52/57] new self to new static --- src/Model/Scope/CompoundCondition.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 4dc1cc836..8523b6250 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -46,7 +46,7 @@ public function __construct(array $nestedConditions = [], string $junction = sel if (is_array($nestedCondition)) { // array of OR nested conditions if (count($nestedCondition) === 1 && isset($nestedCondition[0]) && is_array($nestedCondition[0])) { - $nestedCondition = new self($nestedCondition[0], self::OR); + $nestedCondition = new static($nestedCondition[0], self::OR); } else { $nestedCondition = new BasicCondition(...$nestedCondition); } @@ -178,7 +178,7 @@ public function toWords(Model $model = null): string */ public static function createAnd(...$conditions) { - return new self($conditions, self::AND); + return new static($conditions, self::AND); } /** @@ -186,6 +186,6 @@ public static function createAnd(...$conditions) */ public static function createOr(...$conditions) { - return new self($conditions, self::OR); + return new static($conditions, self::OR); } } From 848766355c47a2c5c5e1e4d0d1e6511b74e28772 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 22:18:00 +0200 Subject: [PATCH 53/57] [fix] test toWords --- tests/ScopeTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index b812bd67d..b04cecf9f 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -151,7 +151,7 @@ public function testContitionToWords() if ($this->driverType == 'sqlite') { $condition = new BasicCondition('name', $user->expr('[surname]')); - $this->assertEquals('Name is equal to expression \'"surname"\'', $condition->toWords($user)); + $this->assertEquals('Name is equal to expression \'"user"."surname"\'', $condition->toWords($user)); } $condition = new BasicCondition('country_id', null); From 8197334d76d64e6ee856ff82726e54bd9903b44a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 22:33:28 +0200 Subject: [PATCH 54/57] fix Scope::createAnd/Or for real world usage --- src/Model/Scope.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Model/Scope.php b/src/Model/Scope.php index 75d96febf..edabb3dca 100644 --- a/src/Model/Scope.php +++ b/src/Model/Scope.php @@ -44,4 +44,20 @@ public function negate() { throw new Exception('Model Scope cannot be negated!'); } + + /** + * @return static + */ + public static function createAnd(...$conditions) + { + return parent::createAnd(...$conditions); + } + + /** + * @return static + */ + public static function createOr(...$conditions) + { + return parent::createOr(...$conditions); + } } From 1de2aec966b106704d6e727e24e3d8aaed8fc8ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 22:42:51 +0200 Subject: [PATCH 55/57] make Scope constructor protected --- src/Model.php | 4 +++- src/Model/Scope.php | 2 +- src/Model/Scope/CompoundCondition.php | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Model.php b/src/Model.php index 6d536f826..7a43ac6b3 100644 --- a/src/Model.php +++ b/src/Model.php @@ -359,7 +359,9 @@ class Model implements \IteratorAggregate */ public function __construct($persistence = null, $defaults = []) { - $this->scope = new Model\Scope(); + $this->scope = \Closure::bind(function() { + return new Model\Scope(); + }, null, Model\Scope::class)(); if ((is_string($persistence) || is_array($persistence)) && func_num_args() === 1) { $defaults = $persistence; diff --git a/src/Model/Scope.php b/src/Model/Scope.php index edabb3dca..a3f264ca5 100644 --- a/src/Model/Scope.php +++ b/src/Model/Scope.php @@ -19,7 +19,7 @@ class Scope extends Scope\CompoundCondition */ protected $model; - public function __construct(array $nestedConditions = []) + protected function __construct(array $nestedConditions = []) { parent::__construct($nestedConditions, self::AND); } diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 8523b6250..5c550c352 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -46,7 +46,7 @@ public function __construct(array $nestedConditions = [], string $junction = sel if (is_array($nestedCondition)) { // array of OR nested conditions if (count($nestedCondition) === 1 && isset($nestedCondition[0]) && is_array($nestedCondition[0])) { - $nestedCondition = new static($nestedCondition[0], self::OR); + $nestedCondition = new self($nestedCondition[0], self::OR); } else { $nestedCondition = new BasicCondition(...$nestedCondition); } From 0b65f8eabfd8be295adf3f7ca2d0cfad6f82e560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Thu, 23 Jul 2020 22:48:47 +0200 Subject: [PATCH 56/57] fix CS --- src/Model.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Model.php b/src/Model.php index 7a43ac6b3..d35b14303 100644 --- a/src/Model.php +++ b/src/Model.php @@ -359,7 +359,7 @@ class Model implements \IteratorAggregate */ public function __construct($persistence = null, $defaults = []) { - $this->scope = \Closure::bind(function() { + $this->scope = \Closure::bind(function () { return new Model\Scope(); }, null, Model\Scope::class)(); From 954db7431d5840cca11b13bb2b26956c4553bce6 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Thu, 23 Jul 2020 23:26:10 +0200 Subject: [PATCH 57/57] [update] introduce addCondition method to CompoundCondition --- src/Model/Scope/CompoundCondition.php | 16 ++++++++++++++++ tests/ScopeTest.php | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Model/Scope/CompoundCondition.php b/src/Model/Scope/CompoundCondition.php index 5c550c352..bc3204dd4 100644 --- a/src/Model/Scope/CompoundCondition.php +++ b/src/Model/Scope/CompoundCondition.php @@ -68,6 +68,22 @@ public function __clone() } } + /** + * Add nested condition. + * + * @param mixed $field + * @param mixed $operator + * @param mixed $value + * + * @return static + */ + public function addCondition($field, $operator = null, $value = null) + { + $this->add(new self([func_get_args()])); + + return $this; + } + /** * Return array of nested conditions. * diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index b04cecf9f..4098e8392 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -298,9 +298,9 @@ public function testScope() $this->assertEquals($compoundCondition->toWords($user), $user->scope()->toWords()); - $condition5 = new BasicCondition('country_code', 'BR'); + $compoundCondition = CompoundCondition::createOr($compoundCondition1, $compoundCondition2); - $compoundCondition = CompoundCondition::createOr($compoundCondition1, $compoundCondition2, $condition5); + $compoundCondition->addCondition('country_code', 'BR'); $this->assertEquals('(Name is equal to \'John\' and Code is equal to \'CA\') or (Surname is equal to \'Doe\' and Code is equal to \'LV\') or Code is equal to \'BR\'', $compoundCondition->toWords($user));