Skip to content

Add Model\AggregateModel model #817

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 81 commits into from
Apr 15, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
81 commits
Select commit Hold shift + click to select a range
690e1ca
[feature] introduce Model\Aggregate
georgehristov Dec 29, 2020
2ee790b
[update] avoid modifying existing model Client
georgehristov Dec 29, 2020
4a9f37c
[update] move setup of properties to constructor
georgehristov Dec 29, 2020
d566c62
[update] add docs
georgehristov Dec 29, 2020
de06f03
[update] use strong argument typing
georgehristov Dec 29, 2020
ae45471
[update] rename properties to intuitive names
georgehristov Dec 29, 2020
3dd1ab0
[update] phpdoc types
georgehristov Dec 29, 2020
5cb2bf8
[update] allow to unset Model::$id_field
georgehristov Dec 29, 2020
68d32d3
[update] use array_merge
georgehristov Dec 29, 2020
8c69320
[update] arguments and return types
georgehristov Dec 29, 2020
47fc81d
[update] tests
georgehristov Dec 29, 2020
adb57dc
[update] docs
georgehristov Dec 29, 2020
b6b0852
[update] add return type for Invoice
georgehristov Dec 29, 2020
a974658
[fix] phpstan issues
georgehristov Dec 29, 2020
a176a33
[update] optimize demo
georgehristov Dec 29, 2020
665eb86
[update] LIMIT functionality
georgehristov Dec 30, 2020
4e2221d
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Nov 6, 2021
ae0a56a
fix renamed TestCase class
mvorisek Nov 6, 2021
722cbdf
fix renamed atk4_money type
mvorisek Nov 6, 2021
1f25740
add missing void return type for test cases
mvorisek Nov 6, 2021
5c40110
fix renamespaced Dsql names
mvorisek Nov 6, 2021
d047cd5
fix stan
mvorisek Nov 6, 2021
cfae1b1
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Jan 5, 2022
ad9c90a
fix typo in comment
mvorisek Jan 5, 2022
41637d0
fix SQL render assertions
mvorisek Jan 5, 2022
a21ef63
fix renamed API
mvorisek Jan 5, 2022
25dc215
fix comparison/bind for atk4_money typed values for Sqlite
mvorisek Jan 5, 2022
f62907b
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Jan 6, 2022
0f9bbe8
fix tests by not enforcing use_table_prefixes
mvorisek Jan 5, 2022
e7639e8
workaround mysql server 8.0.27 bug in test
mvorisek Jan 6, 2022
787c5d9
fix doc cs
mvorisek Jan 6, 2022
564944c
fix/use non-aliased field name for update/delete where
mvorisek Jan 2, 2022
7353322
fix id typecasting in SQL persistence
mvorisek Jan 4, 2022
e0af1c5
dedup/impl insert/update/delete methods in main Persistence
mvorisek Jan 4, 2022
af5333b
impl raw write methods
mvorisek Jan 4, 2022
9dd61ba
add support for model nesting for SQL
mvorisek Jan 9, 2022
ddc2db3
fix join/hasMany
mvorisek Jan 9, 2022
bcd74c5
impl write queries using nested Models
mvorisek Jan 4, 2022
a4438ea
use "_tm" as default alias for model-in-model table
mvorisek Jan 9, 2022
79b2743
drop unneeded composer requirements
mvorisek Jan 9, 2022
96f6009
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Jan 10, 2022
9e95d1c
do not accept badly formatted seeds
mvorisek Jan 7, 2022
934041f
fix unordered export assertions
mvorisek Jan 10, 2022
8e563e5
fix expr seeding
mvorisek Jan 10, 2022
a883a75
allow to group by non-selecting expr, but then do not add that field
mvorisek Jan 10, 2022
58b83ee
fix grouping for PostgreSQL
mvorisek Jan 10, 2022
0540bf9
Merge branch 'model_in_model' into feature/introduce-aggregate-model
mvorisek Jan 10, 2022
035f36b
createInvoiceAggregate must not add default "client" field
mvorisek Jan 10, 2022
65ada49
impl Aggregate using model-in-model
mvorisek Jan 10, 2022
abfaabe
do not skip WITH test for MariaDB
mvorisek Jan 11, 2022
12c2c2a
add model-in-model tests incl. hooks
mvorisek Jan 11, 2022
aab0bf9
test with "_id" ID column name
mvorisek Jan 11, 2022
1a96086
fix different ID column across nested models
mvorisek Jan 11, 2022
d48a037
assert nested transactions
mvorisek Jan 11, 2022
e572ff6
Merge branch 'model_in_model' into feature/introduce-aggregate-model
mvorisek Jan 12, 2022
3f51e91
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Jan 12, 2022
a96f9d8
fixed mysql server 8.0.28 released
mvorisek Jan 19, 2022
755bf26
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Jan 21, 2022
081f694
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Jan 23, 2022
f451c5c
drop withAggregateField method
mvorisek Jan 23, 2022
e9edb8b
drop AggregatesTrait trait
mvorisek Jan 23, 2022
fcbb43c
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Jan 30, 2022
254bb67
rename Aggregate to AggregateModel
mvorisek Jan 30, 2022
cdbd07a
fix doc
mvorisek Jan 30, 2022
da5856f
rename groupBy to setGroupBy as it mutates the model
mvorisek Jan 30, 2022
25c92cb
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Mar 13, 2022
3db6a8a
adjust to latest develop
mvorisek Mar 14, 2022
9349c88
Merge branch 'develop' into feature/introduce-aggregate-model
mvorisek Apr 15, 2022
e2cd2e2
adjust to latest develop
mvorisek Apr 15, 2022
8ae2036
simplify
mvorisek Mar 21, 2022
9c9fa6f
fix cloned field - we should either disallow refs or wrap twice
mvorisek Mar 21, 2022
2da2f6a
compact assertSameExportUnordered assertions
mvorisek Apr 15, 2022
07d1838
fix array merge
mvorisek Mar 21, 2022
5d8ee39
fix count query
mvorisek Mar 21, 2022
431da2e
no false for initQueryFields
mvorisek Mar 21, 2022
1a821a8
fix cloned field - we should either disallow refs or wrap twice
mvorisek Mar 21, 2022
8e8bfd5
fix ii - do not clone field
mvorisek Mar 21, 2022
a46433d
fix unaggregated fields for pgsql
mvorisek Apr 15, 2022
95af333
compact test code
mvorisek Apr 15, 2022
5520638
rename hook const
mvorisek Apr 15, 2022
c6c8921
fix cs
mvorisek Apr 15, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Contents:
references
expressions
joins
aggregates
hooks
deriving
advanced
Expand Down
1 change: 1 addition & 0 deletions src/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Model implements \IteratorAggregate
use InitializerTrait {
init as _init;
}
use Model\AggregatesTrait;
use Model\JoinsTrait;
use Model\ReferencesTrait;
use Model\UserActionsTrait;
Expand Down
316 changes: 316 additions & 0 deletions src/Model/Aggregate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
<?php

declare(strict_types=1);

namespace Atk4\Data\Model;

use Atk4\Data\Exception;
use Atk4\Data\Field;
use Atk4\Data\FieldSqlExpression;
use Atk4\Data\Model;
use Atk4\Data\Persistence;
use Atk4\Data\Reference;
use Atk4\Dsql\Expression;
use Atk4\Dsql\Query;

/**
* Aggregate model allows you to query using "group by" clause on your existing model.
* It's quite simple to set up.
*
* $aggregate = new Aggregate($mymodel);
* $aggregate->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']];
*
* @property \Atk4\Data\Persistence\Sql $persistence
*
* @method Expression expr($expr, array $args = []) forwards to Persistence\Sql::expr using $this as model
*/
class Aggregate extends Model
{
/** @const string */
public const HOOK_INIT_SELECT_QUERY = self::class . '@initSelectQuery';

/** @var array */
protected $systemFields = [];

/** @var Model */
public $baseModel;

/**
* Aggregate model should always be read-only.
*
* @var bool
*/
public $read_only = true;

/**
* Aggregate does not have ID field.
*
* @var string
*/
public $id_field;

/** @var array */
public $group = [];

/** @var array */
public $aggregate = [];

/**
* Constructor.
*/
public function __construct(Model $baseModel, array $defaults = [])
{
if (!$baseModel->persistence instanceof Persistence\Sql) {
throw new Exception('Base model must have Sql persistence to use grouping');
}

$this->baseModel = clone $baseModel;
$this->table = $baseModel->table;

parent::__construct($baseModel->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 $fields Array of field names
* @param array $aggregate Array of aggregate mapping
*
* @return $this
*/
public function groupBy(array $fields, array $aggregate = []): Model
{
$this->group = $fields;
$this->aggregate = $aggregate;

$this->systemFields = array_unique($this->systemFields + $fields);
foreach ($fields as $fieldName) {
$this->addField($fieldName);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impossible to pass seed for these fields to define field type for example.
It could be in format ['fieldName' => ['type' => 'money']].

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I belive seed should be not allowed. Instead a "virtualized Field" from the source model must be used. Or the field must be added using regular addField method.

}

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->baseModel->hasField($fieldName)) {
$args = [$this->baseModel->getField($fieldName)];
}

$seed['expr'] = $this->baseModel->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->baseModel->getRef($link);
}

/**
* Method to enable commutative usage of methods enabling both of below
* Resulting in Aggregate on $model.
*
* $model->groupBy(['abc'])->withAggregateField('xyz');
*
* and
*
* $model->withAggregateField('xyz')->groupBy(['abc']);
*/
public function withAggregateField($name, $seed = []): Model
{
static::addField(...func_get_args());

return $this;
}

/**
* Adds new field into model.
*
* @param array|object $seed
*/
public function addField(string $name, $seed = []): Field
{
$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->baseModel->hasField($name)) {
$field = clone $this->baseModel->getField($name);
$field->unsetOwner(); // will be new owner
} else {
$field = null;
}

return $field
? parent::addField($name, $field)->setDefaults($seed)
: parent::addField($name, $seed);
}

public function setLimit(int $count = null, int $offset = 0)
{
$this->baseModel->setLimit($count, $offset);

return $this;
}

/**
* Execute action.
*
* @param string $mode
* @param array $args
*
* @return Query
*/
public function action($mode, $args = [])
{
switch ($mode) {
case 'select':
$fields = $this->only_fields ?: array_keys($this->getFields());

// select but no need your fields
$query = $this->baseModel->action($mode, [false]);

$this->initQueryFields($query, array_unique($fields + $this->systemFields));
$this->initQueryOrder($query);
$this->initQueryGrouping($query);
$this->initQueryConditions($query);

$this->hook(self::HOOK_INIT_SELECT_QUERY, [$query]);

return $query;
case 'count':
$query = $this->baseModel->action($mode, $args);

$query->reset('field')->field($this->expr('1'));
$this->initQueryGrouping($query);

$this->hook(self::HOOK_INIT_SELECT_QUERY, [$query]);

return $query->dsql()->field('count(*)')->table($this->expr('([]) der', [$query]));
case 'field':
case 'fx':
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if field should be allowed and also how fx works. Need to see some usecase example in SQL and tests.

return parent::action($mode, $args);
default:
throw (new Exception('Aggregate model does not support this action'))
->addMoreInfo('mode', $mode);
}
}

protected function initQueryFields(Query $query, array $fields = []): void
{
$this->persistence->initQueryFields($this, $query, $fields);
}

protected function initQueryOrder(Query $query): void
{
if ($this->order) {
foreach ($this->order as $order) {
$isDesc = strtolower($order[1]) === 'desc';

if ($order[0] instanceof Expression) {
$query->order($order[0], $isDesc);
} elseif (is_string($order[0])) {
$query->order($this->getField($order[0]), $isDesc);
} else {
throw (new Exception('Unsupported order parameter'))
->addMoreInfo('model', $this)
->addMoreInfo('field', $order[0]);
}
}
}
}

protected function initQueryGrouping(Query $query): void
{
// use table alias of base model
$this->table_alias = $this->baseModel->table_alias;

foreach ($this->group as $field) {
if ($this->baseModel->hasField($field)) {
$expression = $this->baseModel->getField($field);
} else {
$expression = $this->expr($field);
}

$query->group($expression);
}
}

protected 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->having(...$condition->toQueryArguments());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having is an interesting argument for Aggregate class, but we may use HAVING instead or WHERE when grouping is active in general (before we can analyse if HAVING is really needed)

Copy link
Member

@mvorisek mvorisek Dec 29, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok for now if we will integrate it for wrapping like now... But keep this unresolved in GH discussion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not if we wrap as subquery...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related with https://github.com/atk4/data/pull/853/files#diff-7e7810cb7d196a13e4c0b10d1c737e5ead3e3169bd82f6b4c1149dcf67d726f0R384 , I belive wrapping is the only solution, having requires group by across all columns for some DB vendors (even if grouped by some unique column)

Copy link
Member

@mvorisek mvorisek Jan 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO after #946 - traversing may be implemented using model wrapping

but it will require "virtualized Fields" - maybe we should always impl. different persistence fields using specific subobjects

}

// nested conditions
if ($condition instanceof Model\Scope) {
$expression = $condition->isOr() ? $query->orExpr() : $query->andExpr();

foreach ($condition->getNestedConditions() as $nestedCondition) {
$this->initQueryConditions($expression, $nestedCondition);
}

$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,
'baseModel' => $this->baseModel->__debugInfo(),
]);
}

// }}}
}
29 changes: 29 additions & 0 deletions src/Model/AggregatesTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Atk4\Data\Model;

use Atk4\Data\Model;

/**
* Provides aggregation methods.
*/
trait AggregatesTrait
{
/**
* @see Aggregate::withAggregateField.
*/
public function withAggregateField($name, $seed = []): Model
{
return (new Aggregate($this))->withAggregateField(...func_get_args());
}

/**
* @see Aggregate::groupBy.
*/
public function groupBy(array $group, array $aggregate = []): Model
{
return (new Aggregate($this))->groupBy(...func_get_args());
}
}
21 changes: 21 additions & 0 deletions tests/Model/Invoice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Atk4\Data\Tests\Model;

use Atk4\Data\Model;

class Invoice extends Model
{
public $table = 'invoice';

protected function init(): void
{
parent::init();
$this->addField('name');

$this->hasOne('client_id', ['model' => [Client::class, 'table' => 'client']]);
$this->addField('amount', ['type' => 'money']);
}
}
Loading