-
Notifications
You must be signed in to change notification settings - Fork 46
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add
Model\AggregateModel
model (#817)
Co-authored-by: Michael Voříšek <mvorisek@mvorisek.cz>
- Loading branch information
1 parent
3678980
commit 8b47b2d
Showing
10 changed files
with
600 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
|
||
.. _Aggregates: | ||
|
||
================ | ||
Model Aggregates | ||
================ | ||
|
||
.. php:namespace:: Atk4\Data\Model | ||
.. php:class:: AggregateModel | ||
In order to create model aggregates the AggregateModel model needs to be used: | ||
|
||
Grouping | ||
-------- | ||
|
||
AggregateModel model can be used for grouping:: | ||
|
||
$aggregate = new AggregateModel($orders)->setGroupBy(['country_id']); | ||
|
||
`$aggregate` above is a new object that is most appropriate for the model's persistence and which can be manipulated | ||
in various ways to fine-tune aggregation. Below is one sample use:: | ||
|
||
$aggregate = new AggregateModel($orders); | ||
$aggregate->addField('country'); | ||
$aggregate->setGroupBy(['country_id'], [ | ||
'count' => ['expr' => 'count(*)', 'type' => 'integer'], | ||
'total_amount' => ['expr' => 'sum([amount])', 'type' => 'atk4_money'], | ||
], | ||
); | ||
|
||
// $aggregate will have following rows: | ||
// ['country' => 'UK', 'count' => 20, 'total_amount' => 123.20]; | ||
// .. | ||
|
||
Below is how opening balance can be built:: | ||
|
||
$ledger = new GeneralLedger($db); | ||
$ledger->addCondition('date', '<', $from); | ||
|
||
// we actually need grouping by nominal | ||
$ledger->setGroupBy(['nominal_id'], [ | ||
'opening_balance' => ['expr' => 'sum([amount])', 'type' => 'atk4_money'], | ||
]); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Atk4\Data\Model; | ||
|
||
use Atk4\Data\Exception; | ||
use Atk4\Data\Field; | ||
use Atk4\Data\Field\SqlExpressionField; | ||
use Atk4\Data\Model; | ||
use Atk4\Data\Persistence; | ||
use Atk4\Data\Persistence\Sql\Expression; | ||
use Atk4\Data\Persistence\Sql\Query; | ||
|
||
/** | ||
* AggregateModel model allows you to query using "group by" clause on your existing model. | ||
* It's quite simple to set up. | ||
* | ||
* $aggregate = new AggregateModel($mymodel); | ||
* $aggregate->setGroupBy(['first', 'last'], [ | ||
* 'salary' => ['expr' => 'sum([])', 'type' => 'atk4_money'], | ||
* ]; | ||
* | ||
* 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. | ||
* | ||
* @property Persistence\Sql $persistence | ||
* @property Model $table | ||
* | ||
* @method Expression expr($expr, array $args = []) forwards to Persistence\Sql::expr using $this as model | ||
*/ | ||
class AggregateModel extends Model | ||
{ | ||
/** @const string */ | ||
public const HOOK_INIT_AGGREGATE_SELECT_QUERY = self::class . '@initAggregateSelectQuery'; | ||
|
||
/** @var array<int, string|Expression> */ | ||
public $groupByFields = []; | ||
|
||
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->table = $baseModel; | ||
|
||
// this model should always be read-only and does not have ID field | ||
$this->read_only = true; | ||
$this->id_field = null; | ||
|
||
parent::__construct($baseModel->persistence, $defaults); | ||
} | ||
|
||
/** | ||
* Specify a single field or array of fields on which we will group model. Multiple calls are allowed. | ||
* | ||
* @param array<string, array|object> $aggregateExpressions Array of aggregate expressions with alias as key | ||
* | ||
* @return $this | ||
*/ | ||
public function setGroupBy(array $fields, array $aggregateExpressions = []) | ||
{ | ||
$this->groupByFields = array_unique(array_merge($this->groupByFields, $fields)); | ||
|
||
foreach ($fields as $fieldName) { | ||
if ($fieldName instanceof Expression || $this->hasField($fieldName)) { | ||
continue; | ||
} | ||
|
||
$this->addField($fieldName); | ||
} | ||
|
||
foreach ($aggregateExpressions as $name => $seed) { | ||
$exprArgs = []; | ||
// if field is defined in the parent model then it can be used in expression | ||
if ($this->table->hasField($name)) { | ||
$exprArgs = [$this->table->getField($name)]; | ||
} | ||
|
||
$seed['expr'] = $this->table->expr($seed['expr'], $exprArgs); | ||
|
||
$this->addExpression($name, $seed); | ||
} | ||
|
||
return $this; | ||
} | ||
|
||
public function addField(string $name, $seed = []): Field | ||
{ | ||
if ($seed instanceof SqlExpressionField) { | ||
return parent::addField($name, $seed); | ||
} | ||
|
||
if ($this->table->hasField($name)) { | ||
$innerField = $this->table->getField($name); | ||
$seed['type'] ??= $innerField->type; | ||
$seed['enum'] ??= $innerField->enum; | ||
$seed['values'] ??= $innerField->values; | ||
$seed['caption'] ??= $innerField->caption; | ||
$seed['ui'] ??= $innerField->ui; | ||
} | ||
|
||
return parent::addField($name, $seed); | ||
} | ||
|
||
/** | ||
* @return Query | ||
*/ | ||
public function action(string $mode, array $args = []) | ||
{ | ||
switch ($mode) { | ||
case 'select': | ||
$fields = $args[0] ?? array_unique(array_merge( | ||
$this->onlyFields ?: array_keys($this->getFields()), | ||
array_filter($this->groupByFields, fn ($v) => !$v instanceof Expression) | ||
)); | ||
|
||
$query = parent::action($mode, [[]]); | ||
if (isset($query->args['where'])) { | ||
$query->args['having'] = $query->args['where']; | ||
unset($query->args['where']); | ||
} | ||
|
||
$this->persistence->initQueryFields($this, $query, $fields); | ||
$this->initQueryGrouping($query); | ||
|
||
$this->hook(self::HOOK_INIT_AGGREGATE_SELECT_QUERY, [$query]); | ||
|
||
return $query; | ||
case 'count': | ||
$innerQuery = $this->action('select', [[]]); | ||
$innerQuery->reset('field')->field($this->expr('1')); | ||
|
||
$query = $innerQuery->dsql() | ||
->field('count(*)', $args['alias'] ?? null) | ||
->table($this->expr('([]) {}', [$innerQuery, '_tc'])); | ||
|
||
return $query; | ||
// case 'field': | ||
// case 'fx': | ||
// case 'fx0': | ||
// return parent::action($mode, $args); | ||
default: | ||
throw (new Exception('AggregateModel model does not support this action')) | ||
->addMoreInfo('mode', $mode); | ||
} | ||
} | ||
|
||
protected function initQueryGrouping(Query $query): void | ||
{ | ||
foreach ($this->groupByFields as $field) { | ||
if ($field instanceof Expression) { | ||
$expression = $field; | ||
} else { | ||
$expression = $this->table->getField($field)->shortName /* TODO shortName should be used by DSQL automatically when in GROUP BY, HAVING, ... */; | ||
} | ||
|
||
$query->group($expression); | ||
} | ||
} | ||
|
||
public function __debugInfo(): array | ||
{ | ||
return array_merge(parent::__debugInfo(), [ | ||
'groupByFields' => $this->groupByFields, | ||
]); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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->hasOne('client_id', ['model' => [Client::class]]); | ||
$this->addField('name'); | ||
$this->addField('amount', ['type' => 'atk4_money']); | ||
} | ||
} |
Oops, something went wrong.