Skip to content

Commit fe77497

Browse files
dfcoffinclaude
andauthored
feat: Issue #97 - Standardize relatedLinks Infrastructure with Composition Pattern (#99)
* docs: Update Phase 17 plan to include EndDevice and Meter in Phase A0 Extended Phase A0 (PREREQUISITE) to include bidirectional Atom links for EndDevice and Meter entities in addition to the original 4 entities: Previous (4 entities): - CustomerAgreement - ProgramDateIdMappings - ServiceLocation - ServiceSupplier Added (2 additional entities): - EndDevice - Meter (inherits from EndDevice) Investigation confirmed: - All 6 related_links database tables exist in V3 migration - All 6 entities lack @ElementCollection mappings for relatedLinks - Bidirectional relationships required: - ServiceLocation ↔ EndDevice - ServiceLocation ↔ Meter - CustomerAgreement ↔ ProgramDateIdMappings - CustomerAgreement ↔ ServiceLocation - CustomerAgreement ↔ ServiceSupplier Per NAESB ESPI 4.0 standard, these relationships are implemented via Atom <link rel="related"> elements, not via customer.xsd definitions. Related to #28 (Phase 17: ProgramDateIdMappings) * feat: Issue #97 - Standardize relatedLinks Infrastructure with Composition Pattern Implements Issue #97 to standardize relatedLinks infrastructure across all ESPI entities using @AssociationOverride and composition pattern with Lombok @DeleGate. Key Changes: - Converted Asset from @MappedSuperclass to @embeddable for composition - Created EndDeviceFields @embeddable for 4 EndDevice-specific fields - Refactored EndDeviceEntity and MeterEntity to use composition instead of inheritance - Both entities now embed Asset + EndDeviceFields with @DeleGate for transparent access - Added @AssociationOverride to 13 entities (4 usage domain + 9 customer domain) Entity Changes: - Usage Domain: ApplicationInformationEntity, AuthorizationEntity, ElectricPowerQualitySummaryEntity, ReadingTypeEntity - Customer Domain: CustomerEntity, CustomerAccountEntity, CustomerAgreementEntity, EndDeviceEntity, MeterEntity, ProgramDateIdMappingsEntity, ServiceLocationEntity, ServiceSupplierEntity, StatementEntity Mapper Updates: - EndDeviceMapper: Updated to map from nested embedded objects (asset.*, endDeviceFields.*) - MeterMapper: Updated to map from nested embedded objects (asset.*, endDeviceFields.*) - Required because MapStruct runs before Lombok @DeleGate generates delegation methods Database Migrations: - V1__Create_Base_Tables.sql: Added relatedLinks tables for usage domain entities - V3__Create_additiional_Base_Tables.sql: Added relatedLinks tables for customer domain, expanded meters table with all IdentifiedObject, Asset, and EndDeviceFields columns Technical Details: - Resolved Hibernate inheritance conflict where MeterEntity couldn't override relatedLinks - Used composition (HAS-A) instead of inheritance (IS-A) per NAESB ESPI 4.0 customer.xsd - Lombok @DeleGate provides transparent access to embedded fields for service layer - Each entity now has separate relatedLinks join table via @AssociationOverride Test Results: - All 760 unit tests passing - H2 in-memory database integration: 3 tests passing - MySQL TestContainers integration: 2 tests passing - PostgreSQL TestContainers integration: 59 tests passing - Total: 824+ tests passing across all database platforms Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * docs: Add toString() method sequencing guideline to CLAUDE.md Added JPA mapping guideline for entity toString() method sequencing to ensure consistency with database schema definitions. Guideline enforces that toString() methods must follow exact database field sequence from Flyway migration scripts: - Standard sequence: id, description, created, updated, published, upLink, selfLink, [type-specific fields in database column order], relatedLinks - Ensures toString() output matches CREATE TABLE statement column order - Improves debugging and log readability by maintaining schema alignment This guideline supports Issue #97 relatedLinks standardization work where consistent field ordering across entities is critical. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 410f4b5 commit fe77497

21 files changed

+1668
-266
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ This practice prevents issues like missing imports, broken references, and compi
288288
- Avoid `@Where` annotation conflicts with `@JoinColumn` on the same field
289289
- Be careful with `@ElementCollection` and `@CollectionTable` for embedded collections
290290
- Phone numbers and addresses are embedded collections using `@ElementCollection`
291+
- **toString() method sequencing**: Entity toString() methods MUST follow the exact database field sequence from Flyway migration scripts. Standard sequence is: id, description, created, updated, published, upLink, selfLink, [type-specific fields in database column order], relatedLinks (if present as last field). Always verify toString() matches the CREATE TABLE statement column order.
291292

292293
#### REST Controller Development
293294
- Controllers in `openespi-datacustodian` implement ESPI REST API

openespi-common/PHASE_17_PROGRAM_DATE_ID_MAPPINGS_IMPLEMENTATION_PLAN.md

Lines changed: 1194 additions & 0 deletions
Large diffs are not rendered by default.

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Asset.java

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,33 +20,26 @@
2020
package org.greenbuttonalliance.espi.common.domain.customer.entity;
2121

2222
import lombok.Data;
23-
import lombok.EqualsAndHashCode;
2423
import lombok.NoArgsConstructor;
25-
import lombok.ToString;
2624
import org.greenbuttonalliance.espi.common.domain.customer.common.ElectronicAddress;
2725

2826
import jakarta.persistence.*;
29-
import java.io.Serializable;
3027
import java.math.BigDecimal;
3128

3229
/**
33-
* Abstract base class for Asset without JAXB concerns.
34-
*
35-
* Tangible resource of the utility, including power system equipment, various end devices,
36-
* cabinets, buildings, etc. Asset description places emphasis on the physical characteristics
30+
* Embeddable component for Asset without JAXB concerns.
31+
*
32+
* Tangible resource of the utility, including power system equipment, various end devices,
33+
* cabinets, buildings, etc. Asset description places emphasis on the physical characteristics
3734
* of the equipment fulfilling that role.
38-
*
39-
* This is a @MappedSuperclass that provides asset-specific fields but does not extend IdentifiedObject.
40-
* Actual ESPI resource entities that represent assets should extend IdentifiedObject directly.
35+
*
36+
* This is an @Embeddable component that can be embedded in ESPI resource entities.
37+
* Per NAESB ESPI 4.0 customer.xsd, Asset extends IdentifiedObject (lines 643-713).
4138
*/
42-
@MappedSuperclass
39+
@Embeddable
4340
@Data
44-
@EqualsAndHashCode
4541
@NoArgsConstructor
46-
@ToString
47-
public abstract class Asset implements Serializable {
48-
49-
private static final long serialVersionUID = 1L;
42+
public class Asset {
5043

5144
/**
5245
* Utility-specific classification of Asset and its subtypes, according to their corporate standards,
@@ -87,16 +80,9 @@ public abstract class Asset implements Serializable {
8780

8881
/**
8982
* Electronic address.
83+
* Note: Column names will be overridden by the entity embedding this Asset.
9084
*/
9185
@Embedded
92-
@AttributeOverride(name = "lan", column = @Column(name = "asset_lan"))
93-
@AttributeOverride(name = "mac", column = @Column(name = "asset_mac"))
94-
@AttributeOverride(name = "email1", column = @Column(name = "asset_email1"))
95-
@AttributeOverride(name = "email2", column = @Column(name = "asset_email2"))
96-
@AttributeOverride(name = "web", column = @Column(name = "asset_web"))
97-
@AttributeOverride(name = "radio", column = @Column(name = "asset_radio"))
98-
@AttributeOverride(name = "userID", column = @Column(name = "asset_user_id"))
99-
@AttributeOverride(name = "password", column = @Column(name = "asset_password"))
10086
private ElectronicAddress electronicAddress;
10187

10288
/**
@@ -126,9 +112,10 @@ public abstract class Asset implements Serializable {
126112

127113
/**
128114
* Status of this asset.
115+
* Note: Uses Status embeddable (column names will be overridden by embedding entity).
129116
*/
130117
@Embedded
131-
private CustomerEntity.Status status;
118+
private Status status;
132119

133120
/**
134121
* Embeddable class for LifecycleDate

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@
5050
@AttributeOverride(name = "selfLink.rel", column = @Column(name = "customer_account_self_link_rel"))
5151
@AttributeOverride(name = "selfLink.href", column = @Column(name = "customer_account_self_link_href"))
5252
@AttributeOverride(name = "selfLink.type", column = @Column(name = "customer_account_self_link_type"))
53+
@AssociationOverride(
54+
name = "relatedLinks",
55+
joinTable = @JoinTable(
56+
name = "customer_account_related_links",
57+
joinColumns = @JoinColumn(name = "customer_account_id")
58+
)
59+
)
5360
@Getter
5461
@Setter
5562
@NoArgsConstructor

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAgreementEntity.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@
4848
@AttributeOverride(name = "selfLink.rel", column = @Column(name = "customer_agreement_self_link_rel"))
4949
@AttributeOverride(name = "selfLink.href", column = @Column(name = "customer_agreement_self_link_href"))
5050
@AttributeOverride(name = "selfLink.type", column = @Column(name = "customer_agreement_self_link_type"))
51+
@AssociationOverride(
52+
name = "relatedLinks",
53+
joinTable = @JoinTable(
54+
name = "customer_agreement_related_links",
55+
joinColumns = @JoinColumn(name = "customer_agreement_id")
56+
)
57+
)
5158
@Getter
5259
@Setter
5360
@NoArgsConstructor
@@ -235,6 +242,12 @@ public final int hashCode() {
235242
public String toString() {
236243
return getClass().getSimpleName() + "(" +
237244
"id = " + getId() + ", " +
245+
"description = " + getDescription() + ", " +
246+
"created = " + getCreated() + ", " +
247+
"updated = " + getUpdated() + ", " +
248+
"published = " + getPublished() + ", " +
249+
"upLink = " + getUpLink() + ", " +
250+
"selfLink = " + getSelfLink() + ", " +
238251
"type = " + getType() + ", " +
239252
"authorName = " + getAuthorName() + ", " +
240253
"createdDateTime = " + getCreatedDateTime() + ", " +
@@ -252,11 +265,6 @@ public String toString() {
252265
"isPrePay = " + getIsPrePay() + ", " +
253266
"shutOffDateTime = " + getShutOffDateTime() + ", " +
254267
"currency = " + getCurrency() + ", " +
255-
"futureStatus = " + getFutureStatus() + ", " +
256-
"agreementId = " + getAgreementId() + ", " +
257-
"description = " + getDescription() + ", " +
258-
"created = " + getCreated() + ", " +
259-
"updated = " + getUpdated() + ", " +
260-
"published = " + getPublished() + ")";
268+
"agreementId = " + getAgreementId() + ")";
261269
}
262270
}

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerEntity.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@
4848
*/
4949
@Entity
5050
@Table(name = "customers")
51+
@AssociationOverride(
52+
name = "relatedLinks",
53+
joinTable = @JoinTable(
54+
name = "customer_related_links",
55+
joinColumns = @JoinColumn(name = "customer_id")
56+
)
57+
)
5158
@Getter
5259
@Setter
5360
@NoArgsConstructor

openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/EndDeviceEntity.java

Lines changed: 63 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@
2020
package org.greenbuttonalliance.espi.common.domain.customer.entity;
2121

2222
import jakarta.persistence.*;
23+
import lombok.Delegate;
2324
import lombok.Getter;
2425
import lombok.NoArgsConstructor;
2526
import lombok.Setter;
2627
import org.greenbuttonalliance.espi.common.domain.common.IdentifiedObject;
27-
import org.greenbuttonalliance.espi.common.domain.customer.common.ElectronicAddress;
28+
import org.hibernate.proxy.HibernateProxy;
2829

29-
import java.math.BigDecimal;
30+
import java.util.Objects;
3031

3132
/**
3233
* Pure JPA/Hibernate entity for EndDevice without JAXB concerns.
@@ -45,149 +46,87 @@
4546
*/
4647
@Entity
4748
@Table(name = "end_devices")
48-
@Inheritance(strategy = InheritanceType.JOINED)
49+
@AssociationOverride(
50+
name = "relatedLinks",
51+
joinTable = @JoinTable(
52+
name = "end_device_related_links",
53+
joinColumns = @JoinColumn(name = "end_device_id")
54+
)
55+
)
4956
@Getter
5057
@Setter
5158
@NoArgsConstructor
5259
public class EndDeviceEntity extends IdentifiedObject {
5360

54-
// Asset fields (previously inherited from Asset superclass)
55-
56-
/**
57-
* Utility-specific classification of Asset and its subtypes, according to their corporate standards,
58-
* practices, and existing IT systems (e.g., for management of assets, maintenance, work, outage, customers, etc.).
59-
*/
60-
@Column(name = "type", length = 256)
61-
private String type;
62-
63-
/**
64-
* Uniquely tracked commodity (UTC) number.
65-
*/
66-
@Column(name = "utc_number", length = 256)
67-
private String utcNumber;
68-
69-
/**
70-
* Serial number of this asset.
71-
*/
72-
@Column(name = "serial_number", length = 256)
73-
private String serialNumber;
74-
75-
/**
76-
* Lot number for this asset. Even for the same model and version number, many assets are manufactured in lots.
77-
*/
78-
@Column(name = "lot_number", length = 256)
79-
private String lotNumber;
80-
81-
/**
82-
* Purchase price of asset.
83-
*/
84-
@Column(name = "purchase_price")
85-
private Long purchasePrice;
86-
87-
/**
88-
* True if asset is considered critical for some reason (for example, a pole with critical attachments).
89-
*/
90-
@Column(name = "critical")
91-
private Boolean critical;
92-
93-
/**
94-
* Electronic address.
95-
*/
96-
@Embedded
97-
@AttributeOverrides({
98-
@AttributeOverride(name = "lan", column = @Column(name = "end_device_lan")),
99-
@AttributeOverride(name = "mac", column = @Column(name = "end_device_mac")),
100-
@AttributeOverride(name = "email1", column = @Column(name = "end_device_email1")),
101-
@AttributeOverride(name = "email2", column = @Column(name = "end_device_email2")),
102-
@AttributeOverride(name = "web", column = @Column(name = "end_device_web")),
103-
@AttributeOverride(name = "radio", column = @Column(name = "end_device_radio")),
104-
@AttributeOverride(name = "userID", column = @Column(name = "end_device_user_id")),
105-
@AttributeOverride(name = "password", column = @Column(name = "end_device_password"))
106-
})
107-
private ElectronicAddress electronicAddress;
108-
109-
/**
110-
* Lifecycle dates for this asset.
111-
*/
112-
@Embedded
113-
private Asset.LifecycleDate lifecycle;
114-
115-
/**
116-
* Information on acceptance test.
117-
*/
61+
// Asset fields (embedded component per NAESB ESPI 4.0 customer.xsd lines 643-713)
11862
@Embedded
11963
@AttributeOverrides({
120-
@AttributeOverride(name = "dateTime", column = @Column(name = "acceptance_test_date_time")),
121-
@AttributeOverride(name = "success", column = @Column(name = "acceptance_test_success")),
122-
@AttributeOverride(name = "type", column = @Column(name = "acceptance_test_type"))
64+
@AttributeOverride(name = "electronicAddress.lan", column = @Column(name = "end_device_lan")),
65+
@AttributeOverride(name = "electronicAddress.mac", column = @Column(name = "end_device_mac")),
66+
@AttributeOverride(name = "electronicAddress.email1", column = @Column(name = "end_device_email1")),
67+
@AttributeOverride(name = "electronicAddress.email2", column = @Column(name = "end_device_email2")),
68+
@AttributeOverride(name = "electronicAddress.web", column = @Column(name = "end_device_web")),
69+
@AttributeOverride(name = "electronicAddress.radio", column = @Column(name = "end_device_radio")),
70+
@AttributeOverride(name = "electronicAddress.userID", column = @Column(name = "end_device_user_id")),
71+
@AttributeOverride(name = "electronicAddress.password", column = @Column(name = "end_device_password")),
72+
@AttributeOverride(name = "status.value", column = @Column(name = "status_value")),
73+
@AttributeOverride(name = "status.dateTime", column = @Column(name = "status_date_time")),
74+
@AttributeOverride(name = "status.remark", column = @Column(name = "status_remark")),
75+
@AttributeOverride(name = "status.reason", column = @Column(name = "status_reason")),
76+
@AttributeOverride(name = "acceptanceTest.dateTime", column = @Column(name = "acceptance_test_date_time")),
77+
@AttributeOverride(name = "acceptanceTest.success", column = @Column(name = "acceptance_test_success")),
78+
@AttributeOverride(name = "acceptanceTest.type", column = @Column(name = "acceptance_test_type"))
12379
})
124-
private Asset.AcceptanceTest acceptanceTest;
125-
126-
/**
127-
* Condition of asset in inventory or at time of installation. Examples include new, rebuilt,
128-
* overhaul required, other. Refer to inspection data for information on the most current condition of the asset.
129-
*/
130-
@Column(name = "initial_condition", length = 256)
131-
private String initialCondition;
132-
133-
/**
134-
* Whenever an asset is reconditioned, percentage of expected life for the asset when it was new; zero for new devices.
135-
*/
136-
@Column(name = "initial_loss_of_life")
137-
private BigDecimal initialLossOfLife;
80+
@Delegate(excludes = {Object.class})
81+
private Asset asset = new Asset();
13882

139-
/**
140-
* Status of this asset.
141-
*/
83+
// EndDevice-specific fields (per NAESB ESPI 4.0 customer.xsd lines 218-238)
14284
@Embedded
143-
@AttributeOverride(name = "value", column = @Column(name = "status_value"))
144-
@AttributeOverride(name = "dateTime", column = @Column(name = "status_date_time"))
145-
@AttributeOverride(name = "remark", column = @Column(name = "status_remark"))
146-
@AttributeOverride(name = "reason", column = @Column(name = "status_reason"))
147-
private Status status;
148-
149-
// AssetContainer fields (AssetContainer is simply an Asset that can contain other assets - no additional fields)
150-
151-
// EndDevice specific fields
152-
153-
/**
154-
* If true, there is no physical device. As an example, a virtual meter can be defined to aggregate
155-
* the consumption for two or more physical meters. Otherwise, this is a physical hardware device.
156-
*/
157-
@Column(name = "is_virtual")
158-
private Boolean isVirtual;
85+
@Delegate(excludes = {Object.class})
86+
private EndDeviceFields endDeviceFields = new EndDeviceFields();
15987

160-
/**
161-
* If true, this is a premises area network (PAN) device.
162-
*/
163-
@Column(name = "is_pan")
164-
private Boolean isPan;
165-
166-
/**
167-
* Installation code.
168-
*/
169-
@Column(name = "install_code", length = 256)
170-
private String installCode;
171-
172-
/**
173-
* Automated meter reading (AMR) or other communication system responsible for communications to this end device.
174-
*/
175-
@Column(name = "amr_system", length = 256)
176-
private String amrSystem;
17788

17889
@Override
17990
public final boolean equals(Object o) {
18091
if (this == o) return true;
18192
if (o == null) return false;
182-
Class<?> oEffectiveClass = o instanceof org.hibernate.proxy.HibernateProxy ? ((org.hibernate.proxy.HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();
183-
Class<?> thisEffectiveClass = this instanceof org.hibernate.proxy.HibernateProxy ? ((org.hibernate.proxy.HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
93+
Class<?> oEffectiveClass = o instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : o.getClass();
94+
Class<?> thisEffectiveClass = this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : this.getClass();
18495
if (thisEffectiveClass != oEffectiveClass) return false;
18596
EndDeviceEntity that = (EndDeviceEntity) o;
186-
return getId() != null && java.util.Objects.equals(getId(), that.getId());
97+
return getId() != null && Objects.equals(getId(), that.getId());
18798
}
18899

189100
@Override
190101
public final int hashCode() {
191-
return this instanceof org.hibernate.proxy.HibernateProxy ? ((org.hibernate.proxy.HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
102+
return this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
103+
}
104+
105+
@Override
106+
public String toString() {
107+
return getClass().getSimpleName() + "(" +
108+
"id = " + getId() + ", " +
109+
"description = " + getDescription() + ", " +
110+
"created = " + getCreated() + ", " +
111+
"updated = " + getUpdated() + ", " +
112+
"published = " + getPublished() + ", " +
113+
"upLink = " + getUpLink() + ", " +
114+
"selfLink = " + getSelfLink() + ", " +
115+
"type = " + getType() + ", " +
116+
"utcNumber = " + getUtcNumber() + ", " +
117+
"serialNumber = " + getSerialNumber() + ", " +
118+
"lotNumber = " + getLotNumber() + ", " +
119+
"purchasePrice = " + getPurchasePrice() + ", " +
120+
"critical = " + getCritical() + ", " +
121+
"electronicAddress = " + getElectronicAddress() + ", " +
122+
"lifecycle = " + getLifecycle() + ", " +
123+
"acceptanceTest = " + getAcceptanceTest() + ", " +
124+
"initialCondition = " + getInitialCondition() + ", " +
125+
"initialLossOfLife = " + getInitialLossOfLife() + ", " +
126+
"status = " + getStatus() + ", " +
127+
"isVirtual = " + getIsVirtual() + ", " +
128+
"isPan = " + getIsPan() + ", " +
129+
"installCode = " + getInstallCode() + ", " +
130+
"amrSystem = " + getAmrSystem() + ")";
192131
}
193132
}

0 commit comments

Comments
 (0)