From 518b0271256b9e2e6941bd7c1bdf7a9dc7871a23 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Fri, 24 Jul 2020 00:55:28 +0300 Subject: [PATCH] Use Scope class for filtering/where impl. for Model (#660) * [feature] introduce basic model scope functionality --- docs/conditions.rst | 166 ++++++++++- src/Action/Iterator.php | 215 ++++++++++---- src/Model.php | 199 +++++-------- src/Model/Scope.php | 63 ++++ src/Model/Scope/AbstractCondition.php | 90 ++++++ src/Model/Scope/BasicCondition.php | 322 +++++++++++++++++++++ src/Model/Scope/CompoundCondition.php | 207 +++++++++++++ src/Persistence/Array_.php | 287 ++++++++---------- src/Persistence/Sql.php | 96 +++---- src/Persistence/Static_.php | 12 +- tests/ConditionSqlTest.php | 4 +- tests/ConditionTest.php | 7 +- tests/PersistentArrayTest.php | 250 +++++++++++++--- tests/PersistentSqlTest.php | 4 + tests/ReferenceSqlTest.php | 2 +- tests/ScopeTest.php | 400 ++++++++++++++++++++++++++ 16 files changed, 1866 insertions(+), 458 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..50d26b727 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,161 @@ 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 enabling 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->toWords($contact); + +.. php:method:: toWords($asHtml = false); + +Converts the condition object to human readable words. Model must be set first:: + + // results in 'Contact where Name is John' + (new BasicCondition('name', 'John'))->toWords($contactModel); + +.. 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%'); + + // $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 + $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::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::createAnd($condition5, $compoundCondition2); + + // $compoundCondition is created using OR as junction and $compoundCondition1 and $compoundCondition3 as nested conditions + $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:: + + $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:: createAnd(AbstractCondition $conditionA, AbstractCondition $conditionB, $_ = null); + +Merge number of conditions using AND as junction. Returns the resulting CompoundCondition object. + +.. php:method:: createOr(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) + + diff --git a/src/Action/Iterator.php b/src/Action/Iterator.php index 0efff92c9..e578c408b 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,173 @@ 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->toQueryArguments(); + + $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; + $match = $this->evaluateIf($row[$field->short_name] ?? null, $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 +220,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 +235,14 @@ public function order($fields) /** * Limit Iterator. * - * @param int $cnt - * @param int $shift + * @param int $limit + * @param int $offset * * @return $this */ - public function limit($cnt, $shift = 0) + public function limit($limit, $offset = 0) { - $data = array_slice($this->get(), $shift, $cnt, true); + $data = array_slice($this->get(), $offset, $limit, true); // put data back in generator $this->generator = new \ArrayIterator($data); @@ -159,6 +262,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..d35b14303 100644 --- a/src/Model.php +++ b/src/Model.php @@ -182,15 +182,8 @@ 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; /** * Array of limit set. @@ -366,6 +359,10 @@ class Model implements \IteratorAggregate */ public function __construct($persistence = null, $defaults = []) { + $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; $persistence = null; @@ -392,6 +389,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'); @@ -784,12 +782,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; } /** @@ -949,6 +945,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 +965,19 @@ 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. + */ + public function scope(): Model\Scope + { + return $this->scope->setModel($this); + } + /** * Shortcut for using addCondition(id_field, $id). * @@ -1349,13 +1324,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 +1354,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 +1387,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,33 +1422,25 @@ 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); + $scopeBak = $this->scope; + $systemBak = $field->system; + $defaultBak = $field->default; try { - $this->loadAny(); - } catch (\Exception $e) { - // restore - array_pop($this->conditions); - $field->system = $system; - $field->default = $default; + // add condition to cloned scope and try to load record + $this->scope = clone $this->scope; + $this->addCondition($field, $value); - throw $e; + return $this->loadAny(); + } finally { + $this->scope = $scopeBak; + $field->system = $systemBak; + $field->default = $defaultBak; } - - // restore - array_pop($this->conditions); - $field->system = $system; - $field->default = $default; - - return $this; } /** @@ -1502,33 +1451,41 @@ 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); + $scopeBak = $this->scope; + $systemBak = $field->system; + $defaultBak = $field->default; try { - $this->tryLoadAny(); - } catch (\Exception $e) { - // restore - array_pop($this->conditions); - $field_name->system = $system; - $field_name->default = $default; + // add condition to cloned scope and try to load record + $this->scope = clone $this->scope; + $this->addCondition($field, $value); - throw $e; + return $this->tryLoadAny(); + } finally { + $this->scope = $scopeBak; + $field->system = $systemBak; + $field->default = $defaultBak; } + } - // restore - array_pop($this->conditions); - $field_name->system = $system; - $field_name->default = $default; + /** + * 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'); + } - return $this; + if ($method && !$this->persistence->hasMethod($method)) { + throw new Exception("Persistence does not support {$method} method"); + } } /** @@ -1744,9 +1701,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 +1921,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 +2206,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..a3f264ca5 --- /dev/null +++ b/src/Model/Scope.php @@ -0,0 +1,63 @@ +model !== $model) { + $this->model = $model; + + $this->onChangeModel(); + } + + return $this; + } + + public function getModel(): ?Model + { + return $this->model; + } + + 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); + } +} diff --git a/src/Model/Scope/AbstractCondition.php b/src/Model/Scope/AbstractCondition.php new file mode 100644 index 000000000..e1efa2858 --- /dev/null +++ b/src/Model/Scope/AbstractCondition.php @@ -0,0 +1,90 @@ +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 protected function onChangeModel(); + + /** + * Get the model this condition is associated with. + */ + public function getModel(): ?Model + { + 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(Model $model = null): string; + + /** + * Simplifies by peeling off nested group conditions with single contained component. + * Useful for converting (((field = value))) to field = value. + */ + public function simplify(): self + { + 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..4b1c445dc --- /dev/null +++ b/src/Model/Scope/BasicCondition.php @@ -0,0 +1,322 @@ + '!=', + '!=' => '=', + '<' => '>=', + '>' => '<=', + '>=' => '<', + '<=' => '>', + '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() == 1 && is_bool($key)) { + if ($key) { + return; + } + + $key = new Expression('false'); + } + + if (func_num_args() == 2) { + $value = $operator; + $operator = '='; + } + + $this->key = $key; + $this->operator = $operator; + $this->value = $value; + } + + protected 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 toQueryArguments method + if (is_string($field = $this->key) && !str_contains($field, '/')) { + $field = $model->getField($field); + } + + if ($field instanceof Field) { + $field->system = true; + $field->default = $this->value; + } + } + } + } + + public function toQueryArguments(): array + { + if ($this->isEmpty()) { + return []; + } + + $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 "/" + if (str_contains($field, '/')) { + $references = explode('/', $field); + $field = array_pop($references); + + $refModels = []; + $refModel = $model; + foreach ($references as $link) { + $refModel = $refModel->refLink($link); + $refModels[] = $refModel; + } + + 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 = null; + $value = null; + } + } + } 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')) + ->addMoreInfo('operator', $this->operator ?: 'no operator'); + } + + return $this; + } + + public function toWords(Model $model = null): string + { + $model = $model ?: $this->getModel(); + + if ($model === null) { + throw new Exception('Condition must be associated with Model to convert to words'); + } + + $key = $this->keyToWords($model); + $operator = $this->operatorToWords(); + $value = $this->valueToWords($model, $this->value); + + return trim("{$key} {$operator} {$value}"); + } + + protected function keyToWords(Model $model): string + { + $words = []; + + if (is_string($field = $this->key)) { + if (str_contains($field, '/')) { + $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 = ''; + } + } + + $field = $model->hasField($field) ? $model->getField($field) : null; + } + + if ($field instanceof Field) { + $words[] = $field->getCaption(); + } elseif ($field instanceof Expression) { + $words[] = "expression '{$field->getDebugQuery()}'"; + } + + return implode(' ', array_filter($words)); + } + + 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')) + ->addMoreInfo('operator', $operator); + } + + return self::$dictionary[$operator]; + } + + protected function valueToWords(Model $model, $value): string + { + if ($value === null) { + return $this->operator ? 'empty' : ''; + } + + if (is_array($values = $value)) { + $ret = []; + foreach ($values as $value) { + $ret[] = $this->valueToWords($model, $value); + } + + 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 (str_contains($field, '/')) { + $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..bc3204dd4 --- /dev/null +++ b/src/Model/Scope/CompoundCondition.php @@ -0,0 +1,207 @@ +addMoreInfo('junction', $junction); + } + + $this->junction = $junction; + + 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; + } + } + + /** + * 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. + * + * @return AbstractCondition[] + */ + public function getNestedConditions() + { + return $this->elements; + } + + protected function onChangeModel(): void + { + foreach ($this->elements as $nestedCondition) { + $nestedCondition->onChangeModel(); + } + } + + public function isEmpty(): bool + { + return count($this->elements) === 0; + } + + 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 compound condition from nested conditions. + * + * @return static + */ + public function clear() + { + $this->elements = []; + + return $this; + } + + public function simplify(): AbstractCondition + { + 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(Model $model = null): string + { + $parts = []; + foreach ($this->elements as $nestedCondition) { + $words = $nestedCondition->toWords($model); + + $parts[] = $this->isCompound() && $nestedCondition->isCompound() ? '(' . $words . ')' : $words; + } + + $glue = ' ' . strtolower($this->junction) . ' '; + + return implode($glue, $parts); + } + + /** + * @return static + */ + public static function createAnd(...$conditions) + { + return new static($conditions, self::AND); + } + + /** + * @return static + */ + public static function createOr(...$conditions) + { + return new static($conditions, self::OR); + } +} diff --git a/src/Persistence/Array_.php b/src/Persistence/Array_.php index bef8cb090..421760ee5 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; @@ -24,6 +23,14 @@ public function __construct(array $data = []) $this->data = $data; } + /** + * Array of last inserted ids per table. + * Last inserted ID for any table is stored under '$' key. + * + * @var array + */ + protected $lastInsertIds = []; + /** * @deprecated TODO temporary for these: * - https://github.com/atk4/data/blob/90ab68ac063b8fc2c72dcd66115f1bd3f70a3a92/src/Reference/ContainsOne.php#L119 @@ -38,10 +45,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 +56,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 +67,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 +87,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 +108,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 +125,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 +149,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 +172,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 +188,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 +198,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 +257,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 +277,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 +293,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 +354,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 +367,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]; + $action = $this->initAction($model, $field); + $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..949e5095c 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -364,60 +364,32 @@ protected function setLimitOrder(Model $m, Query $q) } /** - * Will apply conditions defined inside $m onto query $q. + * Will apply a condition defined inside $condition 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): void { - 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->isEmpty()) { + // peel off the single nested scopes to convert (((field = value))) to field = value + $condition = $condition->simplify(); - continue; + // simple condition + if ($condition instanceof Model\Scope\BasicCondition) { + $query = $query->where(...$condition->toQueryArguments()); } - if (is_string($cond[0])) { - $cond[0] = $m->getField($cond[0]); - } + // nested conditions + if ($condition instanceof Model\Scope\CompoundCondition) { + $expression = $condition->isOr() ? $query->orExpr() : $query->andExpr(); - 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]); + foreach ($condition->getNestedConditions() as $nestedCondition) { + $this->initQueryConditions($model, $expression, $nestedCondition); } - $q->where($cond[0], $cond[1], $cond[2]); + + $query = $query->where($expression); } } - - return $q; } /** @@ -631,14 +603,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 +697,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 +726,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 +752,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 +780,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 +812,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 +854,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 +891,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 +933,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 +969,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..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->assertSame([], $u->conditions); // 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->assertSame([], $u->conditions); // 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 } diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index 006fe1323..c9e40bbbe 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->assertSame(1, count($m->scope()->getNestedConditions())); $m->addCondition('gender', 'F'); - $this->assertSame(2, count($m->conditions)); + $this->assertSame(2, count($m->scope()->getNestedConditions())); $m->addCondition([['gender', 'F'], ['foo', 'bar']]); - $this->assertSame(3, count($m->conditions)); + $this->assertSame(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/PersistentArrayTest.php b/tests/PersistentArrayTest.php index 416650e97..7fa264e9d 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->assertSame(3, $p->lastInsertID()); } public function testIterator() @@ -350,17 +352,18 @@ 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); $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 +379,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->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]); + $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 - $m->conditions = []; + $m->scope()->clear(); $m->addCondition('country', 'LIKE', '%ia'); $result = $m->action('select')->get(); $this->assertSame(4, count($result)); @@ -389,10 +405,10 @@ 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)); + $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]); @@ -404,29 +420,202 @@ 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())); + $this->assertSame(6, 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())); + $this->assertSame(6, 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())); + + // 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())); + } + + /** + * 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->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->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->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->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->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->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->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', '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(); + $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->assertSame(2, count($result)); + $this->assertSame($a['countries'][8], $result[8]); + $this->assertSame($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->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() + { + $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->assertSame(1, $m->action('exists')->getOne()); + + $m->delete(1); + + $this->assertSame(0, $m->action('exists')->getOne()); } /** @@ -512,7 +701,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 } /** @@ -597,14 +786,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 +809,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..4098e8392 --- /dev/null +++ b/tests/ScopeTest.php @@ -0,0 +1,400 @@ +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']); + + $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()); + } +} + +class ScopeTest extends \atk4\schema\PhpunitTestCase +{ + protected $user; + protected $country; + protected $ticket; + + 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'], + ]); + + $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], + ['number' => '004', 'venue' => 'Best Stadium', 'user' => 4], + ['number' => '005', 'venue' => 'Best Stadium', 'user' => 5], + ]); + } + + 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->toWords($user)); + + $condition = new BasicCondition('country_id/code', 'US'); + + $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->toWords($user)); + + if ($this->driverType == 'sqlite') { + $condition = new BasicCondition('name', $user->expr('[surname]')); + + $this->assertEquals('Name is equal to expression \'"user"."surname"\'', $condition->toWords($user)); + } + + $condition = new BasicCondition('country_id', null); + + $this->assertEquals('Country Id is equal to empty', $condition->toWords($user)); + + $condition = new BasicCondition('name', '>', 'Test'); + + $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->toWords($user)); + + $condition = new BasicCondition($user->getField('surname'), $user->getField('name')); + + $this->assertEquals('Surname is equal to User Name', $condition->toWords($user)); + + $country = clone $this->country; + + $country->addCondition('Users/#'); + + $this->assertEquals('Country that has reference Users where any referenced record exists', $country->scope()->toWords()); + } + + public function testConditionOnReferencedRecords() + { + $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')); + } + + $user = clone $this->user; + + // users that have no ticket + $user->addCondition('Tickets/#', 0); + + $this->assertEquals(1, $user->action('count')->getOne()); + + foreach ($user as $u) { + $this->assertTrue(in_array($u->get('name'), ['Alain', 'Aerton', 'Rubens'], true)); + } + + $country = clone $this->country; + + // countries with more than one user + $country->addCondition('Users/#', '>', 1); + + foreach ($country as $c) { + $this->assertEquals('BR', $c->get('code')); + } + + $country = clone $this->country; + + // countries with users that have ticket number 001 + $country->addCondition('Users/Tickets/number', '001'); + + 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(3, $country->action('count')->getOne()); + + foreach ($country as $c) { + $this->assertTrue(in_array($c->get('code'), ['LV', 'CA', 'BR'], true)); + } + + $country = clone $this->country; + + // countries with users that have no tickets + $country->addCondition('Users/Tickets/#', 0); + + $this->assertEquals(1, $country->action('count')->getOne()); + + foreach ($country as $c) { + $this->assertTrue(in_array($c->get('code'), ['FR'], true)); + } + + $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); + 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)); + } + } + + 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::createAnd($condition1, $condition2); + $compoundCondition2 = CompoundCondition::createAnd($condition3, $condition4); + + $compoundCondition = CompoundCondition::createOr($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->toWords($user)); + + $user->scope()->add($compoundCondition); + + $this->assertSame($user, $compoundCondition->getModel()); + + $this->assertEquals(2, count($user->export())); + + $this->assertEquals($compoundCondition->toWords($user), $user->scope()->toWords()); + + $compoundCondition = CompoundCondition::createOr($compoundCondition1, $compoundCondition2); + + $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)); + + $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::createAnd($condition1, $condition2); + $condition3 = (new BasicCondition('surname', 'Prost'))->negate(); + + $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)); + } + + public function testNegate() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', '!=', 'Alain'); + $condition2 = new BasicCondition('country_code', '!=', 'FR'); + + $condition = CompoundCondition::createOr($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::createAnd($condition1, $condition2); + + $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)); + } + + public function testOr() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'FR'); + + $compoundCondition = CompoundCondition::createOr($condition1, $condition2); + + $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)); + } + + public function testMerge() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'FR'); + + $compoundCondition = CompoundCondition::createAnd($condition1, $condition2); + + $this->assertEquals('Name is equal to \'Alain\' and Code is equal to \'FR\'', $compoundCondition->toWords($user)); + } + + public function testDestroyEmpty() + { + $user = clone $this->user; + + $condition1 = new BasicCondition('name', 'Alain'); + $condition2 = new BasicCondition('country_code', 'FR'); + + $compoundCondition = CompoundCondition::createAnd($condition1, $condition2); + + $compoundCondition->clear(); + + $this->assertTrue($compoundCondition->isEmpty()); + + $this->assertEmpty($compoundCondition->toWords($user)); + } +}