diff --git a/docs/index.md b/docs/index.md index 8e5bffae9..8def376e6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ static references expressions joins +unions aggregates hooks deriving diff --git a/docs/unions.md b/docs/unions.md new file mode 100644 index 000000000..b55517fe0 --- /dev/null +++ b/docs/unions.md @@ -0,0 +1,119 @@ +:::{php:namespace} Atk4\Data +::: + +(Unions)= + +# Model Unions + +:::{php:class} Model\UnionModel +::: + +In some cases data from multiple models need to be combined. In this case the UnionModel model comes very handy. +In the case used below Client model schema may have multiple invoices and multiple payments. Payment is not related to the invoice.: + +``` +class Client extends \Atk4\Data\Model { + public $table = 'client'; + + protected function init(): void + { + parent::init(); + + $this->addField('name'); + + $this->hasMany('Payment'); + $this->hasMany('Invoice'); + } +} +``` + +(see tests/ModelUnionTest.php, tests/Model/Client.php, tests/Model/Payment.php and tests/Model/Invoice.php files). + +## Union Model Definition + +Normally a model is associated with a single table. Union model can have multiple nested models defined and it fetches +results from that. As a result, Union model will have no "id" field. Below is an example of inline definition of Union model. +The Union model can be separated in a designated class and nested model added within the init() method body of the new class: + +``` +$unionPaymentInvoice = new \Atk4\Data\Model\UnionModel(); + +$nestedInvoice = $unionPaymentInvoice->addNestedModel(new Invoice()); +$nestedPayment = $unionPaymentInvoice->addNestedModel(new Payment()); +``` + +Next, assuming that both models have common fields "name" and "amount", `$unionPaymentInvoice` fields can be set: + +``` +$unionPaymentInvoice->addField('name'); +$unionPaymentInvoice->addField('amount', ['type' => 'atk4_money']); +``` + +Then data can be queried: + +``` +$unionPaymentInvoice->export(); +``` + +## Define Fields + +Below is an example of 3 different ways to define fields for the UnionModel model: + +``` +// will link the "name" field with all the nested models +$unionPaymentInvoice->addField('client_id'); + +// Expression will not affect nested models in any way +$unionPaymentInvoice->addExpression('name_capital', ['expr' => 'upper([name])']); + +// UnionModel model can be joined with extra tables and define some fields from those joins +$unionPaymentInvoice + ->join('client', 'client_id') + ->addField('client_name', 'name'); +``` + +{ref}`Expressions` and {ref}`Joins` are working just as they would on any other model. + +## Field Mapping + +Sometimes the field that is defined in the UnionModel model may be named differently inside nested models. +E.g. Invoice has field "description" and payment has field "note". +When defining a nested model a field map array needs to be specified: + +``` +$nestedInvoice = $unionPaymentInvoice->addNestedModel(new Invoice()); +$nestedPayment = $unionPaymentInvoice->addNestedModel(new Payment(), ['description' => '[note]']); +$unionPaymentInvoice->addField('description'); +``` + +The key of the field map array must match the UnionModel field. The value is an expression. (See {php:meth}`Model::addExpression`). +This format can also be used to reverse sign on amounts. When we are creating "Transactions", then invoices would be +subtracted from the amount, while payments will be added: + +``` +$nestedInvoice = $mUnion->addNestedModel(new Invoice(), ['amount' => '-[amount]']); +$nestedPayment = $mUnion->addNestedModel(new Payment(), ['description' => '[note]']); +$unionPaymentInvoice->addField('description'); +``` + +Should more flexibility be needed, more expressions (or fields) can be added directly to nested models: + +``` +$nestedInvoice = $unionPaymentInvoice->addNestedModel(new Invoice(), ['amount' => '-[amount]']); +$nestedPayment = $unionPaymentInvoice->addNestedModel(new Payment(), ['description' => '[note]']); + +$nestedInvoice->addExpression('type', ['expr' => '\'invoice\'']); +$nestedPayment->addExpression('type', ['expr' => '\'payment\'']); +$unionPaymentInvoice->addField('type'); +``` + +A new field "type" has been added that will be defined as a static constant. + +## Referencing an UnionModel Model + +Like any other model, UnionModel model can be assigned through a reference. In the case here one Client can have multiple transactions. +Initially a related union can be defined: + +``` +$client->hasMany('Transaction', new Transaction()); +``` diff --git a/src/Model/UnionInternalTable.php b/src/Model/UnionInternalTable.php new file mode 100644 index 000000000..8391477be --- /dev/null +++ b/src/Model/UnionInternalTable.php @@ -0,0 +1,46 @@ +actionInnerTable(). + * + * Called from https://github.com/atk4/data/blob/5.0.0/src/Persistence/Sql.php#L188. + * + * @method Model getOwner() + * + * @internal + */ +class UnionInternalTable +{ + use TrackableTrait; + + /** + * @param array $args + * + * @return Persistence\Sql\Query + */ + public function action(string $mode, array $args = []) + { + if ($mode !== 'select' || $args !== []) { + throw new Exception('Only "select" action with empty arguments is expected'); + } + + $model = $this->getOwner(); + + $tableOrig = $model->table; + $model->table = '_tu'; + try { + return $model->actionSelectInnerTable(); // @phpstan-ignore method.notFound + } finally { + $model->table = $tableOrig; + } + } +} diff --git a/src/Model/UnionModel.php b/src/Model/UnionModel.php new file mode 100644 index 000000000..ad1748137 --- /dev/null +++ b/src/Model/UnionModel.php @@ -0,0 +1,228 @@ + $arguments = []) forwards to Persistence\Sql::expr using $this as model + */ +class UnionModel extends Model +{ + public const HOOK_INIT_UNION_SELECT_QUERY = self::class . '@initUnionSelectQuery'; + + /** UnionModel should always be read-only */ + public bool $readOnly = true; + + /** + * UnionModel normally does not have ID field. Setting this to false will + * disable various per-id operations, such as load(). + * + * If you can define unique ID field, you can specify it inside your + * union model. + */ + public $idField = false; + + /** @var list}> */ + public $union = []; + + /** + * @param array $defaults + */ + public function __construct(?Persistence $persistence = null, array $defaults = []) + { + $unionTable = new UnionInternalTable(); + $unionTable->setOwner($this); + $this->table = $unionTable; // @phpstan-ignore assign.propertyType + + $this->tableAlias ??= '_tu'; // DEBUG + + parent::__construct($persistence, $defaults); + } + + /** + * For a sub-model with a specified mapping, return expression + * that represents a field. + * + * @return Field|Persistence\Sql\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; + } + + /** + * Adds nested model in union. + * + * @param array $fieldMap + */ + public function addNestedModel(Model $model, array $fieldMap = []): Model + { + $model->setPersistence($this->getPersistence()); // TODO this must be removed + + $this->union[] = [$model, $fieldMap]; + + return $model; // TODO nothing/void should be returned + } + + /** + * @return Persistence\Sql\Query + */ + public function actionSelectInnerTable() + { + return $this->createSubquery(null); + } + + #[\Override] + public function action(string $mode, array $args = []) + { + $subquery = null; + switch ($mode) { + case 'select': + $subquery = $this->createSubquery(null); + $query = parent::action($mode, $args)->reset('table')->table($subquery, $this->tableAlias); + + $this->hook(self::HOOK_INIT_UNION_SELECT_QUERY, [$query]); + + return $query; + case 'count': + $mode = 'fx'; + $args = ['sum', $this->expr('{}', ['cnt'])]; + + $subqueries = []; + foreach ($this->union as [$model]) { + $query = $model->action('count', ['alias' => 'cnt']); + $query->wrapInParentheses = false; + $subqueries[] = $query; + } + + $subquery = $this->createUnionQuery($subqueries); + + break; + case 'field': + $subquery = $this->createSubquery([$args[0]]); + + break; + case 'fx': + case 'fx0': + return parent::action($mode, $args); + default: + throw (new Exception('UnionModel model does not support this action')) + ->addMoreInfo('mode', $mode); + } + + $query = parent::action($mode, $args) + ->reset('table')->table($subquery, $this->tableAlias); + + return $query; + } + + /** + * @param list $subqueries + */ + private function createUnionQuery(array $subqueries): Persistence\Sql\Query + { + $unionQuery = $this->getPersistence()->dsql(); + $unionQuery->mode = 'union_all'; + \Closure::bind(static function () use ($unionQuery, $subqueries) { + $unionQuery->template = implode(' UNION ALL ', array_fill(0, count($subqueries), '[]')); + }, null, Persistence\Sql\Query::class)(); + $unionQuery->args['custom'] = $subqueries; + + return $unionQuery; + } + + /** + * Configures nested models to have a specified set of fields available. + * + * @param list $fields + */ + public function createSubquery(?array $fields): Persistence\Sql\Query + { + if ($fields === null) { + $fields = $this->onlyFields ?? array_keys($this->getFields()); + foreach ($fields as $k => $field) { + if ($this->getField($field)->neverPersist) { + unset($fields[$k]); + } + } + } + + $subqueries = []; + foreach ($this->union as [$nestedModel, $fieldMap]) { + // map fields for related model + $queryFieldExpressions = []; + foreach ($fields as $fieldName) { + if (!$this->hasField($fieldName)) { + $queryFieldExpressions[$fieldName] = $nestedModel->expr('NULL'); + + continue; + } + + $field = $this->getField($fieldName); + + if ($field->hasJoin() || $field->neverPersist) { + continue; + } + + // UnionModel 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 ($field instanceof SqlExpressionField /* && !isset($this->aggregate[$fieldName]) */) { + continue; + } + + $fieldExpression = $this->getFieldExpr($nestedModel, $fieldName, $fieldMap[$fieldName] ?? null); + + $queryFieldExpressions[$fieldName] = $fieldExpression; + } + + $query = $this->getPersistence()->action($nestedModel, 'select', [[]]); + $query->wrapInParentheses = false; + + foreach ($queryFieldExpressions as $fAlias => $fExpr) { + $query->field($fExpr, $fAlias); + } + + $subqueries[] = $query; + } + + $unionQuery = $this->createUnionQuery($subqueries); + + return $unionQuery; + } + + #[\Override] + public function __debugInfo(): array + { + return array_merge(parent::__debugInfo(), [ + 'unionModels' => $this->union, + ]); + } +} diff --git a/tests/Model/Payment.php b/tests/Model/Payment.php new file mode 100644 index 000000000..ff1f47ab6 --- /dev/null +++ b/tests/Model/Payment.php @@ -0,0 +1,22 @@ +hasOne('client_id', ['model' => [Client::class]]); + $this->addField('name'); + $this->addField('amount', ['type' => 'atk4_money']); + } +} diff --git a/tests/Model/Transaction.php b/tests/Model/Transaction.php new file mode 100644 index 000000000..7f829002c --- /dev/null +++ b/tests/Model/Transaction.php @@ -0,0 +1,35 @@ +nestedInvoice = new Invoice(); + $this->addNestedModel($this->nestedInvoice, $this->subtractInvoice ? ['amount' => '-[]'] : []); + $this->nestedPayment = new Payment(); + $this->addNestedModel($this->nestedPayment); + + // next, define common fields + $this->hasOne('client_id', ['model' => [Client::class]]); + $this->addField('name'); + $this->addField('amount', ['type' => 'atk4_money']); + } +} diff --git a/tests/ModelUnionTest.php b/tests/ModelUnionTest.php new file mode 100644 index 000000000..486477348 --- /dev/null +++ b/tests/ModelUnionTest.php @@ -0,0 +1,411 @@ +setDb([ + 'client' => [ + // allow of migrator to create all columns + ['name' => 'Vinny', 'surname' => null, 'order' => null], + ['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 createTransaction(): Model\Transaction + { + return new Model\Transaction($this->db); + } + + protected function createSubtractInvoiceTransaction(): Model\Transaction + { + return new Model\Transaction($this->db, ['subtractInvoice' => true]); + } + + protected function createClient(): Model\Client + { + $client = new Model\Client($this->db); + + $client->hasMany('Payment', ['model' => [Model\Payment::class]]); + $client->hasMany('Invoice', ['model' => [Model\Invoice::class]]); + + return $client; + } + + public function testFieldExpr(): void + { + $transaction = $this->createSubtractInvoiceTransaction(); + + $this->assertSameSql('`amount`', $transaction->expr('[]', [$transaction->getFieldExpr($transaction->nestedInvoice, 'amount')])->render()[0]); + $this->assertSameSql('-`amount`', $transaction->expr('[]', [$transaction->getFieldExpr($transaction->nestedInvoice, 'amount', '-[]')])->render()[0]); + $this->assertSameSql('-NULL', $transaction->expr('[]', [$transaction->getFieldExpr($transaction->nestedInvoice, 'blah', '-[]')])->render()[0]); + } + + public function testCreateSubqueryBasic(): void + { + $transaction = $this->createTransaction(); + + $this->assertSameSql( + 'select `name` `name` from `invoice` UNION ALL select `name` `name` from `payment`', + $transaction->createSubquery(['name'])->render()[0] + ); + + $this->assertSameSql( + 'select `name` `name`, `amount` `amount` from `invoice` UNION ALL select `name` `name`, `amount` `amount` from `payment`', + $transaction->createSubquery(['name', 'amount'])->render()[0] + ); + + $this->assertSameSql( + 'select `name` `name` from `invoice` UNION ALL select `name` `name` from `payment`', + $transaction->createSubquery(['name'])->render()[0] + ); + } + + /** + * If field is not set for one of the nested model, instead of generating exception, NULL will be filled in. + */ + public function testCreateSubqueryMissingField(): void + { + $transaction = $this->createTransaction(); + $transaction->nestedInvoice->addExpression('type', ['expr' => '\'invoice\'']); + $transaction->addField('type'); + + $this->assertSameSql( + 'select (\'invoice\') `type`, `amount` `amount` from `invoice` UNION ALL select NULL `type`, `amount` `amount` from `payment`', + $transaction->createSubquery(['type', 'amount'])->render()[0] + ); + } + + public function testActions(): void + { + $transaction = $this->createTransaction(); + + $this->assertSameSql( + 'select `client_id`, `name`, `amount` from (select `client_id` `client_id`, `name` `name`, `amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_tu`', + $transaction->action('select')->render()[0] + ); + + $this->assertSameSql( + 'select `name` from (select `name` `name` from `invoice` UNION ALL select `name` `name` from `payment`) `_tu`', + $transaction->action('field', ['name'])->render()[0] + ); + + $this->assertSameSql( + 'select sum(`cnt`) from (select count(*) `cnt` from `invoice` UNION ALL select count(*) `cnt` from `payment`) `_tu`', + $transaction->action('count')->render()[0] + ); + + $this->assertSameSql( + 'select sum(`amount`) from (select `client_id` `client_id`, `name` `name`, `amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_tu`', + $transaction->action('fx', ['sum', 'amount'])->render()[0] + ); + + $transaction = $this->createSubtractInvoiceTransaction(); + + $this->assertSameSql( + 'select sum(`amount`) from (select `client_id` `client_id`, `name` `name`, -`amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_tu`', + $transaction->action('fx', ['sum', 'amount'])->render()[0] + ); + } + + public function testActions2(): void + { + $transaction = $this->createTransaction(); + self::assertSame('5', $transaction->action('count')->getOne()); + self::assertSame(37.0, (float) $transaction->action('fx', ['sum', 'amount'])->getOne()); + + $transaction = $this->createSubtractInvoiceTransaction(); + self::assertSame(-9.0, (float) $transaction->action('fx', ['sum', 'amount'])->getOne()); + } + + public function testBasics(): void + { + $client = $this->createClient(); + + // There are total of 2 clients + self::assertSame('2', $client->action('count')->getOne()); + + // Client with ID=1 has invoices for 19 + self::assertSame(19.0, (float) $client->load(1)->ref('Invoice')->action('fx', ['sum', 'amount'])->getOne()); + + $transaction = $this->createTransaction(); + + self::assertSameExportUnordered([ + ['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], + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], $transaction->export()); + + // Transaction is UnionModel Model + $client->hasMany('Transaction', ['model' => $transaction]); + + self::assertSameExportUnordered([ + ['client_id' => 1, 'name' => 'chair purchase', 'amount' => 4.0], + ['client_id' => 1, 'name' => 'table purchase', 'amount' => 15.0], + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ], $client->load(1)->ref('Transaction')->export()); + + $client = $this->createClient(); + + $transaction = $this->createSubtractInvoiceTransaction(); + + self::assertSameExportUnordered([ + ['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], + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], $transaction->export()); + + // Transaction is UnionModel Model + $client->hasMany('Transaction', ['model' => $transaction]); + + self::assertSameExportUnordered([ + ['client_id' => 1, 'name' => 'chair purchase', 'amount' => -4.0], + ['client_id' => 1, 'name' => 'table purchase', 'amount' => -15.0], + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ], $client->load(1)->ref('Transaction')->export()); + } + + public function testGrouping1(): void + { + $transaction = $this->createTransaction(); + + $transactionAggregate = new AggregateModel($transaction); + $transactionAggregate->setGroupBy(['name'], [ + 'amount' => ['expr' => 'sum([amount])', 'type' => 'atk4_money'], + ]); + + $this->assertSameSql( + 'select `name`, sum(`amount`) `amount` from (select `client_id`, `name`, `amount` from (select `client_id` `client_id`, `name` `name`, `amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_tu`) `_tm` group by `name`', + $transactionAggregate->action('select', [['name', 'amount']])->render()[0] + ); + + $transaction = $this->createSubtractInvoiceTransaction(); + + $transactionAggregate = new AggregateModel($transaction); + $transactionAggregate->setGroupBy(['name'], [ + 'amount' => ['expr' => 'sum([])', 'type' => 'atk4_money'], + ]); + + $this->assertSameSql( + 'select `name`, sum(`amount`) `amount` from (select `client_id`, `name`, `amount` from (select `client_id` `client_id`, `name` `name`, -`amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_tu`) `_tm` group by `name`', + $transactionAggregate->action('select', [['name', 'amount']])->render()[0] + ); + } + + /** + * 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 testGrouping2(): void + { + $transaction = $this->createTransaction(); + $transaction->removeField('client_id'); + $transaction->setOrder('name'); + + $transactionAggregate = new AggregateModel($transaction); + $transactionAggregate->setGroupBy(['name'], [ + 'amount' => ['expr' => 'sum([amount])', 'type' => 'atk4_money'], + ]); + $transactionAggregate->setOrder('name'); + + self::assertSame([ + ['name' => 'chair purchase', 'amount' => 8.0], + ['name' => 'full pay', 'amount' => 4.0], + ['name' => 'prepay', 'amount' => 10.0], + ['name' => 'table purchase', 'amount' => 15.0], + ], $transactionAggregate->export()); + + $transaction = $this->createSubtractInvoiceTransaction(); + $transaction->removeField('client_id'); + $transaction->setOrder('name'); + + $transactionAggregate = new AggregateModel($transaction); + $transactionAggregate->setGroupBy(['name'], [ + 'amount' => ['expr' => 'sum([])', 'type' => 'atk4_money'], + ]); + $transactionAggregate->setOrder('name'); + + self::assertSame([ + ['name' => 'chair purchase', 'amount' => -8.0], + ['name' => 'full pay', 'amount' => 4.0], + ['name' => 'prepay', 'amount' => 10.0], + ['name' => 'table purchase', 'amount' => -15.0], + ], $transactionAggregate->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(): void + { + $transaction = $this->createTransaction(); + $transaction->nestedInvoice->addExpression('type', ['expr' => '\'invoice\'']); + $transaction->nestedPayment->addExpression('type', ['expr' => '\'payment\'']); + $transaction->addField('type'); + + $transactionAggregate = new AggregateModel($transaction); + $transactionAggregate->setGroupBy(['type'], [ + 'amount' => ['expr' => 'sum([amount])', 'type' => 'atk4_money'], + ]); + + // TODO subselects should not select "client" and "name" fields + $this->assertSameSql( + 'select `type`, sum(`amount`) `amount` from (select `client_id`, `name`, `amount`, `type` from (select `client_id` `client_id`, `name` `name`, `amount` `amount`, (\'invoice\') `type` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount`, (\'payment\') `type` from `payment`) `_tu`) `_tm` group by `type`', + $transactionAggregate->action('select')->render()[0] + ); + + self::assertSameExportUnordered([ + ['type' => 'invoice', 'amount' => 23.0], + ['type' => 'payment', 'amount' => 14.0], + ], $transactionAggregate->export(['type', 'amount'])); + + $transaction = $this->createSubtractInvoiceTransaction(); + $transaction->nestedInvoice->addExpression('type', ['expr' => '\'invoice\'']); + $transaction->nestedPayment->addExpression('type', ['expr' => '\'payment\'']); + $transaction->addField('type'); + + $transactionAggregate = new AggregateModel($transaction); + $transactionAggregate->setGroupBy(['type'], [ + 'amount' => ['expr' => 'sum([])', 'type' => 'atk4_money'], + ]); + + self::assertSameExportUnordered([ + ['type' => 'invoice', 'amount' => -23.0], + ['type' => 'payment', 'amount' => 14.0], + ], $transactionAggregate->export(['type', 'amount'])); + } + + public function testReference(): void + { + $client = $this->createClient(); + $client->hasMany('tr', ['model' => $this->createTransaction()]); + + self::assertSame(19.0, (float) $client->load(1)->ref('Invoice')->action('fx', ['sum', 'amount'])->getOne()); + self::assertSame(10.0, (float) $client->load(1)->ref('Payment')->action('fx', ['sum', 'amount'])->getOne()); + + self::assertSame(29.0, (float) $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->getOne()); + + $this->assertSameSql( + 'select sum(`amount`) from (select `client_id` `client_id`, `name` `name`, `amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_t_e7d707a26e7f` where `client_id` = :a', + $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->render()[0] + ); + + $client = $this->createClient(); + $client->hasMany('tr', ['model' => $this->createSubtractInvoiceTransaction()]); + + self::assertSame(19.0, (float) $client->load(1)->ref('Invoice')->action('fx', ['sum', 'amount'])->getOne()); + self::assertSame(10.0, (float) $client->load(1)->ref('Payment')->action('fx', ['sum', 'amount'])->getOne()); + self::assertSame(-9.0, (float) $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->getOne()); + + $this->assertSameSql( + 'select sum(`amount`) from (select `client_id` `client_id`, `name` `name`, -`amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_t_e7d707a26e7f` where `client_id` = :a', + $client->load(1)->ref('tr')->action('fx', ['sum', 'amount'])->render()[0] + ); + } + + public function testFieldAggregateUnion(): void + { + $client = $this->createClient(); + $client->hasMany('tr', ['model' => $this->createTransaction()]) + ->addField('balance', ['field' => 'amount', 'aggregate' => 'sum', 'type' => 'float']); + + self::assertSameExportUnordered([ + ['id' => 1, 'name' => 'Vinny', 'surname' => null, 'order' => null, 'balance' => 29.0], + ['id' => 2, 'name' => 'Zoe', 'surname' => null, 'order' => null, 'balance' => 8.0], + ], $client->export()); + + self::assertSame(['id' => 1, 'name' => 'Vinny', 'surname' => null, 'order' => null, 'balance' => 29.0], $client->load(1)->get()); + self::assertSame(['id' => 2, 'name' => 'Zoe', 'surname' => null, 'order' => null, 'balance' => 8.0], $client->load(2)->get()); + self::assertNull($client->tryLoad(3)); + + $this->assertSameSql( + 'select `id`, `name`, `surname`, `order`, (select coalesce(sum(`amount`), 0) from (select `client_id` `client_id`, `name` `name`, `amount` `amount` from `invoice` UNION ALL select `client_id` `client_id`, `name` `name`, `amount` `amount` from `payment`) `_t_e7d707a26e7f` where `client_id` = `client`.`id`) `balance` from `client`', + $client->action('select')->render()[0] + ); + } + + public function testConditionOnUnionField(): void + { + $transaction = $this->createSubtractInvoiceTransaction(); + $transaction->addCondition('amount', '<', 0); + + self::assertSameExportUnordered([ + ['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], + ], $transaction->export()); + } + + public function testConditionOnNestedModelField(): void + { + $transaction = $this->createSubtractInvoiceTransaction(); + $transaction->addCondition('client_id', '>', 1); + + self::assertSameExportUnordered([ + ['client_id' => 2, 'name' => 'chair purchase', 'amount' => -4.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], $transaction->export()); + } + + public function testConditionExpression(): void + { + $transaction = $this->createSubtractInvoiceTransaction(); + $transaction->addCondition($transaction->expr('{} > 5', ['amount'])); + + self::assertSameExportUnordered([ + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ], $transaction->export()); + } + + /** + * Model's conditions can still be placed on the original field values. + */ + public function testConditionOnMappedField(): void + { + $transaction = $this->createSubtractInvoiceTransaction(); + $transaction->nestedInvoice->addCondition('amount', 4); + + self::assertSameExportUnordered([ + ['client_id' => 1, 'name' => 'chair purchase', 'amount' => -4.0], + ['client_id' => 2, 'name' => 'chair purchase', 'amount' => -4.0], + ['client_id' => 1, 'name' => 'prepay', 'amount' => 10.0], + ['client_id' => 2, 'name' => 'full pay', 'amount' => 4.0], + ], $transaction->export()); + } + + public function testUnionInternalTableActionException(): void + { + $unionInternalTable = new UnionInternalTable(); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Only "select" action with empty arguments is expected'); + $unionInternalTable->action('count'); + } +}