Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public class ValidationMessages {
public static final String RELATION_REQUIRED_MISSING = "Relation '%s' is required by template '%s'";
public static final String RELATION_TOO_MANY_TARGETS = "Relation '%s' allows only one target in template '%s'";
public static final String RELATION_TARGET_ENTITY_NOT_FOUND = "Relation '%s': target entity '%s' does not exist";
public static final String RELATION_TARGET_TEMPLATE_MISMATCH = "Relation '%s' targets entities with template '%s', but entity '%s' has template '%s'";
public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead.";
public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target.";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package com.decathlon.idp_core.domain.service.entity;

import static java.util.stream.Collectors.*;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import jakarta.validation.Valid;

Expand All @@ -26,6 +29,7 @@
import com.decathlon.idp_core.domain.model.entity.Entity;
import com.decathlon.idp_core.domain.model.entity.EntityFilter;
import com.decathlon.idp_core.domain.model.entity.EntitySummary;
import com.decathlon.idp_core.domain.model.entity.Property;
import com.decathlon.idp_core.domain.model.entity.Relation;
import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate;
import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition;
Expand Down Expand Up @@ -176,9 +180,115 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier,
Entity enrichedEntity = enrichRelationsWithTargetTemplates(entityToSave, template);

entityValidationService.validateForUpdate(enrichedEntity, template);
return entityRepository.save(enrichedEntity);

// Only save if there are actual changes to avoid unnecessary audit records
if (hasEntityChanged(existingEntity, enrichedEntity)) {
return entityRepository.save(enrichedEntity);
}

return existingEntity;
}

/// Detects if an entity has actually changed by comparing its core fields,
/// properties, and relations with the incoming entity.
///
/// @param existingEntity the current entity in the repository
/// @param incomingEntity the new entity data to be applied
/// @return true if any field, property, or relation has changed; false
/// otherwise
private boolean hasEntityChanged(Entity existingEntity, Entity incomingEntity) {
// Check name change
if (!Objects.equals(existingEntity.name(), incomingEntity.name())) {
return true;
}

// Check properties change
if (havePropertiesChanged(existingEntity.properties(), incomingEntity.properties())) {
return true;
}

// last Check relations change
return haveRelationsChanged(existingEntity.relations(), incomingEntity.relations());
}

/// Compares two property lists for equality, ignoring UUID ids which are
/// only present in persisted properties.
///
/// @param existing the current list of properties in the repository
/// @param request the new list of properties to be applied
/// @return true if any property has changed; false otherwise
private boolean havePropertiesChanged(List<Property> existing, List<Property> request) {
if (existing == request)
return false;
if (existing == null || request == null)
return true;
if (existing.size() != request.size())
return true;

Map<String, String> existingMap = existing.stream()
.collect(Collectors.toMap(Property::name, Property::value, (_, v2) -> v2));
Map<String, String> incomingMap = request.stream()
.collect(Collectors.toMap(Property::name, Property::value, (_, v2) -> v2));

return !existingMap.equals(incomingMap);
}

/// Compares two relation lists for equality by name and target identifiers,
/// ignoring the UUID id field which is only present in persisted relations.
///
/// @param existing the current list of relations in the repository
/// @param request the new list of relations to be applied
/// @return true if any relation has changed; false otherwise
private boolean haveRelationsChanged(List<Relation> existing, List<Relation> request) {
if (existing == request)
return false;
if (existing == null || request == null)
return true;
if (existing.size() != request.size())
return true;

Map<String, Relation> existingMap = existing.stream()
.collect(Collectors.toMap(Relation::name, r -> r, (_, r) -> r));
Map<String, Relation> incomingMap = request.stream()
.collect(Collectors.toMap(Relation::name, r -> r, (_, r) -> r));

if (!existingMap.keySet().equals(incomingMap.keySet())) {
return true;
}

for (Map.Entry<String, Relation> entry : existingMap.entrySet()) {
Relation existingRel = entry.getValue();
Relation incomingRel = incomingMap.get(entry.getKey());

if (haveRelationChanged(existingRel, incomingRel)) {
return true;
}
}
return false;
}

/// Compares two relations for equality by target template identifier and target
/// entity identifiers, ignoring the UUID id field which is only present in
/// persisted relations.
/// @param existingRelation the current relation in the repository
/// @param requestRelation the new relation data to be applied
/// @return true if the relation has changed; false otherwise
private boolean haveRelationChanged(Relation existingRelation, Relation requestRelation) {
if (!Objects.equals(existingRelation.targetTemplateIdentifier(),
requestRelation.targetTemplateIdentifier())) {
return true;
}

Set<String> existingEntityIdentifier = existingRelation.targetEntityIdentifiers() == null
? Set.of()
: Set.copyOf(existingRelation.targetEntityIdentifiers());

Set<String> requestEntityIdentifier = requestRelation.targetEntityIdentifiers() == null
? Set.of()
: Set.copyOf(requestRelation.targetEntityIdentifiers());

return !existingEntityIdentifier.equals(requestEntityIdentifier);
}
/// Enriches entity relations with target template identifiers from template
/// definition.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_TEMPLATE_MISMATCH;
import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS;

import java.util.List;
Expand Down Expand Up @@ -155,7 +156,7 @@ public void validateRelationsAgainstTemplate(EntityTemplate template,
}

/// Validates that all target entity identifiers in the relation actually exist
/// in the database.
/// in the database and match the expected target template.
///
/// @param relation the relation whose target entity identifiers are to be
/// validated
Expand All @@ -177,6 +178,31 @@ private void validateRelationTargetEntityExistence(Relation relation, Violations
violations.add(RELATION_TARGET_ENTITY_NOT_FOUND, relation.name(), identifier);
}
}

// Validate that target entities match the expected target template
validateRelationTargetTemplates(relation, existingEntities, violations);
}

/// Validates that all target entities have the expected target template
/// identifier
/// as defined in the relation.
///
/// @param relation the relation with target template identifier
/// @param existingEntities the existing entities to validate
/// @param violations the accumulator for validation violations
private void validateRelationTargetTemplates(Relation relation,
List<EntitySummary> existingEntities, Violations violations) {
if (relation.targetTemplateIdentifier() == null || existingEntities.isEmpty()) {
return;
}

for (EntitySummary entity : existingEntities) {
if (extractValidTargetIdentifiers(relation).contains(entity.identifier())
&& !relation.targetTemplateIdentifier().equals(entity.templateIdentifier())) {
violations.add(RELATION_TARGET_TEMPLATE_MISMATCH, relation.name(),
relation.targetTemplateIdentifier(), entity.identifier(), entity.templateIdentifier());
}
}
}

/// Extracts non-null, non-blank target entity identifiers from the relation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,13 @@ private void mergeProperties(final Entity entity, final EntityJpaEntity existing

/// Merges relations from the incoming domain entity into the existing JPA
/// entity.
/// Removes relations that are no longer present, updates existing ones, and
/// adds
/// new relations as needed. This ensures that the JPA entity accurately
/// reflects the current state of the domain entity.
/// Removes relations that are no longer present, updates existing ones only if
/// they changed, and adds new relations as needed. This prevents unnecessary
/// updates and avoids triggering Envers audit records for unchanged data.
///
/// **Optimization:** Change detection prevents false positives where Hibernate
/// marks collections as modified even though their values haven't changed,
/// which would cause Envers to create duplicate audit table entries.
Comment thread
RVANDO12 marked this conversation as resolved.
private void mergeRelations(final Entity entity, final EntityJpaEntity existing) {

existing.getRelations().removeIf(existingRel -> entity.relations().stream()
Expand All @@ -142,15 +145,47 @@ private void mergeRelations(final Entity entity, final EntityJpaEntity existing)
for (Relation domainRel : entity.relations()) {
existing.getRelations().stream().filter(r -> r.getName().equals(domainRel.name())).findFirst()
.ifPresentOrElse(existingRel -> {
existingRel.setTargetTemplateIdentifier(domainRel.targetTemplateIdentifier());
existingRel.setTargetEntities(domainRel.targetEntityIdentifiers() != null
? resolveBatchTargetEntities(domainRel.targetTemplateIdentifier(),
domainRel.targetEntityIdentifiers())
: Set.of());
if (hasRelationChanged(existingRel, domainRel)) {
existingRel.setTargetTemplateIdentifier(domainRel.targetTemplateIdentifier());
Set<RelationTargetJpaEntity> newTargetEntities = domainRel
.targetEntityIdentifiers() != null
? resolveBatchTargetEntities(domainRel.targetTemplateIdentifier(),
domainRel.targetEntityIdentifiers())
: Set.of();
existingRel.getTargetEntities()
.removeIf(target -> !newTargetEntities.contains(target));

newTargetEntities.stream()
.filter(target -> !existingRel.getTargetEntities().contains(target))
.forEach(existingRel.getTargetEntities()::add);
}
}, () -> existing.getRelations().add(toJpa(domainRel)));
}
}

/// Detects if a relation has actually changed by comparing template identifier
/// and target entities. Returns true only if there are actual differences,
/// preventing unnecessary updates to the database and Envers audit records.
boolean hasRelationChanged(RelationJpaEntity existingRel, Relation domainRel) {
Comment thread
evebrnd marked this conversation as resolved.
if (!Objects.equals(existingRel.getTargetTemplateIdentifier(),
domainRel.targetTemplateIdentifier())) {
return true;
}

Set<RelationTargetJpaEntity> newTargetEntities = domainRel.targetEntityIdentifiers() != null
? resolveBatchTargetEntities(domainRel.targetTemplateIdentifier(),
domainRel.targetEntityIdentifiers())
: Set.of();

var existingTargets = Objects.requireNonNullElse(existingRel.getTargetEntities(),
Set.<RelationTargetJpaEntity>of());
if (existingTargets.size() != newTargetEntities.size()) {
return true;
}

return !existingTargets.equals(newTargetEntities);
}

// =========================================================================
// Property Mapping
// =========================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;

import org.hibernate.envers.Audited;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Embeddable
@Data
@Audited
@NoArgsConstructor
@AllArgsConstructor
public class RelationTargetJpaEntity {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- Flyway migration script: Remove unused modified flag columns from relation_target_entities_aud
-- Purpose: Hibernate Envers does not properly support withModifiedFlag for embeddables
-- within ElementCollections. These columns were never being populated correctly.
-- The collection-level modified flag (target_entities_mod in relation_aud) is sufficient
-- to track when the relation targets change.

ALTER TABLE relation_target_entities_aud
DROP COLUMN IF EXISTS relation_id_mod;

ALTER TABLE relation_target_entities_aud
DROP COLUMN IF EXISTS target_entity_identifier_mod;

ALTER TABLE relation_target_entities_aud
DROP COLUMN IF EXISTS target_entity_uuid_mod;

-- Add table comment for documentation
COMMENT ON TABLE relation_target_entities_aud IS 'Audit table for relation target entities. Modified flags removed as they are not supported for ElementCollection embeddables.';

ALTER TABLE entity_properties_aud
DROP COLUMN IF EXISTS entity_id_mod;

ALTER TABLE entity_properties_aud
DROP COLUMN IF EXISTS property_id_mod;

-- Add table comment for documentation
COMMENT ON TABLE entity_properties_aud IS 'Audit table for entity properties. Modified flags removed as they are not supported for ElementCollection embeddables.';

ALTER TABLE entity_relations_aud
DROP COLUMN IF EXISTS entity_id_mod;

ALTER TABLE entity_relations_aud
DROP COLUMN IF EXISTS relation_id_mod;

-- Add table comment for documentation
COMMENT ON TABLE entity_relations_aud IS 'Audit table for entity relations. Modified flags removed as they are not supported for ElementCollection embeddables.';

ALTER TABLE entity_template_properties_definitions_aud
DROP COLUMN IF EXISTS entity_template_id_mod;

ALTER TABLE entity_template_properties_definitions_aud
DROP COLUMN IF EXISTS properties_definitions_id_mod;

-- Add table comment for documentation
COMMENT ON TABLE entity_template_properties_definitions_aud IS 'Audit table for entity template properties definitions. Modified flags removed as they are not supported for ElementCollection embeddables.';


ALTER TABLE entity_template_relations_definitions_aud
DROP COLUMN IF EXISTS entity_template_id_mod;

ALTER TABLE entity_template_relations_definitions_aud
DROP COLUMN IF EXISTS relations_definitions_id_mod;

-- Add table comment for documentation
COMMENT ON TABLE entity_template_relations_definitions_aud IS 'Audit table for entity template relations definitions. Modified flags removed as they are not supported for ElementCollection embeddables.';
Loading
Loading