From 57abf4fbb813f51403111c9f6658b9081bb49e38 Mon Sep 17 00:00:00 2001 From: "Donald F. Coffin" Date: Fri, 9 Jan 2026 18:02:36 -0500 Subject: [PATCH] refactor: Phase 9 - AggregatedNodeRef ESPI 4.0 schema compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ESPI 4.0 schema compliance for AggregatedNodeRef (espi.xsd:1570-1601): Key Changes: - Changed ID from UUID to Long (extends Object, not IdentifiedObject) - Fixed PnodeRef relationship: @ManyToOne → @ManyToMany with join table (Per XSD line 1597: pnodeRef has minOccurs="0" maxOccurs="unbounded") - Moved table creation to vendor-specific V2 migrations (auto-increment) - Removed 13 non-indexed repository queries, kept only 2 indexed queries - Removed updateEntity mapper method (read-only operations) Entity Changes: - AggregatedNodeRefEntity: UUID id → Long id, PnodeRefEntity → List - Added @ManyToMany join table: aggregated_node_ref_pnode_refs DTO Changes: - AggregatedNodeRefDto: PnodeRefDto → List - Updated all constructors and factory methods Database Changes: - Removed aggregated_node_refs from V3 (vendor-neutral) - Added to V2 MySQL/PostgreSQL/H2 with proper BIGINT AUTO_INCREMENT/BIGSERIAL - Added aggregated_node_ref_pnode_refs join table with composite primary key Tests: - Updated AggregatedNodeRefRepositoryTest with comprehensive test coverage - All 537 tests pass Co-Authored-By: Claude Sonnet 4.5 --- .../domain/usage/AggregatedNodeRefEntity.java | 51 ++-- .../dto/usage/AggregatedNodeRefDto.java | 58 ++-- .../dto/usage/AggregatedNodeRefsDto.java | 6 +- .../mapper/usage/AggregatedNodeRefMapper.java | 14 +- .../usage/AggregatedNodeRefRepository.java | 128 +-------- .../V3__Create_additiional_Base_Tables.sql | 42 +-- .../db/vendor/h2/V2__H2_Specific_Tables.sql | 32 +++ .../mysql/V2__MySQL_Specific_Tables.sql | 32 +++ .../V2__PostgreSQL_Specific_Tables.sql | 32 +++ .../AggregatedNodeRefRepositoryTest.java | 262 +++++++++++++++++- 10 files changed, 436 insertions(+), 221 deletions(-) diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/AggregatedNodeRefEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/AggregatedNodeRefEntity.java index cbea251d..f79d43b8 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/AggregatedNodeRefEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/usage/AggregatedNodeRefEntity.java @@ -23,12 +23,11 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.proxy.HibernateProxy; -import org.hibernate.type.SqlTypes; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; -import java.util.UUID; /** * JPA entity for AggregatedNodeRef (Aggregated Node Reference). @@ -48,12 +47,12 @@ public class AggregatedNodeRefEntity { /** * Primary key identifier. + * AggregatedNodeRef extends Object (not IdentifiedObject), so uses Long ID. */ @Id - @GeneratedValue(strategy = GenerationType.UUID) - @JdbcTypeCode(SqlTypes.CHAR) - @Column(length = 36, columnDefinition = "char(36)", updatable = false, nullable = false) - private UUID id; + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(updatable = false, nullable = false) + private Long id; /** * Type of the aggregated node. @@ -84,12 +83,16 @@ public class AggregatedNodeRefEntity { private Long endEffectiveDate; /** - * Associated pricing node reference for this aggregated node. - * Each aggregated node references an underlying pricing node. + * Associated pricing node references for this aggregated node. + * Per ESPI 4.0 XSD (espi.xsd:1597), each aggregated node can reference 0 to many pricing nodes. */ - @ManyToOne(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE}) - @JoinColumn(name = "pnode_ref_id") - private PnodeRefEntity pnodeRef; + @ManyToMany(fetch = FetchType.LAZY, cascade = {CascadeType.PERSIST, CascadeType.MERGE}) + @JoinTable( + name = "aggregated_node_ref_pnode_refs", + joinColumns = @JoinColumn(name = "aggregated_node_ref_id"), + inverseJoinColumns = @JoinColumn(name = "pnode_ref_id") + ) + private List pnodeRefs = new ArrayList<>(); /** * Usage point that owns this aggregated node reference. @@ -102,21 +105,21 @@ public class AggregatedNodeRefEntity { /** * Constructor with all fields. */ - public AggregatedNodeRefEntity(String anodeType, String ref, Long startEffectiveDate, Long endEffectiveDate, - PnodeRefEntity pnodeRef, UsagePointEntity usagePoint) { + public AggregatedNodeRefEntity(String anodeType, String ref, Long startEffectiveDate, Long endEffectiveDate, + List pnodeRefs, UsagePointEntity usagePoint) { this.anodeType = anodeType; this.ref = ref; this.startEffectiveDate = startEffectiveDate; this.endEffectiveDate = endEffectiveDate; - this.pnodeRef = pnodeRef; + this.pnodeRefs = pnodeRefs != null ? pnodeRefs : new ArrayList<>(); this.usagePoint = usagePoint; } /** * Constructor with basic fields. */ - public AggregatedNodeRefEntity(String anodeType, String ref, PnodeRefEntity pnodeRef, UsagePointEntity usagePoint) { - this(anodeType, ref, null, null, pnodeRef, usagePoint); + public AggregatedNodeRefEntity(String anodeType, String ref, List pnodeRefs, UsagePointEntity usagePoint) { + this(anodeType, ref, null, null, pnodeRefs, usagePoint); } /** @@ -143,14 +146,18 @@ public String getDisplayName() { } /** - * Gets display name including the associated pricing node. - * - * @return formatted display name with pricing node + * Gets display name including the associated pricing nodes. + * + * @return formatted display name with pricing nodes */ public String getFullDisplayName() { String aggregatedDisplay = getDisplayName(); - if (pnodeRef != null) { - return aggregatedDisplay + " -> " + pnodeRef.getDisplayName(); + if (pnodeRefs != null && !pnodeRefs.isEmpty()) { + String pnodeNames = pnodeRefs.stream() + .map(PnodeRefEntity::getDisplayName) + .reduce((a, b) -> a + ", " + b) + .orElse(""); + return aggregatedDisplay + " -> [" + pnodeNames + "]"; } return aggregatedDisplay; } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefDto.java index 567bf80c..ae7f6a7f 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefDto.java @@ -21,12 +21,17 @@ import jakarta.xml.bind.annotation.*; +import java.util.ArrayList; +import java.util.List; + /** * AggregatedNodeRef DTO record for JAXB XML marshalling/unmarshalling. - * + * * Represents a reference to an aggregated node in the electrical grid. * Used within UsagePoint to specify aggregated pricing/load zones. - * + * + * Per ESPI 4.0 XSD (espi.xsd:1597), pnodeRef has minOccurs="0" maxOccurs="unbounded" + * * Part of the NAESB ESPI UsagePoint structure for aggregated node references. */ @XmlAccessorType(XmlAccessType.PROPERTY) @@ -34,12 +39,12 @@ "anodeType", "ref", "startEffectiveDate", "endEffectiveDate", "pnodeRef" }) public record AggregatedNodeRefDto( - + String anodeType, String ref, Long startEffectiveDate, Long endEffectiveDate, - PnodeRefDto pnodeRef + List pnodeRef ) { /** @@ -78,32 +83,33 @@ public Long getEndEffectiveDate() { } /** - * Pricing node reference associated with this aggregated node. - * Contains the underlying pricing node that contributes to the aggregated node. + * Pricing node references associated with this aggregated node. + * Contains the underlying pricing nodes that contribute to the aggregated node. + * Per ESPI 4.0 XSD (espi.xsd:1597), supports 0 to many pricing node references. */ @XmlElement(name = "pnodeRef") - public PnodeRefDto getPnodeRef() { - return pnodeRef; + public List getPnodeRef() { + return pnodeRef != null ? pnodeRef : new ArrayList<>(); } - + /** * Default constructor for JAXB. */ public AggregatedNodeRefDto() { - this(null, null, null, null, null); + this(null, null, null, null, new ArrayList<>()); } - + /** * Constructor with aggregated node reference and type. */ public AggregatedNodeRefDto(String anodeType, String ref) { - this(anodeType, ref, null, null, null); + this(anodeType, ref, null, null, new ArrayList<>()); } - + /** - * Constructor with aggregated node reference, type, and pricing node. + * Constructor with aggregated node reference, type, and pricing nodes. */ - public AggregatedNodeRefDto(String anodeType, String ref, PnodeRefDto pnodeRef) { + public AggregatedNodeRefDto(String anodeType, String ref, List pnodeRef) { this(anodeType, ref, null, null, pnodeRef); } @@ -120,40 +126,40 @@ public boolean isValid() { /** * Creates an AggregatedNodeRef with current validity period. - * + * * @param anodeType the type of aggregated node * @param ref aggregated node reference * @return AggregatedNodeRef valid from now */ public static AggregatedNodeRefDto createCurrent(String anodeType, String ref) { long currentTime = System.currentTimeMillis() / 1000; - return new AggregatedNodeRefDto(anodeType, ref, currentTime, null, null); + return new AggregatedNodeRefDto(anodeType, ref, currentTime, null, new ArrayList<>()); } - + /** - * Creates an AggregatedNodeRef with current validity period and pricing node reference. - * + * Creates an AggregatedNodeRef with current validity period and pricing node references. + * * @param anodeType the type of aggregated node * @param ref aggregated node reference - * @param pnodeRef associated pricing node reference + * @param pnodeRef associated pricing node references * @return AggregatedNodeRef valid from now */ - public static AggregatedNodeRefDto createCurrent(String anodeType, String ref, PnodeRefDto pnodeRef) { + public static AggregatedNodeRefDto createCurrent(String anodeType, String ref, List pnodeRef) { long currentTime = System.currentTimeMillis() / 1000; return new AggregatedNodeRefDto(anodeType, ref, currentTime, null, pnodeRef); } - + /** * Creates an AggregatedNodeRef with specified validity period. - * + * * @param anodeType the type of aggregated node * @param ref aggregated node reference * @param startEffectiveDate start of validity period (epoch seconds) * @param endEffectiveDate end of validity period (epoch seconds, null for indefinite) - * @param pnodeRef associated pricing node reference + * @param pnodeRef associated pricing node references * @return AggregatedNodeRef with specified validity */ - public static AggregatedNodeRefDto create(String anodeType, String ref, Long startEffectiveDate, Long endEffectiveDate, PnodeRefDto pnodeRef) { + public static AggregatedNodeRefDto create(String anodeType, String ref, Long startEffectiveDate, Long endEffectiveDate, List pnodeRef) { return new AggregatedNodeRefDto(anodeType, ref, startEffectiveDate, endEffectiveDate, pnodeRef); } } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefsDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefsDto.java index 9c9cc30a..0adb7bd7 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefsDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/usage/AggregatedNodeRefsDto.java @@ -147,10 +147,10 @@ public static AggregatedNodeRefsDto createLoadZoneRefs() { // Create pricing node references for the load zones PnodeRefDto northPnode = PnodeRefDto.createCurrent("HUB", "NORTH_HUB_PNODE"); PnodeRefDto southPnode = PnodeRefDto.createCurrent("HUB", "SOUTH_HUB_PNODE"); - + return new AggregatedNodeRefsDto( - AggregatedNodeRefDto.createCurrent("LOAD_ZONE", "LOAD_ZONE_NORTH", northPnode), - AggregatedNodeRefDto.createCurrent("LOAD_ZONE", "LOAD_ZONE_SOUTH", southPnode) + AggregatedNodeRefDto.createCurrent("LOAD_ZONE", "LOAD_ZONE_NORTH", List.of(northPnode)), + AggregatedNodeRefDto.createCurrent("LOAD_ZONE", "LOAD_ZONE_SOUTH", List.of(southPnode)) ); } } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/AggregatedNodeRefMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/AggregatedNodeRefMapper.java index d8f44617..9c6ca3a1 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/AggregatedNodeRefMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/AggregatedNodeRefMapper.java @@ -23,7 +23,6 @@ import org.greenbuttonalliance.espi.common.dto.usage.AggregatedNodeRefDto; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; /** * MapStruct mapper for converting between AggregatedNodeRefEntity and AggregatedNodeRefDto. @@ -41,7 +40,7 @@ public interface AggregatedNodeRefMapper { @Mapping(target = "ref", source = "ref") @Mapping(target = "startEffectiveDate", source = "startEffectiveDate") @Mapping(target = "endEffectiveDate", source = "endEffectiveDate") - @Mapping(target = "pnodeRef", source = "pnodeRef") + @Mapping(target = "pnodeRef", source = "pnodeRefs") AggregatedNodeRefDto toDto(AggregatedNodeRefEntity entity); /** @@ -56,16 +55,7 @@ public interface AggregatedNodeRefMapper { @Mapping(target = "ref", source = "ref") @Mapping(target = "startEffectiveDate", source = "startEffectiveDate") @Mapping(target = "endEffectiveDate", source = "endEffectiveDate") - @Mapping(target = "pnodeRef", source = "pnodeRef") + @Mapping(target = "pnodeRefs", source = "pnodeRef") AggregatedNodeRefEntity toEntity(AggregatedNodeRefDto dto); - /** - * Updates an existing AggregatedNodeRefEntity with data from an AggregatedNodeRefDto. - * - * @param dto the source DTO - * @param entity the target entity to update - */ - @Mapping(target = "id", ignore = true) - @Mapping(target = "usagePoint", ignore = true) - void updateEntity(AggregatedNodeRefDto dto, @MappingTarget AggregatedNodeRefEntity entity); } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepository.java index 941becf8..80334356 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepository.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepository.java @@ -20,8 +20,6 @@ package org.greenbuttonalliance.espi.common.repositories.usage; import org.greenbuttonalliance.espi.common.domain.usage.AggregatedNodeRefEntity; -import org.greenbuttonalliance.espi.common.domain.usage.PnodeRefEntity; -import org.greenbuttonalliance.espi.common.domain.usage.UsagePointEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -32,126 +30,16 @@ /** * Spring Data JPA repository for AggregatedNodeRefEntity. - * - * Provides CRUD operations and custom queries for aggregated node references. + *

+ * AggregatedNodeRef extends Object (not IdentifiedObject) in ESPI 4.0 XSD, + * so it uses Long ID (not UUID). */ @Repository -public interface AggregatedNodeRefRepository extends JpaRepository { +public interface AggregatedNodeRefRepository extends JpaRepository { - /** - * Find all aggregated node references for a specific usage point. - * - * @param usagePoint the usage point - * @return list of aggregated node references - */ - List findByUsagePoint(UsagePointEntity usagePoint); + @Query("SELECT a.id FROM AggregatedNodeRefEntity a") + List findAllIds(); - /** - * Find aggregated node references by usage point ID. - * - * @param usagePointId the usage point ID - * @return list of aggregated node references - */ - List findByUsagePointId(UUID usagePointId); - - /** - * Find aggregated node references by type. - * - * @param anodeType the aggregated node type - * @return list of aggregated node references - */ - List findByAnodeType(String anodeType); - - /** - * Find aggregated node references by usage point and type. - * - * @param usagePoint the usage point - * @param anodeType the aggregated node type - * @return list of aggregated node references - */ - List findByUsagePointAndAnodeType(UsagePointEntity usagePoint, String anodeType); - - /** - * Find aggregated node references by reference identifier. - * - * @param ref the reference identifier - * @return list of aggregated node references - */ - List findByRef(String ref); - - /** - * Find aggregated node references by associated pricing node reference. - * - * @param pnodeRef the pricing node reference - * @return list of aggregated node references - */ - List findByPnodeRef(PnodeRefEntity pnodeRef); - - /** - * Find aggregated node references by pricing node reference ID. - * - * @param pnodeRefId the pricing node reference ID - * @return list of aggregated node references - */ - List findByPnodeRefId(UUID pnodeRefId); - - /** - * Find currently valid aggregated node references for a usage point. - * - * @param usagePointId the usage point ID - * @param currentTime current time in epoch seconds - * @return list of valid aggregated node references - */ - @Query("SELECT a FROM AggregatedNodeRefEntity a WHERE a.usagePoint.id = :usagePointId " + - "AND (a.startEffectiveDate IS NULL OR a.startEffectiveDate <= :currentTime) " + - "AND (a.endEffectiveDate IS NULL OR a.endEffectiveDate >= :currentTime)") - List findValidByUsagePointId(@Param("usagePointId") UUID usagePointId, - @Param("currentTime") Long currentTime); - - /** - * Find currently valid aggregated node references by type. - * - * @param anodeType the aggregated node type - * @param currentTime current time in epoch seconds - * @return list of valid aggregated node references - */ - @Query("SELECT a FROM AggregatedNodeRefEntity a WHERE a.anodeType = :anodeType " + - "AND (a.startEffectiveDate IS NULL OR a.startEffectiveDate <= :currentTime) " + - "AND (a.endEffectiveDate IS NULL OR a.endEffectiveDate >= :currentTime)") - List findValidByAnodeType(@Param("anodeType") String anodeType, - @Param("currentTime") Long currentTime); - - /** - * Find aggregated node references with their pricing node references (fetch join). - * - * @param usagePointId the usage point ID - * @return list of aggregated node references with pricing nodes loaded - */ - @Query("SELECT a FROM AggregatedNodeRefEntity a LEFT JOIN FETCH a.pnodeRef " + - "WHERE a.usagePoint.id = :usagePointId") - List findByUsagePointIdWithPnodeRef(@Param("usagePointId") UUID usagePointId); - - /** - * Delete all aggregated node references for a usage point. - * - * @param usagePoint the usage point - * @return number of deleted records - */ - Long deleteByUsagePoint(UsagePointEntity usagePoint); - - /** - * Delete aggregated node references by usage point ID. - * - * @param usagePointId the usage point ID - * @return number of deleted records - */ - Long deleteByUsagePointId(UUID usagePointId); - - /** - * Delete aggregated node references by pricing node reference. - * - * @param pnodeRef the pricing node reference - * @return number of deleted records - */ - Long deleteByPnodeRef(PnodeRefEntity pnodeRef); + @Query("SELECT a FROM AggregatedNodeRefEntity a WHERE a.usagePoint.id = :usagePointId") + List findAllByUsagePointId(@Param("usagePointId") UUID usagePointId); } \ No newline at end of file diff --git a/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql b/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql index 073c30d2..e8d0868e 100644 --- a/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql +++ b/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql @@ -212,42 +212,12 @@ ALTER TABLE usage_points ADD CONSTRAINT fk_usage_point_subscription -- PnodeRef extends Object (not IdentifiedObject) per ESPI 4.0 XSD (espi.xsd:1539) -- Table creation moved to V2 vendor files due to auto-increment syntax differences --- AggregatedNodeRef Table (from V1_9 migration) -CREATE TABLE aggregated_node_refs -( - id CHAR(36) PRIMARY KEY , - description VARCHAR(255), - created TIMESTAMP, - updated TIMESTAMP, - published TIMESTAMP, - up_link_rel VARCHAR(255), - up_link_href VARCHAR(1024), - up_link_type VARCHAR(255), - self_link_rel VARCHAR(255), - self_link_href VARCHAR(1024), - self_link_type VARCHAR(255), - - -- AggregatedNodeRef specific fields - anode_type VARCHAR(64), - ref VARCHAR(256) NOT NULL, - start_effective_date BIGINT, - end_effective_date BIGINT, - - -- Foreign key relationships - pnode_ref_id BIGINT, - usage_point_id CHAR(36) NOT NULL, - - FOREIGN KEY (pnode_ref_id) REFERENCES pnode_refs (id) ON DELETE SET NULL, - FOREIGN KEY (usage_point_id) REFERENCES usage_points (id) ON DELETE CASCADE -); - --- Indexes for aggregated_node_refs table -CREATE INDEX idx_aggregated_node_ref_anode_type ON aggregated_node_refs (anode_type); -CREATE INDEX idx_aggregated_node_ref_ref ON aggregated_node_refs (ref); -CREATE INDEX idx_aggregated_node_ref_pnode_ref_id ON aggregated_node_refs (pnode_ref_id); -CREATE INDEX idx_aggregated_node_ref_usage_point_id ON aggregated_node_refs (usage_point_id); -CREATE INDEX idx_aggregated_node_ref_created ON aggregated_node_refs (created); -CREATE INDEX idx_aggregated_node_ref_updated ON aggregated_node_refs (updated); +-- AggregatedNodeRef Table - Moved to vendor-specific V2 migration files +-- AggregatedNodeRef extends Object (not IdentifiedObject) per ESPI 4.0 XSD (espi.xsd:1570) +-- Table creation moved to V2 vendor files due to auto-increment syntax differences +-- See: db/vendor/mysql/V2__MySQL_Specific_Tables.sql +-- db/vendor/postgres/V2__PostgreSQL_Specific_Tables.sql +-- db/vendor/h2/V2__H2_Specific_Tables.sql -- Customer Table CREATE TABLE customers diff --git a/openespi-common/src/main/resources/db/vendor/h2/V2__H2_Specific_Tables.sql b/openespi-common/src/main/resources/db/vendor/h2/V2__H2_Specific_Tables.sql index 1de5c8e5..712f8004 100644 --- a/openespi-common/src/main/resources/db/vendor/h2/V2__H2_Specific_Tables.sql +++ b/openespi-common/src/main/resources/db/vendor/h2/V2__H2_Specific_Tables.sql @@ -180,6 +180,38 @@ CREATE INDEX idx_pnode_ref_apnode_type ON pnode_refs (apnode_type); CREATE INDEX idx_pnode_ref_ref ON pnode_refs (ref); CREATE INDEX idx_pnode_ref_usage_point_id ON pnode_refs (usage_point_id); +-- AggregatedNodeRef Table (Object-based entity, no IdentifiedObject) +-- Must be created after usage_points which it references +-- AggregatedNodeRef extends Object per ESPI 4.0 XSD (espi.xsd:1570) +CREATE TABLE aggregated_node_refs +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + anode_type VARCHAR(64), + ref VARCHAR(256) NOT NULL, + start_effective_date BIGINT, + end_effective_date BIGINT, + usage_point_id CHAR(36) NOT NULL, + FOREIGN KEY (usage_point_id) REFERENCES usage_points (id) ON DELETE CASCADE +); + +CREATE INDEX idx_aggregated_node_ref_anode_type ON aggregated_node_refs (anode_type); +CREATE INDEX idx_aggregated_node_ref_ref ON aggregated_node_refs (ref); +CREATE INDEX idx_aggregated_node_ref_usage_point_id ON aggregated_node_refs (usage_point_id); + +-- AggregatedNodeRef to PnodeRef Join Table (Many-to-Many) +-- Per ESPI 4.0 XSD (espi.xsd:1597), pnodeRef has minOccurs="0" maxOccurs="unbounded" +CREATE TABLE aggregated_node_ref_pnode_refs +( + aggregated_node_ref_id BIGINT NOT NULL, + pnode_ref_id BIGINT NOT NULL, + PRIMARY KEY (aggregated_node_ref_id, pnode_ref_id), + FOREIGN KEY (aggregated_node_ref_id) REFERENCES aggregated_node_refs (id) ON DELETE CASCADE, + FOREIGN KEY (pnode_ref_id) REFERENCES pnode_refs (id) ON DELETE CASCADE +); + +CREATE INDEX idx_agg_node_ref_pnode_refs_agg ON aggregated_node_ref_pnode_refs (aggregated_node_ref_id); +CREATE INDEX idx_agg_node_ref_pnode_refs_pnode ON aggregated_node_ref_pnode_refs (pnode_ref_id); + -- Meter Reading Table CREATE TABLE meter_readings ( diff --git a/openespi-common/src/main/resources/db/vendor/mysql/V2__MySQL_Specific_Tables.sql b/openespi-common/src/main/resources/db/vendor/mysql/V2__MySQL_Specific_Tables.sql index c700730c..45e2a69c 100644 --- a/openespi-common/src/main/resources/db/vendor/mysql/V2__MySQL_Specific_Tables.sql +++ b/openespi-common/src/main/resources/db/vendor/mysql/V2__MySQL_Specific_Tables.sql @@ -191,6 +191,38 @@ CREATE INDEX idx_pnode_ref_apnode_type ON pnode_refs (apnode_type); CREATE INDEX idx_pnode_ref_ref ON pnode_refs (ref); CREATE INDEX idx_pnode_ref_usage_point_id ON pnode_refs (usage_point_id); +-- AggregatedNodeRef Table (Object-based entity, no IdentifiedObject) +-- Must be created after usage_points which it references +-- AggregatedNodeRef extends Object per ESPI 4.0 XSD (espi.xsd:1570) +CREATE TABLE aggregated_node_refs +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + anode_type VARCHAR(64), + ref VARCHAR(256) NOT NULL, + start_effective_date BIGINT, + end_effective_date BIGINT, + usage_point_id CHAR(36) NOT NULL, + FOREIGN KEY (usage_point_id) REFERENCES usage_points (id) ON DELETE CASCADE +); + +CREATE INDEX idx_aggregated_node_ref_anode_type ON aggregated_node_refs (anode_type); +CREATE INDEX idx_aggregated_node_ref_ref ON aggregated_node_refs (ref); +CREATE INDEX idx_aggregated_node_ref_usage_point_id ON aggregated_node_refs (usage_point_id); + +-- AggregatedNodeRef to PnodeRef Join Table (Many-to-Many) +-- Per ESPI 4.0 XSD (espi.xsd:1597), pnodeRef has minOccurs="0" maxOccurs="unbounded" +CREATE TABLE aggregated_node_ref_pnode_refs +( + aggregated_node_ref_id BIGINT NOT NULL, + pnode_ref_id BIGINT NOT NULL, + PRIMARY KEY (aggregated_node_ref_id, pnode_ref_id), + FOREIGN KEY (aggregated_node_ref_id) REFERENCES aggregated_node_refs (id) ON DELETE CASCADE, + FOREIGN KEY (pnode_ref_id) REFERENCES pnode_refs (id) ON DELETE CASCADE +); + +CREATE INDEX idx_agg_node_ref_pnode_refs_agg ON aggregated_node_ref_pnode_refs (aggregated_node_ref_id); +CREATE INDEX idx_agg_node_ref_pnode_refs_pnode ON aggregated_node_ref_pnode_refs (pnode_ref_id); + -- Meter Reading Table CREATE TABLE meter_readings ( diff --git a/openespi-common/src/main/resources/db/vendor/postgres/V2__PostgreSQL_Specific_Tables.sql b/openespi-common/src/main/resources/db/vendor/postgres/V2__PostgreSQL_Specific_Tables.sql index c1a54778..2a8aeab9 100644 --- a/openespi-common/src/main/resources/db/vendor/postgres/V2__PostgreSQL_Specific_Tables.sql +++ b/openespi-common/src/main/resources/db/vendor/postgres/V2__PostgreSQL_Specific_Tables.sql @@ -177,6 +177,38 @@ CREATE INDEX idx_pnode_ref_apnode_type ON pnode_refs (apnode_type); CREATE INDEX idx_pnode_ref_ref ON pnode_refs (ref); CREATE INDEX idx_pnode_ref_usage_point_id ON pnode_refs (usage_point_id); +-- AggregatedNodeRef Table (Object-based entity, no IdentifiedObject) +-- Must be created after usage_points which it references +-- AggregatedNodeRef extends Object per ESPI 4.0 XSD (espi.xsd:1570) +CREATE TABLE aggregated_node_refs +( + id BIGSERIAL PRIMARY KEY, + anode_type VARCHAR(64), + ref VARCHAR(256) NOT NULL, + start_effective_date BIGINT, + end_effective_date BIGINT, + usage_point_id CHAR(36) NOT NULL, + FOREIGN KEY (usage_point_id) REFERENCES usage_points (id) ON DELETE CASCADE +); + +CREATE INDEX idx_aggregated_node_ref_anode_type ON aggregated_node_refs (anode_type); +CREATE INDEX idx_aggregated_node_ref_ref ON aggregated_node_refs (ref); +CREATE INDEX idx_aggregated_node_ref_usage_point_id ON aggregated_node_refs (usage_point_id); + +-- AggregatedNodeRef to PnodeRef Join Table (Many-to-Many) +-- Per ESPI 4.0 XSD (espi.xsd:1597), pnodeRef has minOccurs="0" maxOccurs="unbounded" +CREATE TABLE aggregated_node_ref_pnode_refs +( + aggregated_node_ref_id BIGINT NOT NULL, + pnode_ref_id BIGINT NOT NULL, + PRIMARY KEY (aggregated_node_ref_id, pnode_ref_id), + FOREIGN KEY (aggregated_node_ref_id) REFERENCES aggregated_node_refs (id) ON DELETE CASCADE, + FOREIGN KEY (pnode_ref_id) REFERENCES pnode_refs (id) ON DELETE CASCADE +); + +CREATE INDEX idx_agg_node_ref_pnode_refs_agg ON aggregated_node_ref_pnode_refs (aggregated_node_ref_id); +CREATE INDEX idx_agg_node_ref_pnode_refs_pnode ON aggregated_node_ref_pnode_refs (pnode_ref_id); + -- Meter Reading Table CREATE TABLE meter_readings ( diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepositoryTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepositoryTest.java index 96dfcce9..6a5b160c 100644 --- a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepositoryTest.java +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/repositories/usage/AggregatedNodeRefRepositoryTest.java @@ -28,14 +28,17 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.UUID; import static org.assertj.core.api.Assertions.*; /** * Comprehensive test suite for AggregatedNodeRefRepository. + * + * Tests all CRUD operations, 2 custom query methods, relationships, + * and validation constraints for AggregatedNodeRef entities. */ @DisplayName("AggregatedNodeRef Repository Tests") class AggregatedNodeRefRepositoryTest extends BaseRepositoryTest { @@ -59,10 +62,14 @@ void shouldSaveAndRetrieveAggregatedNodeRefSuccessfully() { // Arrange UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); - + + PnodeRefEntity pnodeRef = new PnodeRefEntity("HUB", "TEST_HUB", savedUsagePoint); + PnodeRefEntity savedPnodeRef = pnodeRefRepository.save(pnodeRef); + AggregatedNodeRefEntity aggregatedNodeRef = new AggregatedNodeRefEntity(); aggregatedNodeRef.setAnodeType("LOAD_ZONE"); aggregatedNodeRef.setRef("TEST_AGGREGATE_NODE"); + aggregatedNodeRef.setPnodeRefs(List.of(savedPnodeRef)); aggregatedNodeRef.setUsagePoint(savedUsagePoint); // Act @@ -76,6 +83,257 @@ void shouldSaveAndRetrieveAggregatedNodeRefSuccessfully() { assertThat(retrieved).isPresent(); assertThat(retrieved.get().getAnodeType()).isEqualTo("LOAD_ZONE"); assertThat(retrieved.get().getRef()).isEqualTo("TEST_AGGREGATE_NODE"); + assertThat(retrieved.get().getUsagePoint().getId()).isEqualTo(savedUsagePoint.getId()); + } + + @Test + @DisplayName("Should save aggregated node ref with effective dates") + void shouldSaveAggregatedNodeRefWithEffectiveDates() { + // Arrange + UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); + + PnodeRefEntity pnodeRef = new PnodeRefEntity("HUB", "TEST_HUB", savedUsagePoint); + PnodeRefEntity savedPnodeRef = pnodeRefRepository.save(pnodeRef); + + AggregatedNodeRefEntity aggregatedNodeRef = new AggregatedNodeRefEntity(); + aggregatedNodeRef.setAnodeType("TRANSMISSION_ZONE"); + aggregatedNodeRef.setRef("ZONE_AGGREGATE"); + aggregatedNodeRef.setStartEffectiveDate(1640995200L); // 2022-01-01 00:00:00 UTC + aggregatedNodeRef.setEndEffectiveDate(1672531200L); // 2023-01-01 00:00:00 UTC + aggregatedNodeRef.setPnodeRefs(List.of(savedPnodeRef)); + aggregatedNodeRef.setUsagePoint(savedUsagePoint); + + // Act + AggregatedNodeRefEntity saved = aggregatedNodeRefRepository.save(aggregatedNodeRef); + flushAndClear(); + Optional retrieved = aggregatedNodeRefRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getStartEffectiveDate()).isEqualTo(1640995200L); + assertThat(retrieved.get().getEndEffectiveDate()).isEqualTo(1672531200L); + assertThat(retrieved.get().getAnodeType()).isEqualTo("TRANSMISSION_ZONE"); + assertThat(retrieved.get().getRef()).isEqualTo("ZONE_AGGREGATE"); + } + } + + @Nested + @DisplayName("Custom Query Methods") + class CustomQueryMethodsTest { + + @Test + @DisplayName("Should find all IDs") + void shouldFindAllIds() { + // Arrange + UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); + + AggregatedNodeRefEntity agg1 = new AggregatedNodeRefEntity(); + agg1.setAnodeType("LOAD_ZONE"); + agg1.setRef("ZONE_1"); + agg1.setUsagePoint(savedUsagePoint); + + AggregatedNodeRefEntity agg2 = new AggregatedNodeRefEntity(); + agg2.setAnodeType("TRANSMISSION_ZONE"); + agg2.setRef("ZONE_2"); + agg2.setUsagePoint(savedUsagePoint); + + AggregatedNodeRefEntity saved1 = aggregatedNodeRefRepository.save(agg1); + AggregatedNodeRefEntity saved2 = aggregatedNodeRefRepository.save(agg2); + flushAndClear(); + + // Act + List allIds = aggregatedNodeRefRepository.findAllIds(); + + // Assert + assertThat(allIds).contains(saved1.getId(), saved2.getId()); + } + + @Test + @DisplayName("Should find all aggregated node refs by usage point ID") + void shouldFindAllAggregatedNodeRefsByUsagePointId() { + // Arrange + UsagePointEntity usagePoint1 = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity usagePoint2 = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity savedUsagePoint1 = usagePointRepository.save(usagePoint1); + UsagePointEntity savedUsagePoint2 = usagePointRepository.save(usagePoint2); + + AggregatedNodeRefEntity agg1 = new AggregatedNodeRefEntity(); + agg1.setAnodeType("LOAD_ZONE"); + agg1.setRef("ZONE_1"); + agg1.setUsagePoint(savedUsagePoint1); + + AggregatedNodeRefEntity agg2 = new AggregatedNodeRefEntity(); + agg2.setAnodeType("LOAD_ZONE"); + agg2.setRef("ZONE_2"); + agg2.setUsagePoint(savedUsagePoint1); + + AggregatedNodeRefEntity agg3 = new AggregatedNodeRefEntity(); + agg3.setAnodeType("LOAD_ZONE"); + agg3.setRef("ZONE_3"); + agg3.setUsagePoint(savedUsagePoint2); + + aggregatedNodeRefRepository.save(agg1); + aggregatedNodeRefRepository.save(agg2); + aggregatedNodeRefRepository.save(agg3); + flushAndClear(); + + // Act + List aggregatedRefs = aggregatedNodeRefRepository.findAllByUsagePointId(savedUsagePoint1.getId()); + + // Assert + assertThat(aggregatedRefs).hasSize(2); + assertThat(aggregatedRefs).extracting(AggregatedNodeRefEntity::getRef) + .containsExactlyInAnyOrder("ZONE_1", "ZONE_2"); + } + } + + @Nested + @DisplayName("JPA Relationships") + class RelationshipsTest { + + @Test + @DisplayName("Should maintain usage point relationship") + void shouldMaintainUsagePointRelationship() { + // Arrange + UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); + usagePoint.setDescription("Aggregated Node Test Usage Point"); + UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); + + AggregatedNodeRefEntity aggregatedNodeRef = new AggregatedNodeRefEntity(); + aggregatedNodeRef.setAnodeType("DISTRIBUTION_ZONE"); + aggregatedNodeRef.setRef("DIST_ZONE_1"); + aggregatedNodeRef.setUsagePoint(savedUsagePoint); + + // Act + AggregatedNodeRefEntity saved = aggregatedNodeRefRepository.save(aggregatedNodeRef); + flushAndClear(); + Optional retrieved = aggregatedNodeRefRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getUsagePoint()).isNotNull(); + assertThat(retrieved.get().getUsagePoint().getId()).isEqualTo(savedUsagePoint.getId()); + assertThat(retrieved.get().getUsagePoint().getDescription()).isEqualTo("Aggregated Node Test Usage Point"); + } + + @Test + @DisplayName("Should maintain pricing node reference relationships") + void shouldMaintainPricingNodeReferenceRelationships() { + // Arrange + UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); + + PnodeRefEntity pnodeRef1 = new PnodeRefEntity("HUB", "CAISO_SP15_GEN_HUB", savedUsagePoint); + PnodeRefEntity pnodeRef2 = new PnodeRefEntity("LOAD_ZONE", "CAISO_SP15_LOAD_ZONE", savedUsagePoint); + PnodeRefEntity savedPnodeRef1 = pnodeRefRepository.save(pnodeRef1); + PnodeRefEntity savedPnodeRef2 = pnodeRefRepository.save(pnodeRef2); + + AggregatedNodeRefEntity aggregatedNodeRef = new AggregatedNodeRefEntity(); + aggregatedNodeRef.setAnodeType("LOAD_ZONE"); + aggregatedNodeRef.setRef("CAISO_SP15_AGGREGATE"); + aggregatedNodeRef.setPnodeRefs(List.of(savedPnodeRef1, savedPnodeRef2)); + aggregatedNodeRef.setUsagePoint(savedUsagePoint); + + // Act + AggregatedNodeRefEntity saved = aggregatedNodeRefRepository.save(aggregatedNodeRef); + flushAndClear(); + Optional retrieved = aggregatedNodeRefRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getPnodeRefs()).isNotNull(); + assertThat(retrieved.get().getPnodeRefs()).hasSize(2); + assertThat(retrieved.get().getPnodeRefs()).extracting(PnodeRefEntity::getRef) + .containsExactlyInAnyOrder("CAISO_SP15_GEN_HUB", "CAISO_SP15_LOAD_ZONE"); + } + } + + @Nested + @DisplayName("Business Logic") + class BusinessLogicTest { + + @Test + @DisplayName("Should validate aggregated node ref validity") + void shouldValidateAggregatedNodeRefValidity() { + // Arrange + UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); + + long currentTime = System.currentTimeMillis() / 1000; + long pastTime = currentTime - 3600; + long futureTime = currentTime + 3600; + + AggregatedNodeRefEntity validRef = new AggregatedNodeRefEntity(); + validRef.setAnodeType("LOAD_ZONE"); + validRef.setRef("VALID_REF"); + validRef.setStartEffectiveDate(pastTime); + validRef.setEndEffectiveDate(futureTime); + validRef.setUsagePoint(savedUsagePoint); + + AggregatedNodeRefEntity expiredRef = new AggregatedNodeRefEntity(); + expiredRef.setAnodeType("LOAD_ZONE"); + expiredRef.setRef("EXPIRED_REF"); + expiredRef.setStartEffectiveDate(pastTime); + expiredRef.setEndEffectiveDate(pastTime + 1800); + expiredRef.setUsagePoint(savedUsagePoint); + + // Act & Assert + assertThat(validRef.isValid()).isTrue(); + assertThat(expiredRef.isValid()).isFalse(); + } + + @Test + @DisplayName("Should generate display name correctly") + void shouldGenerateDisplayNameCorrectly() { + // Arrange + UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); + + AggregatedNodeRefEntity aggWithType = new AggregatedNodeRefEntity(); + aggWithType.setAnodeType("LOAD_ZONE"); + aggWithType.setRef("TEST_ZONE"); + aggWithType.setUsagePoint(savedUsagePoint); + + AggregatedNodeRefEntity aggWithoutType = new AggregatedNodeRefEntity(); + aggWithoutType.setAnodeType(null); + aggWithoutType.setRef("TEST_REF"); + aggWithoutType.setUsagePoint(savedUsagePoint); + + // Act & Assert + assertThat(aggWithType.getDisplayName()).isEqualTo("LOAD_ZONE:TEST_ZONE"); + assertThat(aggWithoutType.getDisplayName()).isEqualTo("TEST_REF"); + } + } + + @Nested + @DisplayName("Entity Persistence") + class EntityPersistenceTest { + + @Test + @DisplayName("Should persist and retrieve aggregated node reference") + void shouldPersistAndRetrieveAggregatedNodeRef() { + // Arrange + UsagePointEntity usagePoint = TestDataBuilders.createValidUsagePoint(); + UsagePointEntity savedUsagePoint = usagePointRepository.save(usagePoint); + + AggregatedNodeRefEntity aggregatedNodeRef = new AggregatedNodeRefEntity(); + aggregatedNodeRef.setAnodeType("LOAD_ZONE"); + aggregatedNodeRef.setRef("PERSISTENCE_TEST"); + aggregatedNodeRef.setUsagePoint(savedUsagePoint); + + // Act + AggregatedNodeRefEntity saved = aggregatedNodeRefRepository.save(aggregatedNodeRef); + flushAndClear(); + + // Assert + // AggregatedNodeRef extends Object (not IdentifiedObject) in ESPI 4.0 XSD, + // so it has Long ID but no Atom links or timestamps + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getAnodeType()).isEqualTo("LOAD_ZONE"); + assertThat(saved.getRef()).isEqualTo("PERSISTENCE_TEST"); + assertThat(saved.getUsagePoint().getId()).isEqualTo(savedUsagePoint.getId()); } } } \ No newline at end of file