Skip to content

Commit

Permalink
Add Model\AggregateModel model (#817)
Browse files Browse the repository at this point in the history
Co-authored-by: Michael Voříšek <mvorisek@mvorisek.cz>
  • Loading branch information
georgehristov and mvorisek committed Apr 15, 2022
1 parent 3678980 commit 8b47b2d
Show file tree
Hide file tree
Showing 10 changed files with 600 additions and 17 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,16 +182,18 @@ $grid->setModel($data);
$html = $grid->render();
```

Or if you want to display them as a Chart, using https://github.com/atk4/chart and https://github.com/atk4/report
Or if you want to display them as a Chart using https://github.com/atk4/chart

``` php
$chart = new \Atk4\Chart\BarChart();
$data = new JobReport($db);

// BarChart wants aggregated data
$data->addExpression('month', ['expr' => 'month([date])']);
$aggregate = new \Atk4\Report\GroupModel($data);
$aggregate->groupBy('month', ['profit_margin' => 'sum']);
$aggregate = new AggregateModel($data);
$aggregate->setGroupBy(['month'], [
'profit_margin' => ['expr' => 'sum'],
]);

// associate presentation with data
$chart->setModel($aggregate, ['month', 'profit_margin']);
Expand Down
45 changes: 45 additions & 0 deletions docs/aggregates.rst
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'],
]);

3 changes: 1 addition & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ Contents:
references
expressions
joins
aggregates
hooks
deriving
advanced
extensions
persistence/csv



Indices and tables
==================

Expand Down
10 changes: 5 additions & 5 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,11 @@ https://github.com/atk4/report/blob/develop/src/GroupModel.php
This code is specific to SQL databases, but can be used with any Model, so in
order to use grouping with Agile Data, your code would be::

$m = new \Atk4\Report\GroupModel(new Sale($db));
$m->groupBy(['contractor_to', 'type'], [ // groups by 2 columns
'c' => 'count(*)', // defines aggregate formulas for fields
'qty' => 'sum([])', // [] refers back to qty
'total' => 'sum([amount])', // can specify any field here
$aggregate = new AggregateModel(new Sale($db));
$aggregate->setGroupBy(['contractor_to', 'type'], [ // groups by 2 columns
'c' => ['expr' => 'count(*)'], // defines aggregate formulas for fields
'qty' => ['expr' => 'sum([])'], // [] refers back to qty
'total' => ['expr' => 'sum([amount])'], // can specify any field here
]);


Expand Down
2 changes: 1 addition & 1 deletion src/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,7 @@ public function addWith(self $model, string $alias, array $mapping = [], bool $r
}

/**
* Set order for model records. Multiple calls.
* Set order for model records. Multiple calls are allowed.
*
* @param string|array $field
* @param string $direction "asc" or "desc"
Expand Down
177 changes: 177 additions & 0 deletions src/Model/AggregateModel.php
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,
]);
}
}
11 changes: 5 additions & 6 deletions src/Persistence/Sql.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,15 +258,10 @@ public function initField(Query $query, Field $field): void
/**
* Adds model fields in Query.
*
* @param array|false|null $fields
* @param array|null $fields
*/
public function initQueryFields(Model $model, Query $query, $fields = null): void
{
// do nothing on purpose
if ($fields === false) {
return;
}

// init fields
if (is_array($fields)) {
// Set of fields is strictly defined for purposes of export,
Expand Down Expand Up @@ -661,6 +656,10 @@ public function getFieldSqlExpression(Field $field, Expression $expression): Exp

public function lastInsertId(Model $model): string
{
if (is_object($model->table)) {
throw new \Error('Table must be a string');
}

// PostgreSQL and Oracle DBAL platforms use sequence internally for PK autoincrement,
// use default name if not set explicitly
$sequenceName = null;
Expand Down
2 changes: 2 additions & 0 deletions tests/Model/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

class Client extends User
{
public $table = 'client';

protected function init(): void
{
parent::init();
Expand Down
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->hasOne('client_id', ['model' => [Client::class]]);
$this->addField('name');
$this->addField('amount', ['type' => 'atk4_money']);
}
}

0 comments on commit 8b47b2d

Please sign in to comment.