-
Notifications
You must be signed in to change notification settings - Fork 48
Add JSON aggregate support for HasMany references #1286
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
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,9 @@ | |
|
|
||
| /** | ||
| * Perform query operation on SQL server (such as select, insert, delete, etc). | ||
| * | ||
| * @method Expression fxJsonObject(array<string, Expressionable> $keyValuePairs) | ||
| * @method Expression jsonArrayAgg(Expressionable $expr) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This functionality has already #1268 PR but it is buggy on MySQL & MariaDB. In MariaDB it is a confirmed bug. In MySQL I did not investigate it in depth yet. Maybe it depends on configuration option. The PR should be merged first and we should not integrate it before MySQL/MariaDB is either fixed or we can always throw an error if and only if the bug is applicable. I see you are interested in HasMany To better understand your needs, what are your usecases for this PR?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Well, good low-code design should have nice, short methods! :)
Example: movie database https://raw.githubusercontent.com/geldata/imdbench/master/docs/schema.png Task: Get data for a movie page
This is difficult or impossible to achieve without JSON aggregation. With this PR, I can accomplish this: class Movie extends \App\Core\Model {
protected function init(): void {
...
$this->hasMany('directors', ['model' => function ($persistence) {
$model = new Directors($persistence);
$model->join('person');
return $model;
}, 'theirField' => 'movie_id'])
->addField('directors_json', [
'aggregate' => 'json',
'fields' => [
'person.id',
'full_name' => fn($q, $r) => $q->expr('CONCAT([], CASE WHEN [] != "" THEN CONCAT(" ", []) ELSE "" END, " ", [])', [$r('person.first_name '), $r('person.middle_name'), $r('person.middle_name'), $r('person.last_name')]),
'person.image',
],
'type' => 'json',
]);
$this->hasMany('cast', ['model' => function ($persistence) {
$model = new Cast($persistence);
$model->join('person');
return $model;
}, 'theirField' => 'movie_id'])
->addField('cast_json', [
'aggregate' => 'json',
'fields' => [
'person.id',
'full_name' => fn($q, $r) => $q->expr('CONCAT([], CASE WHEN [] != "" THEN CONCAT(" ", []) ELSE "" END, " ", [])', [$r('person.first_name '), $r('person.middle_name'), $r('person.middle_name'), $r('person.last_name')]),
'person.image',
],
'type' => 'json',
]);
...
}
$movie = new Movie($persistence);
$movie = $movie->load($id);
return json_encode([
'id' => $movie->get('id'),
...
'directors' => $movie->get('directors_json') ?? [],
'cast' => $movie->get('cast_json') ?? [],
]);This example clearly shows why we need both JSON aggregation and dot notation working together. |
||
| */ | ||
| abstract class Query extends Expression | ||
| { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,8 @@ | |
| use Atk4\Data\Exception; | ||
| use Atk4\Data\Field; | ||
| use Atk4\Data\Model; | ||
| use Atk4\Data\Model\Join; | ||
| use Atk4\Data\Persistence\Sql\Expressionable; | ||
| use Atk4\Data\Reference; | ||
|
|
||
| class HasMany extends Reference | ||
|
|
@@ -155,6 +157,90 @@ public function addField(string $fieldName, array $defaults = []): Field | |
| $fx = function () use ($defaults, $field) { | ||
| return $this->refLink()->action('fx0', [$defaults['aggregate'], $field]); | ||
| }; | ||
| } elseif ($defaults['aggregate'] === 'json') { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| if (!isset($defaults['fields'])) { | ||
| throw new Exception('JSON aggregate requires "fields" parameter with array of field names'); | ||
| } | ||
|
|
||
| $jsonFields = $defaults['fields']; | ||
| unset($defaults['fields']); // Remove from field defaults | ||
|
|
||
| $fx = function () use ($jsonFields) { | ||
| $theirModel = $this->refLink(); | ||
| $query = $theirModel->action('select', [[]]); | ||
|
|
||
| // Helper to resolve field reference with dot notation support | ||
| $resolveField = static function ($fieldRef) use ($theirModel, $query) { | ||
| if (is_string($fieldRef) && str_contains($fieldRef, '.')) { | ||
| // Dot notation: 'join.field' - resolve from join | ||
| [$joinName, $fieldName] = explode('.', $fieldRef, 2); | ||
|
|
||
| // Find the join by iterating through model's joins | ||
| $join = null; | ||
| foreach ($theirModel->elements as $element) { | ||
| if ($element instanceof Join) { | ||
| if ($element->getForeignTable() === $joinName) { | ||
| $join = $element; | ||
|
|
||
| break; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (!$join) { | ||
| throw (new Exception('Join not found for field reference')) | ||
| ->addMoreInfo('field', $fieldRef) | ||
| ->addMoreInfo('join', $joinName); | ||
| } | ||
|
|
||
| // Get field from join's foreign table | ||
| // The field is qualified as joinAlias.fieldName in the query | ||
| $joinAlias = $join->foreignAlias ?? ('_' . $joinName); | ||
|
|
||
| return $query->expr($joinAlias . '.' . $fieldName); | ||
| } | ||
|
|
||
| // Regular field reference | ||
| return $theirModel->getField($fieldRef); | ||
| }; | ||
|
|
||
| $jsonPairs = []; | ||
| foreach ($jsonFields as $key => $fieldSpec) { | ||
| // Determine output key name | ||
| if (is_int($key)) { | ||
| // No custom name - auto-generate from field spec | ||
| if (is_string($fieldSpec) && str_contains($fieldSpec, '.')) { | ||
| // Strip join prefix: 'person.first_name' -> 'first_name' | ||
| $jsonKey = explode('.', $fieldSpec, 2)[1]; | ||
| } else { | ||
| $jsonKey = is_string($fieldSpec) ? $fieldSpec : $key; | ||
| } | ||
| } else { | ||
| // Custom name provided | ||
| $jsonKey = $key; | ||
| } | ||
|
|
||
| if (is_string($fieldSpec)) { | ||
| $jsonPairs[$jsonKey] = $resolveField($fieldSpec); | ||
| } elseif ($fieldSpec instanceof Expressionable) { | ||
| $jsonPairs[$jsonKey] = $fieldSpec; | ||
| } elseif (is_callable($fieldSpec)) { | ||
| $jsonPairs[$jsonKey] = $fieldSpec($query, $resolveField); | ||
| } elseif (is_array($fieldSpec) && isset($fieldSpec['expr'])) { | ||
| if (is_callable($fieldSpec['expr'])) { | ||
| $jsonPairs[$jsonKey] = $fieldSpec['expr']($query, $resolveField); | ||
| } else { | ||
| $resolvedArgs = array_map($resolveField, $fieldSpec['args'] ?? []); | ||
| $jsonPairs[$jsonKey] = $query->expr($fieldSpec['expr'], $resolvedArgs); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| $jsonObj = $query->fxJsonObject($jsonPairs); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is legit usecase for a new method. See #1267 for non-associative PR. I would like to have this reviewed and merged separately. Please reuse as much as code from #1267 as possible. Like the non-object version, the object (associative) version should support MySQL 5.6, it should be possible to construct JSON string reliably without native JSON support. |
||
| $jsonAgg = $query->jsonArrayAgg($jsonObj); | ||
|
|
||
| return $theirModel->action('field', [$jsonAgg]); | ||
| }; | ||
| } else { | ||
| $fx = function () use ($defaults, $field) { | ||
| $args = [$defaults['aggregate'], $field]; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
revert