refactor(api)!: improve doctrine performance; remove AbstractContentNodeOwner#2683
Conversation
…--> ContentNode relation
BacLuc
left a comment
There was a problem hiding this comment.
Ist mir leider zu gross für jetzt.
Mache später weiter
…face + minor changes as per review
carlobeltrame
left a comment
There was a problem hiding this comment.
Did another pass. I can't say I really understand how of all the many changes you did here fit into one single big picture, so I am trying to instead review the individual files.
| collectionOperations: [ | ||
| 'get' => [ | ||
| 'normalization_context' => ['groups' => ['read', 'Activity:ActivityResponsibles']], | ||
| 'normalization_context' => ['groups' => ['read', 'Activity:ActivityResponsibles', 'Activity:ScheduleEntries']], |
There was a problem hiding this comment.
We have ScheduleEntry:Activity in the item normalization context of ScheduleEntry. Can this never lead to an infinite loop? Why did you need to add this?
There was a problem hiding this comment.
In ScheduleEntryLinks.vue https://github.com/ecamp/ecamp3/pull/2683/files#diff-88775c426170bcdeb7ef38ff0453b604a09d3f830d372c911e33286d568ac63aR5 we list/display all ScheduleEntries of an Activity.
Before this PR, a separate network request was necessary for every activity. With embedding ScheduleEntries into Activity collection, I can load all Activities and ScheduleEntries of a camp with a single network request and traverse in both sides without additional network request.
Infinite Loop should not happen with our setup of normalization groups, unless you start to explicitly embedded the return relations in the normalization context, i.e.:
'normalization_context' => ['groups' => ['read', 'Activity:ActivityResponsibles', 'Activity:ScheduleEntries', 'ScheduleEntry:Activity']],
A potential (and probably more performant?) alternative could be to avoid RelatedCollectionLink on activity->scheduleEntries (similar as for contentNode->children). In that case, I can load all data of a camp with 2 network request (1 for all activities + 1 for all scheduleEntries) and I can still traverse freely in both directions.
| messageNoneNull: 'Must be null on root content nodes.' | ||
| )] | ||
| #[AssertBelongsToSameOwner(groups: ['update'])] | ||
| #[Assert\NotNull(groups: ['create'])] // Root nodes have parent:null, but manually creating root nodes is not allowed |
There was a problem hiding this comment.
You now allow changing the parent of an existing ContentNode to null. I think you need to revert this to use AssertEitherIsNull again.
There was a problem hiding this comment.
I think #[AssertBelongsToSameRoot(groups: ['update'])] already disallows changing parent to null (because that would implicitly change the root). There's also a test for this (testPatchColumnLayoutValidatesMissingParent).
But I think it doesn't hurt to #[Assert\NotNull], that would make it more explicit.
There was a problem hiding this comment.
Ah, now I remember why this was not possible. If #[Assert\NotNull] is enabled for update, I cannot modify root-nodes anymore. So I decided to to verify this in #[AssertBelongsToSameRoot]. Basically, changing the parent property from non-null to null would make the ContentNode a root and therefore implictly also changing the root (which is not allowed according to #[AssertBelongsToSameRoot]).
Happy to discuss counterproposals.
There was a problem hiding this comment.
I think you need to revert this to use
AssertEitherIsNullagain.
There's no owner anymore. Before there were 2 evidences that a ContentNode is a root (parent is null and owner is not-null). They had to be in sync with AssertEitherIsNull. Now there's only parent and nothing to verify/sync against.
| #[ApiProperty(writable: false, example: '/activities/1a2b3c4d')] | ||
| #[Groups(['read'])] | ||
| public function getRootOwner(): Activity|Category|AbstractContentNodeOwner|null { | ||
| #[ApiProperty(readable: false)] |
There was a problem hiding this comment.
Is it writable now? Otherwise, you should add writable: false back, no?
There was a problem hiding this comment.
I think it should not be an ApiProperty at all, will fix.
| public function getRootDescendants(): array { | ||
| return $this->rootDescendants->getValues(); | ||
| } | ||
|
|
||
| public function addRootDescendant(ContentNode $rootDescendant): self { | ||
| if (!$this->rootDescendants->contains($rootDescendant)) { | ||
| $this->rootDescendants[] = $rootDescendant; | ||
| $rootDescendant->root = $this; | ||
| } | ||
|
|
||
| return $this; | ||
| } | ||
|
|
||
| public function removeRootDescendant(ContentNode $rootDescendant): self { | ||
| if ($this->rootDescendants->removeElement($rootDescendant)) { | ||
| // reset the owning side (unless already changed) | ||
| if ($rootDescendant->root === $this) { | ||
| $rootDescendant->root = null; | ||
| } | ||
| } | ||
|
|
||
| return $this; | ||
| } |
There was a problem hiding this comment.
These will also be available on nested column layouts, and appear in the API (?). We need to take care to only use these methods on root content nodes. Or maybe it would help to create another dedicated root content node type instead of using ColumnLayout for it.
There was a problem hiding this comment.
These will also be available on nested column layouts, and appear in the API (?). We need to take care to only use these methods on root content nodes.
rootDescendants will be available on all ColumnLayout entities, but empty for nested ones (non-root entities). Not displaying this for nested ColumnLayouts would break the rule, that an entity of the same type always looks the same.
Or maybe it would help to create another dedicated root content node type instead of using
ColumnLayoutfor it.
We discussed this as an option in #2569 and, yes, I have the opinion it would be cleaner. In the meeting we settled on using ColumnLayout as root node for the first shot.
This would however become irrelevant, in case we decide to implement #2633
| )] | ||
| #[Groups(['create'])] | ||
| #[ORM\OneToOne(targetEntity: Profile::class, inversedBy: 'user', cascade: ['persist'])] | ||
| #[ORM\OneToOne(targetEntity: Profile::class, inversedBy: 'user', cascade: ['persist'], fetch: 'EAGER')] |
There was a problem hiding this comment.
What does this have to do with this PR?
There was a problem hiding this comment.
This was a small general performance improvement: With every call User is loaded and then Profile for access verification. The latter is because of this line: https://github.com/ecamp/ecamp3/blob/devel/api/src/Entity/User.php#L208 (roles stored on profile entity)
Eager loading combines both entities in 1 DB query instead of 2 separate ones.
| $previousObject = $this->requestStack->getCurrentRequest()?->attributes?->get('previous_data'); | ||
| $previousValue = $previousObject?->{$this->context->getPropertyName()}; | ||
|
|
||
| // root nodes have parent:null (before and after) | ||
| if ($previousObject instanceof ContentNode && null === $value && null === $previousValue) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Does a root content node have $this->root === null? Would things be easier if we had a dedicated RootContentNode class which just returns $this when asked for getRoot()?
There was a problem hiding this comment.
Root nodes have $parent===null and $root === $this (same as before).
$root === null could also be an option for root nodes.
Implementing a dedicated RootContentNode could be an option, yes.
| } | ||
|
|
||
| if ($value->entityClass !== get_class($object)) { | ||
| if ($value->entityClass !== $this->getObjectClass($object)) { |
There was a problem hiding this comment.
Why is getObjectClass better than get_class? Should we start using that everywhere now?
There was a problem hiding this comment.
Yes, we should. get_class() could return the doctrine proxy class, if for any reason the object is not yet fully resolved form the DB. Instanceof comparisons would still be fine, but direct comparisons could fail.
It probably shouldn't happen here (if I'm validating an object, that object should already be resolved). But I stumbled over this in another code fragment and just felt safer to use getObjectClass everywhere, when $object is a Doctrine entity.
| const root = await contentNode.$href('root') | ||
|
|
||
| return this.camp.activities().items.find(activity => { | ||
| return activity.rootContentNode()._meta.self === root |
There was a problem hiding this comment.
Do we have any mechanisms in place now to make sure that every root content node can only belong to one activity or category? And to make sure that no sub-column-layout is the root of some other activity or category?
There was a problem hiding this comment.
Not in DB, no.
The protection is "only" from API side, because rootCotentNode is not writable and always a new one is created, when an activity or a category is created.
There was a problem hiding this comment.
So, could we add a unique index in the DB to the activity#root_content_node_id and category#root_content_node_id DB columns? Do you see any drawbacks with that?
There was a problem hiding this comment.
Is Doctrine and Postgres able to handle unique constraints across multiple tables? If so, sure. No drawback I see.
There was a problem hiding this comment.
Discussion: Add at least a unique index to each table (activity + category)
There was a problem hiding this comment.
This was already implemented. See unique: true on JoinColumn Attribute:
https://github.com/ecamp/ecamp3/pull/2683/files#diff-6c5bf17f3740f1a2190f671331eb17d4f1b4ea14ff04d2883f63f3ec379093a1L20
and the actual indexes in the migration files:
https://github.com/ecamp/ecamp3/pull/2683/files#diff-7f44091fbb4175265ebac8238f7aebed816b76abaf3bbcf6a153a0eb46413cecR38
| contentNodeOwner: { | ||
| preferredContentTypes: () => this.preferredContentTypes, | ||
| contentNodes: () => this.contentNodes, | ||
| camp: () => this.camp | ||
| } |
There was a problem hiding this comment.
Ah, I was surprised to read that the provided contentNodeOwner is not an entity from the API. Don't you think it would be better for developer experience if we passed a real entity in here, so that nobody unexpectedly gets errors when they try to access e.g. the self link of contentNodeOwner?
There was a problem hiding this comment.
Maybe an other name for this object could be a solution. I struggled here a bit to make the provide/inject data reactive and it finally worked when I encapsulated everything in this contentNodeOwner object.
I don't necessarily want to provide the real owner (which is a category or an activity). Otherwise the components below have to include separate logic to figure out whether the owner is a category or an activity. This way I can just inject the preferredContentTypes and the subcomponents don't have to care, whether they live inside a category or inside an activity.
There was a problem hiding this comment.
So far the only relevant difference between activity and category is the preferredContentTypes. If we added a #[RelatedCollectionLink()] function getPreferredContentTypes() { ... } to Activity, wouldn't that allow us to provide a real API entity from the store here?
Might involve the same discussion as in #2700 (comment) though.
| .filter(idx => idx !== null) | ||
| }) | ||
|
|
||
| this.camp().activities().$reload() |
There was a problem hiding this comment.
A small improvement to avoid several network requests.
In the component MaterialTable.vue https://github.com/ecamp/ecamp3/pull/2683/files#diff-850ca229cd293729e94d2201607458788fe4d0e9369f0fa45a605374f75c529dR369 the activities are needed to match materialItem.materialNode.root with activity.rootContentNode
Nice thanks. I can imagine not everything is self-explanatory. We spent quite some time in the last meeting to discuss the way forward. |
BacLuc
left a comment
There was a problem hiding this comment.
Funktioniert alles noch: (Neue Kategorie erstellen, Activity von Category erstellen, Camp von CampPrototype erstellen, drucken, ContentNodes herumschieben).
Performance fühlt sich besser an.
Ein kleines Problem mit der Migration, es ist aber für mich auch ok, wenn wir meinen komischen stand lokal nicht fixen mit der migration.
Wenn die kommentare von cosinus geklärt sind, kann das rein.
Danke vielmals
| @@ -0,0 +1,43 @@ | |||
| <?php | |||
There was a problem hiding this comment.
Diese migration is fehlgeschlagen bei meinem lokalen stand:
In ExceptionConverter.php line 54:
ecamp3-api |
ecamp3-api | An exception occurred while executing a query: SQLSTATE[23503]: Foreign key
ecamp3-api | violation: 7 ERROR: insert or update on table "abstract_content_node_owne
ecamp3-api | r" violates foreign key constraint "fk_8e710ab4f886581c"
ecamp3-api | DETAIL: Key (rootcontentnodeid)=(3e67389a5898) is not present in table "co
ecamp3-api | ntent_node_columnlayout".
ecamp3-api |
ecamp3-api |
ecamp3-api | In Exception.php line 30:
ecamp3-api |
ecamp3-api | SQLSTATE[23503]: Foreign key violation: 7 ERROR: insert or update on table
ecamp3-api | "abstract_content_node_owner" violates foreign key constraint "fk_8e710ab4
ecamp3-api | f886581c"
ecamp3-api | DETAIL: Key (rootcontentnodeid)=(3e67389a5898) is not present in table "co
ecamp3-api | ntent_node_columnlayout".
ecamp3-api |
ecamp3-api |
ecamp3-api | In Connection.php line 72:
ecamp3-api |
ecamp3-api | SQLSTATE[23503]: Foreign key violation: 7 ERROR: insert or update on table
ecamp3-api | "abstract_content_node_owner" violates foreign key constraint "fk_8e710ab4
ecamp3-api | f886581c"
ecamp3-api | DETAIL: Key (rootcontentnodeid)=(3e67389a5898) is not present in table "co
ecamp3-api | ntent_node_columnlayout".
ecamp3-api |
Ist die Frage ob man das noch anschauen will.
Sobald alle ihre db's gespühlt haben, ist das wieder ok.
| namespace eCamp\Core\Entity; | ||
|
|
||
| interface BelongsToContentNodeInterface { | ||
| interface BelongsToContentNodeTreeInterface { |
There was a problem hiding this comment.
hmm, weird. I double checked and noticed that I renamed the file already...
Then I noticed that this is in the old backend --> victim of mass replace😆
If ok for you, I'd rather not fix this, as we're going to delete the old backend code soon anyway.
| )] | ||
| #[AssertBelongsToSameOwner(groups: ['update'])] | ||
| #[Assert\NotNull(groups: ['create'])] // Root nodes have parent:null, but manually creating root nodes is not allowed | ||
| #[AssertBelongsToSameRoot(groups: ['update'])] |
There was a problem hiding this comment.
Alternative name: AssertNoRootChange
// cannot change non-null value to null value
There was a problem hiding this comment.
|
@carlobeltrame & @pmattmann: |
| computed: { | ||
| preferredContentTypes () { | ||
| return this.contentNodeOwner.preferredContentTypes().items.map(this.contentTypeMap) | ||
| preferredContentTypesList () { |
There was a problem hiding this comment.
Wieso wurde das umbenannt?
There was a problem hiding this comment.
Name conflict mit dem injected property preferredContentTypes
| <v-list> | ||
| <!-- preferred content types --> | ||
| <v-list-item v-for="act in preferredContentTypes" | ||
| <v-list-item v-for="act in preferredContentTypesList" |
There was a problem hiding this comment.
Müsste ich raten... 😆 ActivityContentType vielleicht?
Habs vereinfacht mit eab7b42
| provide () { | ||
| return { | ||
| preferredContentTypes: () => this.preferredContentTypes, | ||
| rootContentNodes: () => this.contentNodes, |
There was a problem hiding this comment.
I like it this way 👍
Only the name rootContentNodes is not optimal I think. When I read that in a component which gets it injected, I think of a set of root content nodes, i.e. the roots of multiple trees. How about allContentNodes or contentNodesInTree?
…o allContentNodes
Implements improvements discussed in #2569 and agreed in #2569 (comment)
Implemented
To do
Time measurement