From 7083cf5d2735fc8ae4e7bcb6f29d912def89afe3 Mon Sep 17 00:00:00 2001 From: Georgi Hristov Date: Sun, 26 Jul 2020 23:40:25 +0200 Subject: [PATCH] [update] migrate aggregate and union models to data package --- src/Model/Aggregate.php | 334 +++++++++++++++++++++++++ src/Model/Union.php | 462 +++++++++++++++++++++++++++++++++++ tests/Model/Client.php | 2 + tests/Model/Invoice.php | 21 ++ tests/Model/Payment.php | 22 ++ tests/Model/Transaction.php | 26 ++ tests/Model/Transaction2.php | 30 +++ tests/ModelAggregateTest.php | 230 +++++++++++++++++ tests/ModelUnionExprTest.php | 276 +++++++++++++++++++++ tests/ModelUnionTest.php | 240 ++++++++++++++++++ tests/ReportTest.php | 57 +++++ 11 files changed, 1700 insertions(+) create mode 100644 src/Model/Aggregate.php create mode 100644 src/Model/Union.php create mode 100644 tests/Model/Invoice.php create mode 100644 tests/Model/Payment.php create mode 100644 tests/Model/Transaction.php create mode 100644 tests/Model/Transaction2.php create mode 100644 tests/ModelAggregateTest.php create mode 100644 tests/ModelUnionExprTest.php create mode 100644 tests/ModelUnionTest.php create mode 100644 tests/ReportTest.php diff --git a/src/Model/Aggregate.php b/src/Model/Aggregate.php new file mode 100644 index 000000000..d3bb68a93 --- /dev/null +++ b/src/Model/Aggregate.php @@ -0,0 +1,334 @@ +groupBy(['first','last'], ['salary'=>'sum([])']; + * + * your resulting model will have 3 fields: + * first, last, salary + * + * but when querying it will use the original model to calculate the query, then add grouping and aggregates. + * + * If you wish you can add more fields, which will be passed through: + * $aggregate->addField('middle'); + * + * If this field exist in the original model it will be added and you'll get exception otherwise. Finally you are + * permitted to add expressions. + * + * The base model must not be Union model or another Aggregate model, however it's possible to use Aggregate model as nestedModel inside Union model. + * Union model implements identical grouping rule on its own. + * + * You can also pass seed (for example field type) when aggregating: + * $aggregate->groupBy(['first','last'], ['salary' => ['sum([])', 'type'=>'money']]; + */ +class Aggregate extends Model +{ + /** @const string */ + public const HOOK_AFTER_SELECT = self::class . '@afterSelect'; + + /** + * @deprecated use HOOK_AFTER_SELECT instead - will be removed dec-2020 + */ + public const HOOK_AFTER_GROUP_SELECT = self::HOOK_AFTER_SELECT; + + /** + * Aggregate model should always be read-only. + * + * @var bool + */ + public $read_only = true; + + /** @var Model */ + public $master_model; + + /** @var string */ + public $id_field; + + /** @var array */ + public $group = []; + + /** @var array */ + public $aggregate = []; + + /** @var array */ + public $system_fields = []; + + /** + * Constructor. + */ + public function __construct(Model $model, array $defaults = []) + { + $this->master_model = $model; + $this->table = $model->table; + + //$this->_default_class_addExpression = $model->_default_class_addExpression; + parent::__construct($model->persistence, $defaults); + + // always use table prefixes for this model + $this->persistence_data['use_table_prefixes'] = true; + } + + /** + * Specify a single field or array of fields on which we will group model. + * + * @param array $group Array of field names + * @param array $aggregate Array of aggregate mapping + * + * @return $this + */ + public function groupBy(array $group, array $aggregate = []) + { + $this->group = $group; + $this->aggregate = $aggregate; + + $this->system_fields = array_unique($this->system_fields + $group); + foreach ($group as $fieldName) { + $this->addField($fieldName); + } + + foreach ($aggregate as $fieldName => $expr) { + $seed = is_array($expr) ? $expr : [$expr]; + + $args = []; + // if field originally defined in the parent model, then it can be used as part of expression + if ($this->master_model->hasField($fieldName)) { + $args = [$this->master_model->getField($fieldName)]; // @TODO Probably need cloning here + } + + $seed['expr'] = $this->master_model->expr($seed[0] ?? $seed['expr'], $args); + + // now add the expressions here + $this->addExpression($fieldName, $seed); + } + + return $this; + } + + /** + * Return reference field. + * + * @param string $link + */ + public function getRef($link): Reference + { + return $this->master_model->getRef($link); + } + + /** + * Adds new field into model. + * + * @param string $name + * @param array|object $seed + * + * @return Field + */ + public function addField($name, $seed = []) + { + $seed = is_array($seed) ? $seed : [$seed]; + + if (isset($seed[0]) && $seed[0] instanceof FieldSqlExpression) { + return parent::addField($name, $seed[0]); + } + + if ($seed['never_persist'] ?? false) { + return parent::addField($name, $seed); + } + + if ($this->master_model->hasField($name)) { + $field = clone $this->master_model->getField($name); + $field->owner = null; // will be new owner + } else { + $field = null; + } + + return $field + ? parent::addField($name, $field)->setDefaults($seed) + : parent::addField($name, $seed); + } + + /** + * Given a query, will add safe fields in. + */ + public function queryFields(Query $query, array $fields = []): Query + { + $this->persistence->initQueryFields($this, $query, $fields); + + return $query; + } + + /** + * Adds grouping in query. + */ + public function addGrouping(Query $query) + { + // use table alias of master model + $this->table_alias = $this->master_model->table_alias; + + foreach ($this->group as $field) { + if ($this->master_model->hasField($field)) { + $expression = $this->master_model->getField($field); + } else { + $expression = $this->expr($field); + } + + $query->group($expression); + } + } + + /** + * Sets limit. + * + * @param int $count + * @param int|null $offset + * + * @return $this + * + * @todo Incorrect implementation + */ + public function setLimit($count, $offset = null) + { + $this->master_model->setLimit($count, $offset); + + return $this; + } + + /** + * Sets order. + * + * @param mixed $field + * @param bool|null $desc + * + * @return $this + * + * @todo Incorrect implementation + */ + public function setOrder($field, $desc = null) + { + $this->master_model->setOrder($field, $desc); + + return $this; + } + + /** + * Execute action. + * + * @param string $mode + * @param array $args + * + * @return Query + */ + public function action($mode, $args = []) + { + $subquery = null; + switch ($mode) { + case 'select': + $fields = $this->only_fields ?: array_keys($this->getFields()); + + // select but no need your fields + $query = $this->master_model->action($mode, [false]); + $query = $this->queryFields($query, array_unique($fields + $this->system_fields)); + + $this->addGrouping($query); + $this->initQueryConditions($query); + + $this->hook(self::HOOK_AFTER_SELECT, [$query]); + + return $query; + case 'count': + $query = $this->master_model->action($mode, $args); + + $query->reset('field')->field($this->expr('1')); + $this->addGrouping($query); + + $this->hook(self::HOOK_AFTER_SELECT, [$query]); + + return $query->dsql()->field('count(*)')->table($this->expr('([]) der', [$query])); + case 'field': + if (!isset($args[0])) { + throw (new Exception('This action requires one argument with field name')) + ->addMoreInfo('mode', $mode); + } + + if (!is_string($args[0])) { + throw (new Exception('action "field" only support string fields')) + ->addMoreInfo('field', $args[0]); + } + + $subquery = $this->getSubQuery([$args[0]]); + + break; + case 'fx': + $subquery = $this->getSubAction('fx', [$args[0], $args[1], 'alias' => 'val']); + + $args = [$args[0], $this->expr('val')]; + + break; + default: + throw (new Exception('Aggregate model does not support this action')) + ->addMoreInfo('mode', $mode); + } + + // Substitute FROM table with our subquery expression + return parent::action($mode, $args)->reset('table')->table($subquery); + } + + /** + * Our own way applying conditions, where we use "having" for + * fields. + */ + public function initQueryConditions(Query $query, Model\Scope\AbstractScope $condition = null): void + { + $condition = $condition ?? $this->scope(); + + if (!$condition->isEmpty()) { + // peel off the single nested scopes to convert (((field = value))) to field = value + $condition = $condition->simplify(); + + // simple condition + if ($condition instanceof Model\Scope\Condition) { + $query = $query->having(...$condition->toQueryArguments()); + } + + // nested conditions + if ($condition instanceof Model\Scope) { + $expression = $condition->isOr() ? $query->orExpr() : $query->andExpr(); + + foreach ($condition->getNestedConditions() as $nestedCondition) { + $this->initQueryConditions($expression, $nestedCondition); + } + + $query = $query->having($expression); + } + } + } + + // {{{ Debug Methods + + /** + * Returns array with useful debug info for var_dump. + */ + public function __debugInfo(): array + { + return array_merge(parent::__debugInfo(), [ + 'group' => $this->group, + 'aggregate' => $this->aggregate, + 'master_model' => $this->master_model->__debugInfo(), + ]); + } + + // }}} +} diff --git a/src/Model/Union.php b/src/Model/Union.php new file mode 100644 index 000000000..e10781d10 --- /dev/null +++ b/src/Model/Union.php @@ -0,0 +1,462 @@ +'total_gross'] ] , [$m2, []] ]; + * + * @var array + */ + public $union = []; + + /** + * Union normally does not have ID field. Setting this to null will + * disable various per-id operations, such as load(). + * + * If you can define unique ID field, you can specify it inside your + * union model. + * + * @var string + */ + public $id_field; + + /** + * When aggregation happens, this field will contain list of fields + * we use in groupBy. Multiple fields can be in the array. All + * the remaining fields will be hidden (marked as system()) and + * have their "aggregates" added into the selectQuery (if possible). + * + * @var array|string + */ + public $group; + + /** + * When grouping, the functions will be applied as per aggregate + * fields, e.g. 'balance'=>['sum', 'amount']. + * + * You can also use Expression instead of array. + * + * @var array + */ + public $aggregate = []; + + /** @var string Derived table alias */ + public $table = 'derivedTable'; + + /** + * For a sub-model with a specified mapping, return expression + * that represents a field. + * + * @return Field|Expression + */ + public function getFieldExpr(Model $model, string $fieldName, string $expr = null) + { + if ($model->hasField($fieldName)) { + $field = $model->getField($fieldName); + } else { + $field = $this->expr('NULL'); + } + + // Some fields are re-mapped for this nested model + if ($expr !== null) { + $field = $model->expr($expr, [$field]); + } + + return $field; + } + + /** + * Configures nested models to have a specified set of fields + * available. + */ + public function getSubQuery(array $fields): Expression + { + $cnt = 0; + $expr = []; + $args = []; + + foreach ($this->union as $n => [$nestedModel, $fieldMap]) { + // map fields for related model + $queryFieldMap = []; + foreach ($fields as $fieldName) { + try { + // Union can be joined with additional + // table/query and we don't touch those + // fields + + if (!$this->hasField($fieldName)) { + $queryFieldMap[$fieldName] = $nestedModel->expr('NULL'); + + continue; + } + + if ($this->getField($fieldName)->join || $this->getField($fieldName)->never_persist) { + continue; + } + + // Union can have some fields defined as expressions. We don't touch those either. + // Imants: I have no idea why this condition was set, but it's limiting our ability + // to use expression fields in mapping + if ($this->getField($fieldName) instanceof FieldSqlExpression && !isset($this->aggregate[$fieldName])) { + continue; + } + + $field = $this->getFieldExpr($nestedModel, $fieldName, $fieldMap[$fieldName] ?? null); + + if (isset($this->aggregate[$fieldName])) { + $seed = (array) $this->aggregate[$fieldName]; + + // first element of seed should be expression itself + $field = $nestedModel->expr($seed[0], [$field]); + } + + $queryFieldMap[$fieldName] = $field; + } catch (\atk4\core\Exception $e) { + throw $e->addMoreInfo('model', $n); + } + } + + // now prepare query + $expr[] = '[' . $cnt . ']'; + $query = $this->persistence->action($nestedModel, 'select', [false]); + + if ($nestedModel instanceof self) { + $subquery = $nestedModel->getSubQuery($fields); + //$query = parent::action($mode, $args); + $query->reset('table')->table($subquery); + + if (isset($nestedModel->group)) { + $query->group($nestedModel->group); + } + } + + $query->field($queryFieldMap); + + // also for sub-queries + if ($this->group) { + if (is_array($this->group)) { + foreach ($this->group as $group) { + if (isset($fieldMap[$group])) { + $query->group($nestedModel->expr($fieldMap[$group])); + } elseif ($nestedModel->hasField($group)) { + $query->group($nestedModel->getField($group)); + } + } + } elseif (isset($fieldMap[$this->group])) { + $query->group($nestedModel->expr($fieldMap[$this->group])); + } else { + $query->group($this->group); + } + } + + // subquery should not be wrapped in parenthesis, SQLite is especially picky about that + $query->allowToWrapInParenthesis = false; + + $args[$cnt++] = $query; + } + + // last element is table name itself + $args[$cnt] = $this->table; + + return $this->persistence->dsql()->expr('(' . implode(' UNION ALL ', $expr) . ') {' . $cnt . '}', $args); + } + + /** + * No description. + */ + public function getSubAction(string $action, array $act_arg = []): Expression + { + $cnt = 0; + $expr = []; + $args = []; + + foreach ($this->union as [$model, $mapping]) { + // now prepare query + $expr[] = '[' . $cnt . ']'; + if ($act_arg && isset($act_arg[1])) { + $a = $act_arg; + $a[1] = $this->getFieldExpr( + $model, + $a[1], + $mapping[$a[1]] ?? null + ); + $query = $model->action($action, $a); + } else { + $query = $model->action($action, $act_arg); + } + + // subquery should not be wrapped in parenthesis, SQLite is especially picky about that + $query->allowToWrapInParenthesis = false; + + $args[$cnt++] = $query; + } + + // last element is table name itself + $args[$cnt] = $this->table; + + return $this->persistence->dsql()->expr('(' . implode(' UNION ALL ', $expr) . ') {' . $cnt . '}', $args); + } + + /** + * Execute action. + * + * @param string $mode + * @param array $args + * + * @return Query + */ + public function action($mode, $args = []) + { + $subquery = null; + switch ($mode) { + case 'select': + // get list of available fields + $fields = $this->only_fields ?: array_keys($this->getFields()); + foreach ($fields as $k => $field) { + if ($this->getField($field)->never_persist) { + unset($fields[$k]); + } + } + $subquery = $this->getSubQuery($fields); + $query = parent::action($mode, $args)->reset('table')->table($subquery); + + if (isset($this->group)) { + $query->group($this->group); + } + $this->hook(self::HOOK_AFTER_SELECT, [$query]); + + return $query; + case 'count': + $subquery = $this->getSubAction('count', ['alias' => 'cnt']); + + $mode = 'fx'; + $args = ['sum', $this->expr('{}', ['cnt'])]; + + break; + case 'field': + if (!isset($args[0])) { + throw (new Exception('This action requires one argument with field name')) + ->addMoreInfo('mode', $mode); + } + + if (!is_string($args[0])) { + throw (new Exception('action "field" only support string fields')) + ->addMoreInfo('field', $args[0]); + } + + $subquery = $this->getSubQuery([$args[0]]); + + break; + case 'fx': + $subquery = $this->getSubAction('fx', [$args[0], $args[1], 'alias' => 'val']); + + $args = [$args[0], $this->expr('{}', ['val'])]; + + break; + default: + throw (new Exception('Union model does not support this action')) + ->addMoreInfo('mode', $mode); + } + + // Substitute FROM table with our subquery expression + return parent::action($mode, $args)->reset('table')->table($subquery); + } + + /** + * Export model. + * + * @param array|null $fields Names of fields to export + * @param string $key_field Optional name of field which value we will use as array key + * @param bool $typecast_data Should we typecast exported data + */ + public function export($fields = null, $key_field = null, $typecast_data = true): array + { + if ($fields) { + $this->onlyFields($fields); + } + + $data = []; + foreach ($this->getIterator() as $row) { + $data[] = $row->get(); + } + + return $data; + } + + /** + * Adds nested model in union. + * + * @param string|Model $class model + * @param array $fieldMap Array of field mapping + */ + public function addNestedModel($class, array $fieldMap = []): Model + { + $nestedModel = $this->persistence->add($class); + + $this->union[] = [$nestedModel, $fieldMap]; + + return $nestedModel; + } + + /** + * Specify a single field or array of fields. + * + * @param string|array $group + * + * @return $this + */ + public function groupBy($group, array $aggregate = []) + { + $this->aggregate = $aggregate; + $this->group = $group; + + foreach ($aggregate as $fieldName => $seed) { + $seed = (array) $seed; + + $field = $this->hasField($fieldName) ? $this->getField($fieldName) : null; + + // first element of seed should be expression itself + if (isset($seed[0]) && is_string($seed[0])) { + $seed[0] = $this->expr($seed[0], $field ? [$field] : null); + } + + if ($field) { + $this->removeField($fieldName); + } + + $this->addExpression($fieldName, $seed); + } + + foreach ($this->union as [$nestedModel, $fieldMap]) { + if ($nestedModel instanceof self) { + $nestedModel->aggregate = $aggregate; + $nestedModel->group = $group; + } + } + + return $this; + } + + /** + * Adds condition. + * + * If Union model has such field, then add condition to it. + * Otherwise adds condition to all nested models. + * + * @param mixed $key + * @param mixed $operator + * @param mixed $value + * @param bool $forceNested Should we add condition to all nested models? + * + * @return $this + */ + public function addCondition($key, $operator = null, $value = null, $forceNested = false) + { + if (func_num_args() === 1) { + return parent::addCondition($key); + } + + // if Union model has such field, then add condition to it + if ($this->hasField($key) && !$forceNested) { + return parent::addCondition(...func_get_args()); + } + + // otherwise add condition in all sub-models + foreach ($this->union as $n => [$nestedModel, $fieldMap]) { + try { + $field = $key; + + if (isset($fieldMap[$key])) { + // field is included in mapping - use mapping expression + $field = $fieldMap[$key] instanceof Expression + ? $fieldMap[$key] + : $this->expr($fieldMap[$key], $nestedModel->getFields()); + } elseif (is_string($key) && $nestedModel->hasField($key)) { + // model has such field - use that field directly + $field = $nestedModel->getField($key); + } else { + // we don't know what to do, so let's do nothing + continue; + } + + switch (func_num_args()) { + case 2: + $nestedModel->addCondition($field, $operator); + + break; + case 3: + case 4: + $nestedModel->addCondition($field, $operator, $value); + + break; + } + } catch (\atk4\core\Exception $e) { + throw $e->addMoreInfo('sub_model', $n); + } + } + + return $this; + } + + // {{{ Debug Methods + + /** + * Returns array with useful debug info for var_dump. + */ + public function __debugInfo(): array + { + $unionModels = []; + foreach ($this->union as [$nestedModel, $fieldMap]) { + $unionModels[get_class($nestedModel)] = array_merge( + ['fieldMap' => $fieldMap], + $nestedModel->__debugInfo() + ); + } + + return array_merge( + parent::__debugInfo(), + [ + 'group' => $this->group, + 'aggregate' => $this->aggregate, + 'unionModels' => $unionModels, + ] + ); + } + + // }}} +} diff --git a/tests/Model/Client.php b/tests/Model/Client.php index cae763c11..c7917706e 100644 --- a/tests/Model/Client.php +++ b/tests/Model/Client.php @@ -6,6 +6,8 @@ class Client extends User { + public $table = 'client'; + public function init(): void { parent::init(); diff --git a/tests/Model/Invoice.php b/tests/Model/Invoice.php new file mode 100644 index 000000000..510d02c44 --- /dev/null +++ b/tests/Model/Invoice.php @@ -0,0 +1,21 @@ +addField('name'); + + $this->hasOne('client_id', [Client::class]); + $this->addField('amount', ['type' => 'money']); + } +} diff --git a/tests/Model/Payment.php b/tests/Model/Payment.php new file mode 100644 index 000000000..1333cf23c --- /dev/null +++ b/tests/Model/Payment.php @@ -0,0 +1,22 @@ +addField('name'); + + $this->hasOne('client_id', [Client::class]); + $this->addField('amount', ['type' => 'money']); + } +} diff --git a/tests/Model/Transaction.php b/tests/Model/Transaction.php new file mode 100644 index 000000000..7c4f17bf6 --- /dev/null +++ b/tests/Model/Transaction.php @@ -0,0 +1,26 @@ +nestedInvoice = $this->addNestedModel(new Invoice()); + $this->nestedPayment = $this->addNestedModel(new Payment()); + + // next, define common fields + $this->addField('name'); + $this->addField('amount', ['type' => 'money']); + } +} diff --git a/tests/Model/Transaction2.php b/tests/Model/Transaction2.php new file mode 100644 index 000000000..e31cf8525 --- /dev/null +++ b/tests/Model/Transaction2.php @@ -0,0 +1,30 @@ +nestedInvoice = $this->addNestedModel(new Invoice(), ['amount' => '-[]']); + $this->nestedPayment = $this->addNestedModel(new Payment()); + + //$this->nestedInvoice->hasOne('client_id', [new Client()]); + //$this->nestedPayment->hasOne('client_id', [new Client()]); + + // next, define common fields + $this->addField('name'); + $this->addField('amount', ['type' => 'money']); + //$this->hasOne('client_id', [new Client()]); + } +} diff --git a/tests/ModelAggregateTest.php b/tests/ModelAggregateTest.php new file mode 100644 index 000000000..e5fb97d92 --- /dev/null +++ b/tests/ModelAggregateTest.php @@ -0,0 +1,230 @@ + [ + ['name' => 'Vinny'], + ['name' => 'Zoe'], + ], + 'invoice' => [ + ['client_id' => 1, 'name' => 'chair purchase', 'amount' => 4.0], + ['client_id' => 1, 'name' => 'table purchase', 'amount' => 15.0], + ['client_id' => 2, 'name' => 'chair purchase', 'amount' => 4.0], + ], + 'payment' => [ + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], + ]; + + /** @var Aggregate */ + protected $aggregate; + + protected function setUp(): void + { + parent::setUp(); + $this->setDB($this->init_db); + + $m_invoice = new Model\Invoice($this->db); + $m_invoice->getRef('client_id')->addTitle(); + + $this->aggregate = new Aggregate($m_invoice); + $this->aggregate->addField('client'); + } + + public function testGroupSelect() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], ['c' => ['expr' => 'count(*)', 'type' => 'integer']]); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 'c' => 2], + ['client' => 'Zoe', 'client_id' => '2', 'c' => 1], + ], + $aggregate->export() + ); + } + + public function testGroupSelect2() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 'amount' => 19.0], + ['client' => 'Zoe', 'client_id' => '2', 'amount' => 4.0], + ], + $aggregate->export() + ); + } + + public function testGroupSelect3() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 's' => ['expr' => 'sum([amount])', 'type' => 'money'], + 'min' => ['expr' => 'min([amount])', 'type' => 'money'], + 'max' => ['expr' => 'max([amount])', 'type' => 'money'], + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], // same as `s`, but reuse name `amount` + ]); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 's' => 19.0, 'min' => 4.0, 'max' => 15.0, 'amount' => 19.0], + ['client' => 'Zoe', 'client_id' => '2', 's' => 4.0, 'min' => 4.0, 'max' => 4.0, 'amount' => 4.0], + ], + $aggregate->export() + ); + } + + public function testGroupSelectExpr() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 's' => ['expr' => 'sum([amount])', 'type' => 'money'], + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + + $aggregate->addExpression('double', ['[s]+[amount]', 'type' => 'money']); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 's' => 19.0, 'amount' => 19.0, 'double' => 38.0], + ['client' => 'Zoe', 'client_id' => '2', 's' => 4.0, 'amount' => 4.0, 'double' => 8.0], + ], + $aggregate->export() + ); + } + + public function testGroupSelectCondition() + { + $aggregate = $this->aggregate; + $aggregate->master_model->addCondition('name', 'chair purchase'); + + $aggregate->groupBy(['client_id'], [ + 's' => ['expr' => 'sum([amount])', 'type' => 'money'], + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + + $aggregate->addExpression('double', ['[s]+[amount]', 'type' => 'money']); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 's' => 4.0, 'amount' => 4.0, 'double' => 8.0], + ['client' => 'Zoe', 'client_id' => '2', 's' => 4.0, 'amount' => 4.0, 'double' => 8.0], + ], + $aggregate->export() + ); + } + + public function testGroupSelectCondition2() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 's' => ['expr' => 'sum([amount])', 'type' => 'money'], + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + + $aggregate->addExpression('double', ['[s]+[amount]', 'type' => 'money']); + $aggregate->addCondition('double', '>', 10); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 's' => 19.0, 'amount' => 19.0, 'double' => 38.0], + ], + $aggregate->export() + ); + } + + public function testGroupSelectCondition3() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 's' => ['expr' => 'sum([amount])', 'type' => 'money'], + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + + $aggregate->addExpression('double', ['[s]+[amount]', 'type' => 'money']); + $aggregate->addCondition('double', 38); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 's' => 19.0, 'amount' => 19.0, 'double' => 38.0], + ], + $aggregate->export() + ); + } + + public function testGroupSelectCondition4() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 's' => ['expr' => 'sum([amount])', 'type' => 'money'], + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + + $aggregate->addExpression('double', ['[s]+[amount]', 'type' => 'money']); + $aggregate->addCondition('client_id', 2); + + $this->assertSame( + [ + ['client' => 'Zoe', 'client_id' => '2', 's' => 4.0, 'amount' => 4.0, 'double' => 8.0], + ], + $aggregate->export() + ); + } + + public function testGroupLimit() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + $aggregate->setLimit(1); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 'amount' => 19.0], + ], + $aggregate->export() + ); + } + + public function testGroupLimit2() + { + $aggregate = $this->aggregate; + + $aggregate->groupBy(['client_id'], [ + 'amount' => ['expr' => 'sum([])', 'type' => 'money'], + ]); + $aggregate->setLimit(1, 1); + + $this->assertSame( + [ + ['client' => 'Zoe', 'client_id' => '2', 'amount' => 4.0], + ], + $aggregate->export() + ); + } +} diff --git a/tests/ModelUnionExprTest.php b/tests/ModelUnionExprTest.php new file mode 100644 index 000000000..00de31229 --- /dev/null +++ b/tests/ModelUnionExprTest.php @@ -0,0 +1,276 @@ + [ + ['name' => 'Vinny'], + ['name' => 'Zoe'], + ], + 'invoice' => [ + ['client_id' => 1, 'name' => 'chair purchase', 'amount' => 4.0], + ['client_id' => 1, 'name' => 'table purchase', 'amount' => 15.0], + ['client_id' => 2, 'name' => 'chair purchase', 'amount' => 4.0], + ], + 'payment' => [ + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->setDB($this->init_db); + + $this->transaction = new Model\Transaction2($this->db); + $this->client = new Model\Client($this->db, 'client'); + + $this->client->hasMany('Payment', [Model\Payment::class]); + $this->client->hasMany('Invoice', [Model\Invoice::class]); + } + + public function testFieldExpr() + { + $transaction = $this->transaction; + + $e = $this->getEscapeChar(); + $this->assertSame(str_replace('"', $e, '"amount"'), $transaction->expr('[]', [$transaction->getFieldExpr($transaction->nestedInvoice, 'amount')])->render()); + $this->assertSame(str_replace('"', $e, '-"amount"'), $transaction->expr('[]', [$transaction->getFieldExpr($transaction->nestedInvoice, 'amount', '-[]')])->render()); + $this->assertSame(str_replace('"', $e, '-NULL'), $transaction->expr('[]', [$transaction->getFieldExpr($transaction->nestedInvoice, 'blah', '-[]')])->render()); + } + + public function testNestedQuery1() + { + $transaction = $this->transaction; + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, '(select "name" "name" from "invoice" UNION ALL select "name" "name" from "payment") "derivedTable"'), + $transaction->getSubQuery(['name'])->render() + ); + + $this->assertSame( + str_replace('"', $e, '(select "name" "name",-"amount" "amount" from "invoice" UNION ALL select "name" "name","amount" "amount" from "payment") "derivedTable"'), + $transaction->getSubQuery(['name', 'amount'])->render() + ); + + $this->assertSame( + str_replace('"', $e, '(select "name" "name" from "invoice" UNION ALL select "name" "name" from "payment") "derivedTable"'), + $transaction->getSubQuery(['name'])->render() + ); + } + + /** + * If field is not set for one of the nested model, instead of generating exception, NULL will be filled in. + */ + public function testMissingField() + { + $transaction = $this->transaction; + $transaction->nestedInvoice->addExpression('type', '\'invoice\''); + $transaction->addField('type'); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('`', $e, '(select (\'invoice\') `type`,-`amount` `amount` from `invoice` UNION ALL select NULL `type`,`amount` `amount` from `payment`) `derivedTable`'), + $transaction->getSubQuery(['type', 'amount'])->render() + ); + } + + public function testActions() + { + $transaction = $this->transaction; + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, 'select "name","amount" from (select "name" "name",-"amount" "amount" from "invoice" UNION ALL select "name" "name","amount" "amount" from "payment") "derivedTable"'), + $transaction->action('select')->render() + ); + + $this->assertSame( + str_replace('"', $e, 'select "name" from (select "name" "name" from "invoice" UNION ALL select "name" "name" from "payment") "derivedTable"'), + $transaction->action('field', ['name'])->render() + ); + + $this->assertSame( + str_replace('"', $e, 'select sum("cnt") from (select count(*) "cnt" from "invoice" UNION ALL select count(*) "cnt" from "payment") "derivedTable"'), + $transaction->action('count')->render() + ); + + $this->assertSame( + str_replace('"', $e, 'select sum("val") from (select sum(-"amount") "val" from "invoice" UNION ALL select sum("amount") "val" from "payment") "derivedTable"'), + $transaction->action('fx', ['sum', 'amount'])->render() + ); + } + + public function testActions2() + { + $transaction = $this->transaction; + $this->assertSame(5, (int) $transaction->action('count')->getOne()); + $this->assertSame(-9.0, (float) $transaction->action('fx', ['sum', 'amount'])->getOne()); + } + + public function testSubAction1() + { + $transaction = $this->transaction; + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, '(select sum(-"amount") from "invoice" UNION ALL select sum("amount") from "payment") "derivedTable"'), + $transaction->getSubAction('fx', ['sum', 'amount'])->render() + ); + } + + public function testBasics() + { + $this->setDB($this->init_db); + + $client = $this->client; + + // There are total of 2 clients + $this->assertSame(2, (int) $client->action('count')->getOne()); + + // Client with ID=1 has invoices for 19 + $client->load(1); + $this->assertSame(19.0, (float) $client->ref('Invoice')->action('fx', ['sum', 'amount'])->getOne()); + + $transaction = new Model\Transaction2($this->db); + $this->assertSame([ + ['name' => 'chair purchase', 'amount' => -4.0], + ['name' => 'table purchase', 'amount' => -15.0], + ['name' => 'chair purchase', 'amount' => -4.0], + ['name' => 'prepay', 'amount' => 10.0], + ['name' => 'full pay', 'amount' => 4.0], + ], $transaction->export()); + + // Transaction is Union Model + $client->hasMany('Transaction', new Model\Transaction2()); + + $this->assertSame([ + ['name' => 'chair purchase', 'amount' => -4.0], + ['name' => 'table purchase', 'amount' => -15.0], + ['name' => 'prepay', 'amount' => 10.0], + ], $client->ref('Transaction')->export()); + } + + public function testGrouping1() + { + $transaction = $this->transaction; + + $transaction->groupBy('name', ['amount' => ['sum([])', 'type' => 'money']]); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, '(select "name" "name",sum(-"amount") "amount" from "invoice" group by "name" UNION ALL select "name" "name",sum("amount") "amount" from "payment" group by "name") "derivedTable"'), + $transaction->getSubQuery(['name', 'amount'])->render() + ); + } + + public function testGrouping2() + { + $transaction = $this->transaction; + + $transaction->groupBy('name', ['amount' => ['sum([])', 'type' => 'money']]); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, 'select "name",sum("amount") "amount" from (select "name" "name",sum(-"amount") "amount" from "invoice" group by "name" UNION ALL select "name" "name",sum("amount") "amount" from "payment" group by "name") "derivedTable" group by "name"'), + $transaction->action('select', [['name', 'amount']])->render() + ); + } + + /** + * If all nested models have a physical field to which a grouped column can be mapped into, then we should group all our + * sub-queries. + */ + public function testGrouping3() + { + $transaction = $this->transaction; + $transaction->groupBy('name', ['amount' => ['sum([])', 'type' => 'money']]); + $transaction->setOrder('name'); + + $this->assertSame([ + ['name' => 'chair purchase', 'amount' => -8.0], + ['name' => 'full pay', 'amount' => 4.0], + ['name' => 'prepay', 'amount' => 10.0], + ['name' => 'table purchase', 'amount' => -15.0], + ], $transaction->export()); + } + + /** + * If a nested model has a field defined through expression, it should be still used in grouping. We should test this + * with both expressions based off the fields and static expressions (such as "blah"). + */ + public function testSubGroupingByExpressions() + { + $transaction = $this->transaction; + $transaction->nestedInvoice->addExpression('type', '\'invoice\''); + $transaction->nestedPayment->addExpression('type', '\'payment\''); + $transaction->addField('type'); + + $transaction->groupBy('type', ['amount' => ['sum([])', 'type' => 'money']]); + + $this->assertSame([ + ['type' => 'invoice', 'amount' => -23.0], + ['type' => 'payment', 'amount' => 14.0], + ], $transaction->export(['type', 'amount'])); + } + + public function testReference() + { + $client = $this->client; + $client->hasMany('tr', new Model\Transaction2()); + + $this->assertSame(19.0, (float) $client->load(1)->ref('Invoice')->action('fx', ['sum', 'amount'])->getOne()); + $this->assertSame(10.0, (float) $client->load(1)->ref('Payment')->action('fx', ['sum', 'amount'])->getOne()); + $this->assertSame(-9.0, (float) $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->getOne()); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, 'select sum("val") from (select sum(-"amount") "val" from "invoice" where "client_id" = :a ' . + 'UNION ALL select sum("amount") "val" from "payment" where "client_id" = :b) "derivedTable"'), + $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->render() + ); + } + + /** + * Aggregation is supposed to work in theory, but MySQL uses "semi-joins" for this type of query which does not support UNION, + * and therefore it complains about `client`.`id` field. + * + * See also: http://stackoverflow.com/questions/8326815/mysql-field-from-union-subselect#comment10267696_8326815 + */ + public function testFieldAggregate() + { + $this->client->hasMany('tr', new Model\Transaction2()) + ->addField('balance', ['field' => 'amount', 'aggregate' => 'sum']); + + $this->assertTrue(true); // fake assert + //select "client"."id","client"."name",(select sum("val") from (select sum("amount") "val" from "invoice" where "client_id" = "client"."id" UNION ALL select sum("amount") "val" from "payment" where "client_id" = "client"."id") "derivedTable") "balance" from "client" where "client"."id" = 1 limit 0, 1 + //$c->load(1); + } + + /** + * Model's conditions can still be placed on the original field values. + */ + public function testConditionOnMappedField() + { + $transaction = new Model\Transaction2($this->db); + $transaction->nestedInvoice->addCondition('amount', 4); + + $this->assertSame([ + ['name' => 'chair purchase', 'amount' => -4.0], + ['name' => 'chair purchase', 'amount' => -4.0], + ['name' => 'prepay', 'amount' => 10.0], + ['name' => 'full pay', 'amount' => 4.0], + ], $transaction->export()); + } +} diff --git a/tests/ModelUnionTest.php b/tests/ModelUnionTest.php new file mode 100644 index 000000000..96deef3fd --- /dev/null +++ b/tests/ModelUnionTest.php @@ -0,0 +1,240 @@ + [ + ['name' => 'Vinny'], + ['name' => 'Zoe'], + ], + 'invoice' => [ + ['client_id' => 1, 'name' => 'chair purchase', 'amount' => 4.0], + ['client_id' => 1, 'name' => 'table purchase', 'amount' => 15.0], + ['client_id' => 2, 'name' => 'chair purchase', 'amount' => 4.0], + ], + 'payment' => [ + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], + ]; + + protected function setUp(): void + { + parent::setUp(); + $this->setDB($this->init_db); + + $this->transaction = new Model\Transaction($this->db); + + $this->client = new Model\Client($this->db); + + $this->client->hasMany('Payment', [Model\Payment::class]); + $this->client->hasMany('Invoice', [Model\Invoice::class]); + } + + public function testNestedQuery1() + { + $transaction = $this->transaction; + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, '(select "name" "name" from "invoice" UNION ALL select "name" "name" from "payment") "derivedTable"'), + $transaction->getSubQuery(['name'])->render() + ); + + $this->assertSame( + str_replace('"', $e, '(select "name" "name","amount" "amount" from "invoice" UNION ALL select "name" "name","amount" "amount" from "payment") "derivedTable"'), + $transaction->getSubQuery(['name', 'amount'])->render() + ); + + $this->assertSame( + str_replace('"', $e, '(select "name" "name" from "invoice" UNION ALL select "name" "name" from "payment") "derivedTable"'), + $transaction->getSubQuery(['name'])->render() + ); + } + + /** + * If field is not set for one of the nested model, instead of generating exception, NULL will be filled in. + */ + public function testMissingField() + { + $transaction = $this->transaction; + $transaction->nestedInvoice->addExpression('type', '\'invoice\''); + $transaction->addField('type'); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, '(select (\'invoice\') "type","amount" "amount" from "invoice" UNION ALL select NULL "type","amount" "amount" from "payment") "derivedTable"'), + $transaction->getSubQuery(['type', 'amount'])->render() + ); + } + + public function testActions() + { + $transaction = $this->transaction; + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, 'select "name","amount" from (select "name" "name","amount" "amount" from "invoice" UNION ALL select "name" "name","amount" "amount" from "payment") "derivedTable"'), + $transaction->action('select')->render() + ); + + $this->assertSame( + str_replace('"', $e, 'select "name" from (select "name" "name" from "invoice" UNION ALL select "name" "name" from "payment") "derivedTable"'), + $transaction->action('field', ['name'])->render() + ); + + $this->assertSame( + str_replace('"', $e, 'select sum("cnt") from (select count(*) "cnt" from "invoice" UNION ALL select count(*) "cnt" from "payment") "derivedTable"'), + $transaction->action('count')->render() + ); + + $this->assertSame( + str_replace('"', $e, 'select sum("val") from (select sum("amount") "val" from "invoice" UNION ALL select sum("amount") "val" from "payment") "derivedTable"'), + $transaction->action('fx', ['sum', 'amount'])->render() + ); + } + + public function testActions2() + { + $transaction = $this->transaction; + $this->assertSame(5, (int) $transaction->action('count')->getOne()); + $this->assertSame(37.0, (float) $transaction->action('fx', ['sum', 'amount'])->getOne()); + } + + public function testBasics() + { + $client = $this->client; + + // There are total of 2 clients + $this->assertSame(2, (int) $client->action('count')->getOne()); + + // Client with ID=1 has invoices for 19 + $client->load(1); + $this->assertSame(19.0, (float) $client->ref('Invoice')->action('fx', ['sum', 'amount'])->getOne()); + + $transaction = new Model\Transaction($this->db); + $this->assertSame([ + ['name' => 'chair purchase', 'amount' => 4.0], + ['name' => 'table purchase', 'amount' => 15.0], + ['name' => 'chair purchase', 'amount' => 4.0], + ['name' => 'prepay', 'amount' => 10.0], + ['name' => 'full pay', 'amount' => 4.0], + ], $transaction->export()); + + // Transaction is Union Model + $client->hasMany('Transaction', new Model\Transaction()); + + $this->assertSame([ + ['name' => 'chair purchase', 'amount' => 4.0], + ['name' => 'table purchase', 'amount' => 15.0], + ['name' => 'prepay', 'amount' => 10.0], + ], $client->ref('Transaction')->export()); + } + + public function testGrouping1() + { + $transaction = $this->transaction; + + $transaction->groupBy('name', ['amount' => ['sum([amount])', 'type' => 'money']]); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, '(select "name" "name",sum("amount") "amount" from "invoice" group by "name" UNION ALL select "name" "name",sum("amount") "amount" from "payment" group by "name") "derivedTable"'), + $transaction->getSubQuery(['name', 'amount'])->render() + ); + } + + public function testGrouping2() + { + $transaction = $this->transaction; + + $transaction->groupBy('name', ['amount' => ['sum([amount])', 'type' => 'money']]); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, 'select "name",sum("amount") "amount" from (select "name" "name",sum("amount") "amount" from "invoice" group by "name" UNION ALL select "name" "name",sum("amount") "amount" from "payment" group by "name") "derivedTable" group by "name"'), + $transaction->action('select', [['name', 'amount']])->render() + ); + } + + /** + * If all nested models have a physical field to which a grouped column can be mapped into, then we should group all our + * sub-queries. + */ + public function testGrouping3() + { + $transaction = $this->transaction; + $transaction->groupBy('name', ['amount' => ['sum([amount])', 'type' => 'money']]); + $transaction->setOrder('name'); + + $this->assertSame([ + ['name' => 'chair purchase', 'amount' => 8.0], + ['name' => 'full pay', 'amount' => 4.0], + ['name' => 'prepay', 'amount' => 10.0], + ['name' => 'table purchase', 'amount' => 15.0], + ], $transaction->export()); + } + + /** + * If a nested model has a field defined through expression, it should be still used in grouping. We should test this + * with both expressions based off the fields and static expressions (such as "blah"). + */ + public function testSubGroupingByExpressions() + { + $transaction = $this->transaction; + $transaction->nestedInvoice->addExpression('type', '\'invoice\''); + $transaction->nestedPayment->addExpression('type', '\'payment\''); + $transaction->addField('type'); + + $transaction->groupBy('type', ['amount' => ['sum([amount])', 'type' => 'money']]); + + $this->assertSame([ + ['type' => 'invoice', 'amount' => 23.0], + ['type' => 'payment', 'amount' => 14.0], + ], $transaction->export(['type', 'amount'])); + } + + public function testReference() + { + $client = $this->client; + $client->hasMany('tr', new Model\Transaction()); + + $this->assertSame(19.0, (float) $client->load(1)->ref('Invoice')->action('fx', ['sum', 'amount'])->getOne()); + $this->assertSame(10.0, (float) $client->load(1)->ref('Payment')->action('fx', ['sum', 'amount'])->getOne()); + $this->assertSame(29.0, (float) $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->getOne()); + + $e = $this->getEscapeChar(); + $this->assertSame( + str_replace('"', $e, 'select sum("val") from (select sum("amount") "val" from "invoice" where "client_id" = :a ' . + 'UNION ALL select sum("amount") "val" from "payment" where "client_id" = :b) "derivedTable"'), + $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->render() + ); + } + + /** + * Aggregation is supposed to work in theory, but MySQL uses "semi-joins" for this type of query which does not support UNION, + * and therefore it complains about "client"."id" field. + * + * See also: http://stackoverflow.com/questions/8326815/mysql-field-from-union-subselect#comment10267696_8326815 + */ + public function testFieldAggregate() + { + $client = $this->client; + $client->hasMany('tr', new Model\Transaction2()) + ->addField('balance', ['field' => 'amount', 'aggregate' => 'sum']); + + $this->assertTrue(true); // fake assert + //select "client"."id","client"."name",(select sum("val") from (select sum("amount") "val" from "invoice" where "client_id" = "client"."id" UNION ALL select sum("amount") "val" from "payment" where "client_id" = "client"."id") "derivedTable") "balance" from "client" where "client"."id" = 1 limit 0, 1 + //$c->load(1); + } +} diff --git a/tests/ReportTest.php b/tests/ReportTest.php new file mode 100644 index 000000000..395cd93c9 --- /dev/null +++ b/tests/ReportTest.php @@ -0,0 +1,57 @@ + [ + ['name' => 'Vinny'], + ['name' => 'Zoe'], + ], + 'invoice' => [ + ['client_id' => 1, 'name' => 'chair purchase', 'amount' => 4.0], + ['client_id' => 1, 'name' => 'table purchase', 'amount' => 15.0], + ['client_id' => 2, 'name' => 'chair purchase', 'amount' => 4.0], + ], + 'payment' => [ + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], + ]; + + /** @var Aggregate */ + protected $g; + + protected function setUp(): void + { + parent::setUp(); + $this->setDB($this->init_db); + + $m1 = new Model\Invoice($this->db); + $m1->getRef('client_id')->addTitle(); + $this->g = new Aggregate($m1); + $this->g->addField('client'); + } + + public function testAliasGroupSelect() + { + $g = $this->g; + + $g->groupBy(['client_id'], ['c' => ['count(*)', 'type' => 'integer']]); + + $this->assertSame( + [ + ['client' => 'Vinny', 'client_id' => '1', 'c' => 2], + ['client' => 'Zoe', 'client_id' => '2', 'c' => 1], + ], + $g->export() + ); + } +}