-
Notifications
You must be signed in to change notification settings - Fork 0
Add write support for models #482
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: main
Are you sure you want to change the base?
Changes from all commits
2f0e37d
8aceff4
16b0df8
eeb409f
cc36ad5
209c665
3b994ca
c8ec7c3
0ac9659
b4dde84
1db1b3d
d8dd5d0
893a8e9
1d434cf
192fc87
616df17
fbf12e4
d04b33c
e34dba8
7e59e4b
0f2bbf0
b7afa06
706159c
ccd20a8
18762fe
21e4be7
adff3f6
a6daae5
221630d
823fcaa
d19bafa
4b86067
9de2b04
99bfe40
835f1d7
cf90e02
5b70401
57fbabf
06b9675
d704a6f
85c4e3b
4d80aa3
483c6fe
b662def
bd67282
c1eb22b
ee4d6a4
0a00366
8d45a34
ee2d576
302f598
813cf05
f1e30ad
857b244
3f4e1c9
5239b79
0578205
c5e8f9c
19da108
4c6ceda
a0c9d05
121b62b
aee9a6e
dc424d0
b921378
bc83d29
86df655
19acad0
6628523
38ae4e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| <?php | ||
|
|
||
| // SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com> | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| namespace Icinga\Module\Notifications\Common; | ||
|
|
||
| use ipl\Sql\Connection; | ||
| use ipl\Sql\ExpressionInterface; | ||
|
|
||
| /** | ||
| * Base class for all module models that tracks the changes made to a model | ||
| * | ||
| * Records which properties have changed since the model was loaded, and whether the model has been | ||
| * persisted yet, so the {@see EntityManager} can store a model and write only what actually changed. | ||
| * | ||
| * {@see self::setNew()} must be called explicitly when creating a new instance, it tells the {@see EntityManager} | ||
| * whether to insert or update | ||
| */ | ||
| abstract class Model extends \ipl\Orm\Model | ||
|
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. Please add constants for |
||
| { | ||
| /** @var ?bool Whether this model is newly created and does not yet exist in the database */ | ||
| private ?bool $isNew = null; | ||
|
|
||
| /** @var bool Whether the model is marked for deletion on the next {@see EntityManager::save()} */ | ||
| private bool $markedForDeletion = false; | ||
|
|
||
| /** @var array<string, true> Names of properties modified since the model was loaded */ | ||
| private array $modifiedProperties = []; | ||
|
|
||
| /** | ||
| * Whether a getter for a Closure-backed property is currently resolving. | ||
| * | ||
| * {@see \ipl\Orm\Common\PropertiesWithDefaults::getProperty()} memoizes a resolved Closure by | ||
| * calling {@see setProperty()}. Without this guard, that internal write would be misread as a | ||
| * user-driven change and (worse) re-enter {@see setProperty()} recursively. | ||
| * | ||
| * @var bool | ||
| */ | ||
| private bool $resolvingProperty = false; | ||
|
|
||
| /** | ||
| * Get whether this entity is newly created and does not yet exist in the database | ||
| * | ||
| * @return ?bool | ||
| */ | ||
| public function isNew(): ?bool | ||
| { | ||
| return $this->isNew; | ||
| } | ||
|
|
||
| /** | ||
| * Set whether this entity is newly created and does not yet exist in the database | ||
| * | ||
| * @param bool $new | ||
| * | ||
| * @return $this | ||
| */ | ||
| public function setNew(bool $new = true): static | ||
| { | ||
| $this->isNew = $new; | ||
|
|
||
| return $this; | ||
| } | ||
|
|
||
| /** | ||
| * Get whether the entity, or the given property, has unsaved modifications | ||
| * | ||
| * Always returns false for new entities, which carry no change tracking. | ||
| * | ||
| * @param ?string $property The property to check, or null to check the whole entity | ||
| * | ||
| * @return bool | ||
| */ | ||
| public function isModified(?string $property = null): bool | ||
| { | ||
| if ($property === null) { | ||
| return ! empty($this->modifiedProperties); | ||
| } | ||
|
|
||
| return isset($this->modifiedProperties[$property]); | ||
| } | ||
|
|
||
| /** | ||
| * Get the names of all properties modified since the entity was loaded as a set keyed by name | ||
| * | ||
| * The keys may be columns or relations. | ||
| * | ||
| * @return array<string, true> | ||
| */ | ||
| public function getModifiedProperties(): array | ||
| { | ||
| return $this->modifiedProperties; | ||
| } | ||
|
|
||
| /** | ||
| * Reset change tracking and accept the current values as the new baseline | ||
| * | ||
| * @return $this | ||
| */ | ||
| public function clearModifiedProperties(): static | ||
| { | ||
| $this->modifiedProperties = []; | ||
| $this->markedForDeletion = false; | ||
|
|
||
| return $this; | ||
| } | ||
|
|
||
| /** | ||
| * Get whether the model's table uses soft deletes | ||
| * | ||
| * @return bool | ||
| */ | ||
| public function isSoftDeletable(): bool | ||
| { | ||
| return in_array('deleted', $this->getColumns(), true); | ||
|
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 should utilize |
||
| } | ||
|
|
||
| /** | ||
| * Mark the model for deletion on the next {@see EntityManager::save()} and return it | ||
| * | ||
| * If the model uses soft deletes this function must set the `deleted` property | ||
| * | ||
| * @return $this | ||
| */ | ||
| public function delete(): static | ||
| { | ||
| $this->markedForDeletion = true; | ||
| if ($this->isSoftDeletable()) { | ||
| $this->deleted = true; | ||
| } | ||
|
|
||
| return $this; | ||
| } | ||
|
|
||
| /** | ||
| * Get whether the model is marked for deletion on the next {@see EntityManager::save()} | ||
| * | ||
| * @return bool | ||
| */ | ||
| public function isMarkedForDeletion(): bool | ||
| { | ||
| return $this->markedForDeletion; | ||
| } | ||
|
|
||
| /** | ||
| * Get the model's real columns as a property => column map | ||
| * | ||
| * "Real" means that the column maps to an actual table column, and can be written to by the {@see EntityManager} | ||
| * | ||
| * @return array<string, string> | ||
| */ | ||
| public function getRealColumnMap(): array | ||
| { | ||
| $columns = []; | ||
| foreach ((array) $this->getKeyName() as $key) { | ||
| $columns[$key] = $key; | ||
| } | ||
|
|
||
| foreach ($this->getColumns() as $alias => $column) { | ||
| if ($column instanceof ExpressionInterface) { | ||
| continue; | ||
| } | ||
|
|
||
| if (is_int($alias)) { | ||
| $columns[$column] = $column; | ||
| } else { | ||
| $columns[$alias] = $column; | ||
| } | ||
| } | ||
|
|
||
| return $columns; | ||
| } | ||
|
Comment on lines
+146
to
+173
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. I rather had a more static approach in mind. Differences in columns compared to the actual table columns manifest usually only as the primary key being missing. Expressions are possible, but really an edge case as at the moment none of our notifications models have any. So here are my suggestions:
|
||
|
|
||
| /** | ||
| * Get the column used to store the timestamp of the most recent modification to the row | ||
| * | ||
| * `changed_at` is the schema-wide convention, the {@see EntityManager} checks whether the column | ||
| * exists on the model before stamping it. | ||
| * | ||
| * @return string | ||
| */ | ||
| public function getChangedAtColumn(): string | ||
| { | ||
| return 'changed_at'; | ||
|
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. We just noticed recently that a particular table had no |
||
| } | ||
|
|
||
| /** | ||
| * @param string $key The name of the property, which may be a column or a relation | ||
| */ | ||
| protected function getProperty(string $key): mixed | ||
| { | ||
| $wasResolving = $this->resolvingProperty; | ||
| $this->resolvingProperty = true; | ||
| try { | ||
| return parent::getProperty($key); | ||
| } finally { | ||
| $this->resolvingProperty = $wasResolving; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @param string $key The name of the property, which may be a column or a relation | ||
| */ | ||
| protected function setProperty(string $key, mixed $value): static | ||
| { | ||
| if (! $this->resolvingProperty && $this->isNew === false && ! isset($this->modifiedProperties[$key])) { | ||
| // Resolve the prior value via the trait's iterator, which skips Closure-valued properties. | ||
| // This avoids triggering lazy relation loaders just to capture change-tracking state. | ||
| $hadValue = false; | ||
| $original = null; | ||
| foreach ($this as $k => $v) { | ||
| if ($k === $key) { | ||
| $hadValue = true; | ||
| $original = $v; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if (! $hadValue || $original !== $value) { | ||
| $this->modifiedProperties[$key] = true; | ||
| } | ||
| } | ||
|
|
||
| return parent::setProperty($key, $value); | ||
| } | ||
|
|
||
| public static function on(Connection $db): StatefulQuery | ||
|
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. I'm more and more convinced that this should possibly be a new method: It clearly signals the intention and |
||
| { | ||
| return (new StatefulQuery()) | ||
| ->setDb($db) | ||
| ->setModel(new static()); | ||
| } | ||
| } | ||
|
nilmerg marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| <?php | ||
|
|
||
| // SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com> | ||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||
|
|
||
| namespace Icinga\Module\Notifications\Common; | ||
|
|
||
| use Generator; | ||
| use ipl\Orm\Query; | ||
|
|
||
| /** | ||
| * Mark models loaded from the db as not new, and can flag every result for deletion | ||
| */ | ||
| class StatefulQuery extends Query | ||
| { | ||
| /** @var bool Whether all yielded models should be marked for deletion */ | ||
| protected bool $deleteAll = false; | ||
|
|
||
| /** | ||
| * Mark each yielded model as loaded, and for deletion when {@see self::deleteAll()} was set | ||
| * | ||
| * @inheritDoc | ||
| * | ||
| * @return Generator | ||
| */ | ||
| public function yieldResults(): Generator | ||
| { | ||
| foreach (parent::yieldResults() as $key => $model) { | ||
| if ($model instanceof Model) { | ||
|
sukhwinder33445 marked this conversation as resolved.
|
||
| $this->markLoaded($model); | ||
| if ($this->deleteAll) { | ||
| $model->delete(); | ||
| } | ||
| } | ||
|
|
||
| yield $key => $model; | ||
| } | ||
|
Copilot marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Mark each model as deleted when yielded by {@see self::yieldResults()} | ||
| * | ||
| * This only affects the root models themselves, not their eager-loaded relations | ||
| * | ||
| * @return $this | ||
| */ | ||
| public function deleteAll(): static | ||
| { | ||
| $this->deleteAll = true; | ||
|
|
||
| return $this; | ||
| } | ||
|
|
||
| /** | ||
| * Recursively mark the given model and its eagerly-loaded related models as loaded | ||
| * | ||
| * @param Model $model | ||
| * | ||
| * @return void | ||
| */ | ||
| private function markLoaded(Model $model): void | ||
|
nilmerg marked this conversation as resolved.
|
||
| { | ||
| if ($model->isNew() !== null) { | ||
| return; | ||
| } | ||
|
|
||
| $model->setNew(false); | ||
|
|
||
| foreach ($model as $value) { | ||
| if ($value instanceof Model) { | ||
| $this->markLoaded($value); | ||
| } elseif (is_array($value)) { | ||
| foreach ($value as $item) { | ||
| if ($item instanceof Model) { | ||
| $this->markLoaded($item); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.