diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/ReportingRate.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/ReportingRate.java index 0835e4af53fd..332b1d9fa43b 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/ReportingRate.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/common/ReportingRate.java @@ -114,7 +114,7 @@ public TotalAggregationType getTotalAggregationType() { // ------------------------------------------------------------------------- @JsonProperty - @JsonSerialize(as = BaseNameableObject.class) + @JsonSerialize(as = NameableObject.class) @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) @Property( value = PropertyType.REFERENCE, diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStateResponse.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStateResponse.java index 241893e327f9..3b54ad3d6b1d 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStateResponse.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataapproval/DataApprovalStateResponse.java @@ -39,6 +39,7 @@ import lombok.Builder; import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.DxfNamespaces; +import org.hisp.dhis.common.IdentifiableObject; import org.hisp.dhis.dataset.DataSet; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.period.Period; @@ -68,7 +69,7 @@ public class DataApprovalStateResponse { */ @JsonProperty - @JsonSerialize(as = BaseIdentifiableObject.class) + @JsonSerialize(as = IdentifiableObject.class) @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) public DataSet getDataSet() { return dataSet; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java index 665fa41d1d6d..cf8827723d54 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/CompleteDataSetRegistration.java @@ -215,7 +215,7 @@ public boolean equals(Object object) { // ------------------------------------------------------------------------- @JsonProperty - @JsonSerialize(as = BaseIdentifiableObject.class) + @JsonSerialize(as = IdentifiableObject.class) @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) public DataSet getDataSet() { return dataSet; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataInputPeriod.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataInputPeriod.java index fdb73412293f..b07867286cf4 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataInputPeriod.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataInputPeriod.java @@ -36,9 +36,22 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import com.google.common.base.MoreObjects; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import java.util.Date; +import lombok.Setter; import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hisp.dhis.common.DxfNamespaces; import org.hisp.dhis.common.EmbeddedObject; import org.hisp.dhis.common.adapter.JacksonPeriodDeserializer; @@ -48,18 +61,32 @@ /** * @author Stian Sandvold */ +@Entity +@Table(name = "datainputperiod") +@Setter +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) @JacksonXmlRootElement(localName = "dataInputPeriods", namespace = DxfNamespaces.DXF_2_0) public class DataInputPeriod implements EmbeddedObject { /** The database internal identifier for this Object. */ + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + @Column(name = "datainputperiodid") private int id; /** Period data must belong to */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn( + name = "periodid", + nullable = false, + foreignKey = @ForeignKey(name = "fk_datainputperiod_period")) private Period period; /** Opening date of which data can be entered */ + @Column(name = "openingdate") private Date openingDate; /** Closing date of which data can no longer be entered */ + @Column(name = "closingdate") private Date closingDate; public DataInputPeriod() {} @@ -103,10 +130,6 @@ public int getId() { return id; } - public void setId(int id) { - this.id = id; - } - @JsonProperty @JsonSerialize(using = JacksonPeriodSerializer.class) @JsonDeserialize(using = JacksonPeriodDeserializer.class) @@ -115,30 +138,18 @@ public Period getPeriod() { return period; } - public void setPeriod(Period period) { - this.period = period; - } - @JsonProperty @JacksonXmlProperty(localName = "openingDate", namespace = DxfNamespaces.DXF_2_0) public Date getOpeningDate() { return openingDate; } - public void setOpeningDate(Date openingDate) { - this.openingDate = openingDate; - } - @JsonProperty @JacksonXmlProperty(localName = "closingDate", namespace = DxfNamespaces.DXF_2_0) public Date getClosingDate() { return closingDate; } - public void setClosingDate(Date closingDate) { - this.closingDate = closingDate; - } - @Override public boolean equals(Object object) { if (this == object) { diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSet.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSet.java index e92926ac602a..c3984f0821dd 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSet.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSet.java @@ -30,7 +30,9 @@ package org.hisp.dhis.dataset; import static org.apache.commons.collections4.CollectionUtils.isNotEmpty; +import static org.hisp.dhis.hibernate.HibernateProxyUtils.getRealClass; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -40,26 +42,63 @@ import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Sets; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.OrderColumn; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.Cascade; +import org.hibernate.annotations.Type; +import org.hisp.dhis.analytics.AggregationType; +import org.hisp.dhis.attribute.AttributeValues; +import org.hisp.dhis.attribute.AttributeValuesDeserializer; +import org.hisp.dhis.attribute.AttributeValuesSerializer; +import org.hisp.dhis.audit.AuditAttribute; import org.hisp.dhis.category.Category; import org.hisp.dhis.category.CategoryCombo; import org.hisp.dhis.category.CategoryOption; import org.hisp.dhis.category.CategoryOptionCombo; import org.hisp.dhis.category.CategoryOptionGroupSet; -import org.hisp.dhis.common.BaseDimensionalItemObject; -import org.hisp.dhis.common.BaseIdentifiableObject; +import org.hisp.dhis.common.BaseMetadataObject; import org.hisp.dhis.common.DimensionItemType; +import org.hisp.dhis.common.DimensionalItemObject; +import org.hisp.dhis.common.DisplayProperty; import org.hisp.dhis.common.DxfNamespaces; +import org.hisp.dhis.common.IdScheme; import org.hisp.dhis.common.IdentifiableObject; +import org.hisp.dhis.common.IdentifiableProperty; import org.hisp.dhis.common.InterpretableObject; import org.hisp.dhis.common.MetadataObject; import org.hisp.dhis.common.ObjectStyle; import org.hisp.dhis.common.OpenApi; +import org.hisp.dhis.common.QueryModifiers; import org.hisp.dhis.common.Sortable; +import org.hisp.dhis.common.TotalAggregationType; +import org.hisp.dhis.common.TranslationProperty; import org.hisp.dhis.common.VersionedObject; import org.hisp.dhis.common.adapter.JacksonPeriodTypeDeserializer; import org.hisp.dhis.common.adapter.JacksonPeriodTypeSerializer; @@ -69,13 +108,21 @@ import org.hisp.dhis.dataentryform.DataEntryForm; import org.hisp.dhis.indicator.Indicator; import org.hisp.dhis.interpretation.Interpretation; +import org.hisp.dhis.legend.LegendSet; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.period.PeriodType; import org.hisp.dhis.schema.PropertyType; +import org.hisp.dhis.schema.annotation.Gist; +import org.hisp.dhis.schema.annotation.Gist.Include; import org.hisp.dhis.schema.annotation.Property; import org.hisp.dhis.schema.annotation.PropertyRange; import org.hisp.dhis.translation.Translatable; +import org.hisp.dhis.translation.Translation; +import org.hisp.dhis.user.User; import org.hisp.dhis.user.UserGroup; +import org.hisp.dhis.user.sharing.Sharing; +import org.hisp.dhis.user.sharing.UserAccess; +import org.hisp.dhis.user.sharing.UserGroupAccess; /** * This class is used for defining the standardized DataSets. A DataSet consists of a collection of @@ -83,14 +130,50 @@ * * @author Kristian Nordal */ +@Entity +@Table(name = "dataset") +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) @JacksonXmlRootElement(localName = "dataSet", namespace = DxfNamespaces.DXF_2_0) -public class DataSet extends BaseDimensionalItemObject - implements VersionedObject, MetadataObject, InterpretableObject { +public class DataSet extends BaseMetadataObject + implements DimensionalItemObject, VersionedObject, MetadataObject, InterpretableObject { public static final int NO_EXPIRY = 0; + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE) + @Column(name = "datasetid") + private long id; + + @Column(name = "code", unique = true, length = 60) + private String code; + + @Column(name = "name", nullable = false, length = 230) + private String name; + + @Column(name = "shortname", nullable = false, unique = true, length = 50) + private String shortName; + + @Type(type = "text") + @Column(name = "description", columnDefinition = "text") + private String description; + + @Type(type = "text") + @Column(name = "formname", columnDefinition = "text") private String formName; + @Type(type = "jbObjectStyle") + @Column(name = "style") + private ObjectStyle style; + + @Type(type = "jbPlainString") + @Column(name = "displayoptions", length = 50000) + private String displayOptions; + /** The PeriodType indicating the frequency that this DataSet should be used */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn( + name = "periodtypeid", + nullable = false, + foreignKey = @ForeignKey(name = "fk_dataset_periodtypeid")) private PeriodType periodType; /** @@ -99,57 +182,129 @@ public class DataSet extends BaseDimensionalItemObject * actually registering this data is allowed. The same period can exist at the same time with * different opening and closing dates to allow for multiple periods for registering data. */ + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn( + name = "datasetid", + foreignKey = @ForeignKey(name = "fk_datasetdatainputperiods_datasetid")) + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private Set dataInputPeriods = new HashSet<>(); /** All DataElements associated with this DataSet. */ + // The collection owns the FK; metadata import clears references to an unsaved DataSet. + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "datasetid", foreignKey = @ForeignKey(name = "fk_datasetmembers_datasetid")) + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private Set dataSetElements = new HashSet<>(); /** * Indicators associated with this data set. Indicators are used for view and output purposes, * such as calculated fields in forms and reports. */ + @ManyToMany + @JoinTable( + name = "datasetindicators", + joinColumns = + @JoinColumn( + name = "datasetid", + foreignKey = @ForeignKey(name = "fk_datasetindicators_datasetid")), + inverseJoinColumns = + @JoinColumn( + name = "indicatorid", + foreignKey = @ForeignKey(name = "fk_dataset_indicatorid"))) + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private Set indicators = new HashSet<>(); /** * The DataElementOperands for which data must be entered in order for the DataSet to be * considered as complete. */ + @ManyToMany + @Cascade( + value = { + org.hibernate.annotations.CascadeType.ALL, + org.hibernate.annotations.CascadeType.DELETE_ORPHAN + }) + @JoinTable( + name = "datasetoperands", + joinColumns = + @JoinColumn( + name = "datasetid", + foreignKey = @ForeignKey(name = "fk_datasetoperands_datasetid")), + inverseJoinColumns = + @JoinColumn( + name = "dataelementoperandid", + foreignKey = @ForeignKey(name = "fk_dataset_dataelementoperandid"))) + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private Set compulsoryDataElementOperands = new HashSet<>(); /** All Sources that register data with this DataSet. */ + @ManyToMany + @JoinTable( + name = "datasetsource", + joinColumns = + @JoinColumn( + name = "datasetid", + foreignKey = @ForeignKey(name = "fk_datasetsource_datasetid")), + inverseJoinColumns = + @JoinColumn( + name = "sourceid", + foreignKey = @ForeignKey(name = "fk_dataset_organisationunit"))) + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private Set sources = new HashSet<>(); /** The Sections associated with the DataSet. */ + @OneToMany(mappedBy = "dataSet") + @OrderBy("sortOrder") + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private Set
sections = new HashSet<>(); /** The CategoryCombo used for data attributes. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "categorycomboid", + nullable = false, + foreignKey = @ForeignKey(name = "fk_dataset_categorycomboid")) private CategoryCombo categoryCombo; - /** Property indicating if the dataset could be collected using mobile data entry. */ - private boolean mobile; // TODO Remove, mobile service is now removed - /** Indicating custom data entry form, can be null. */ + @ManyToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @JoinColumn(name = "dataentryform", foreignKey = @ForeignKey(name = "fk_dataset_dataentryform")) private DataEntryForm dataEntryForm; + /** Property indicating if the dataset could be collected using mobile data entry. */ + @Column(name = "mobile") + private boolean mobile; // TODO Remove, mobile service is now removed + /** Indicating version number. */ + @Column(name = "version") private int version; /** How many days after period is over will this dataSet auto-lock */ + @Column(name = "expirydays") private double expiryDays; /** Days after period end to qualify for timely data submission */ + @Column(name = "timelydays") private double timelyDays; /** User group which will receive notifications when data set is marked complete, can be null. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "notificationrecipients", + foreignKey = @ForeignKey(name = "fk_dataset_notificationrecipients")) private UserGroup notificationRecipients; /** Indicating whether the user completing this data set should be sent a notification. */ + @Column(name = "notifycompletinguser") private boolean notifyCompletingUser; /** The approval workflow for this data set, can be null. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workflowid", foreignKey = @ForeignKey(name = "fk_dataset_workflowid")) private DataApprovalWorkflow workflow; /** Interpretations of this data set. */ + @OneToMany(mappedBy = "dataSet") private Set interpretations = new HashSet<>(); // ------------------------------------------------------------------------- @@ -160,43 +315,83 @@ public class DataSet extends BaseDimensionalItemObject * Number of periods in the future to open for data capture, 0 means capture not allowed for * current period. */ + @Column(name = "openfutureperiods") private int openFuturePeriods; /** Number of periods to open for data capture that are after the category option's end date. */ + @Column(name = "openperiodsaftercoenddate") private int openPeriodsAfterCoEndDate; /** Property indicating that all fields for a data element must be filled. */ + @Column(name = "fieldcombinationrequired") private boolean fieldCombinationRequired; /** Property indicating that all validation rules must pass before the form can be completed. */ + @Column(name = "validcompleteonly") private boolean validCompleteOnly; /** * Property indicating whether a comment is required for all fields in a form which are not * entered, including false for boolean values. */ + @Column(name = "novaluerequirescomment") private boolean noValueRequiresComment; /** Property indicating whether offline storage is enabled for this dataSet or not */ + @Column(name = "skipoffline") private boolean skipOffline; /** Property indicating whether it should enable data elements decoration in forms. */ + @Column(name = "dataelementdecoration") private boolean dataElementDecoration; /** Render default and section forms with tabs instead of multiple sections in one page */ + @Column(name = "renderastabs") private boolean renderAsTabs; /** Render multi-organisationUnit forms either with OU vertically or horizontally. */ + @Column(name = "renderhorizontally") private boolean renderHorizontally; /** * Property indicating whether all compulsory fields should be filled before completing data set */ + @Column(name = "compulsoryfieldscompleteonly") private boolean compulsoryFieldsCompleteOnly; - private ObjectStyle style; + @Embedded private TranslationProperty translations = new TranslationProperty(); - private String displayOptions; + /** The legend sets for this dimension. */ + @ManyToMany + @JoinTable( + name = "datasetlegendsets", + joinColumns = @JoinColumn(name = "datasetid"), + inverseJoinColumns = + @JoinColumn( + name = "legendsetid", + foreignKey = @ForeignKey(name = "fk_dataset_legendsetid"))) + @OrderColumn(name = "sort_order") + @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) + private List legendSets = new ArrayList<>(); + + /** The aggregation type for this dimension. Not persisted for DataSet. */ + @Transient private AggregationType aggregationType; + + /** Query modifiers for this object. Not persisted for DataSet. */ + @Transient private transient QueryModifiers queryMods; + + // ------------------------------------------------------------------------- + // Dynamic attribute values / Sharing + // ------------------------------------------------------------------------- + + @AuditAttribute + @Type(type = "jsbAttributeValues") + @Column(name = "attributevalues") + private AttributeValues attributeValues = AttributeValues.empty(); + + @Type(type = "jsbObjectSharing") + @Column(name = "sharing") + private Sharing sharing = new Sharing(); // ------------------------------------------------------------------------- // Constructors @@ -456,6 +651,79 @@ public DimensionItemType getDimensionItemType() { return DimensionItemType.REPORTING_RATE; } + @Override + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + public String getDimensionItem() { + return getUid(); + } + + @Override + public String getDimensionItem(IdScheme idScheme) { + return getPropertyValue(idScheme); + } + + @Override + public boolean hasLegendSet() { + return legendSets != null && !legendSets.isEmpty(); + } + + @Override + public boolean hasAggregationType() { + return getAggregationType() != null; + } + + @Override + public TotalAggregationType getTotalAggregationType() { + return getAggregationType() == AggregationType.NONE + ? TotalAggregationType.NONE + : TotalAggregationType.SUM; + } + + @Override + @JsonProperty + @JacksonXmlElementWrapper(localName = "legendSets", namespace = DxfNamespaces.DXF_2_0) + @JacksonXmlProperty(localName = "legendSets", namespace = DxfNamespaces.DXF_2_0) + public List getLegendSets() { + return this.legendSets; + } + + public void setLegendSets(List legendSets) { + this.legendSets = legendSets; + } + + @Override + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + public LegendSet getLegendSet() { + return legendSets.isEmpty() ? null : legendSets.get(0); + } + + @Override + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + public AggregationType getAggregationType() { + return (queryMods != null && queryMods.getAggregationType() != null) + ? queryMods.getAggregationType() + : aggregationType; + } + + public void setAggregationType(AggregationType aggregationType) { + this.aggregationType = aggregationType; + } + + @Override + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + public QueryModifiers getQueryMods() { + return queryMods; + } + + @Override + public void setQueryMods(QueryModifiers queryMods) { + this.queryMods = queryMods; + } + // ------------------------------------------------------------------------- // Getters and setters // ------------------------------------------------------------------------- @@ -506,7 +774,7 @@ public void setDataSetElements(Set dataSetElements) { } @JsonProperty - @JsonSerialize(contentAs = BaseIdentifiableObject.class) + @JsonSerialize(contentAs = IdentifiableObject.class) @JacksonXmlElementWrapper(localName = "indicators", namespace = DxfNamespaces.DXF_2_0) @JacksonXmlProperty(localName = "indicator", namespace = DxfNamespaces.DXF_2_0) public Set getIndicators() { @@ -532,7 +800,7 @@ public void setCompulsoryDataElementOperands( } @JsonProperty(value = "organisationUnits") - @JsonSerialize(contentAs = BaseIdentifiableObject.class) + @JsonSerialize(contentAs = IdentifiableObject.class) @JacksonXmlElementWrapper(localName = "organisationUnits", namespace = DxfNamespaces.DXF_2_0) @JacksonXmlProperty(localName = "organisationUnit", namespace = DxfNamespaces.DXF_2_0) public Set getSources() { @@ -641,7 +909,7 @@ public void setWorkflow(DataApprovalWorkflow workflow) { @Override @JsonProperty - @JsonSerialize(contentAs = BaseIdentifiableObject.class) + @JsonSerialize(contentAs = IdentifiableObject.class) @JacksonXmlElementWrapper(localName = "interpretations", namespace = DxfNamespaces.DXF_2_0) @JacksonXmlProperty(localName = "interpretation", namespace = DxfNamespaces.DXF_2_0) public Set getInterpretations() { @@ -763,14 +1031,12 @@ public void setDisplayOptions(String displayOptions) { this.displayOptions = displayOptions; } - @Override @JsonProperty @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) public String getFormName() { return formName; } - @Override public void setFormName(String formName) { this.formName = formName; } @@ -785,12 +1051,274 @@ public void setCompulsoryFieldsCompleteOnly(boolean compulsoryFieldsCompleteOnly this.compulsoryFieldsCompleteOnly = compulsoryFieldsCompleteOnly; } + // ------------------------------------------------------------------------- + // IdentifiableObject / NameableObject + // ------------------------------------------------------------------------- + + @Override + @JsonIgnore + public long getId() { + return id; + } + + @Override + public void setId(long id) { + this.id = id; + } + + @Override + @JsonProperty + @JacksonXmlProperty(isAttribute = true) + @Property(PropertyType.IDENTIFIER) + public String getCode() { + return code; + } + + @Override + public void setCode(String code) { + this.code = code; + } + + @Override + @JsonProperty + @JacksonXmlProperty(isAttribute = true) + @PropertyRange(min = 1) + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + @Sortable + @JsonProperty + @JacksonXmlProperty(isAttribute = true) + @PropertyRange(min = 1, max = 50) + public String getShortName() { + return shortName; + } + + public void setShortName(String shortName) { + this.shortName = shortName; + } + + @Override + @Sortable + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + @PropertyRange(min = 1) + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + @Override @Sortable(whenPersisted = false) @JsonProperty @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) @Translatable(propertyName = "name", key = "NAME") public String getDisplayName() { - return getTranslation("NAME", getName()); + return translations.getTranslation("NAME", getName()); + } + + @Override + @Sortable(whenPersisted = false) + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + @Translatable(propertyName = "shortName", key = "SHORT_NAME") + public String getDisplayShortName() { + return translations.getTranslation("SHORT_NAME", getShortName()); + } + + @Override + @Sortable(value = false) + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + @Translatable(propertyName = "description", key = "DESCRIPTION") + public String getDisplayDescription() { + return translations.getTranslation("DESCRIPTION", getDescription()); + } + + /** Returns the form name, or the name if it does not exist. */ + public String getFormNameFallback() { + return formName != null && !formName.isEmpty() ? getFormName() : getDisplayName(); + } + + @JsonProperty + @Sortable(whenPersisted = false) + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + @Translatable(propertyName = "formName", key = "FORM_NAME") + public String getDisplayFormName() { + return translations.getTranslation("FORM_NAME", getFormNameFallback()); + } + + @Override + @JsonIgnore + public String getDisplayProperty(DisplayProperty displayProperty) { + if (DisplayProperty.SHORTNAME == displayProperty && getDisplayShortName() != null) { + return getDisplayShortName(); + } else { + return getDisplayName(); + } + } + + @Override + @JsonProperty("attributeValues") + @JsonDeserialize(using = AttributeValuesDeserializer.class) + @JsonSerialize(using = AttributeValuesSerializer.class) + public AttributeValues getAttributeValues() { + return attributeValues; + } + + @Override + public void setAttributeValues(AttributeValues attributeValues) { + this.attributeValues = attributeValues == null ? AttributeValues.empty() : attributeValues; + } + + @Override + public void addAttributeValue(String attributeId, String value) { + this.attributeValues = attributeValues.added(attributeId, value); + } + + @Override + public void removeAttributeValue(String attributeId) { + this.attributeValues = attributeValues.removed(attributeId); + } + + @JsonIgnore + public String getAttributeValue(String attributeUid) { + return attributeValues.get(attributeUid); + } + + @Gist(included = Include.FALSE) + @Override + @Sortable(value = false) + @JsonProperty + @JacksonXmlElementWrapper(localName = "translations", namespace = DxfNamespaces.DXF_2_0) + @JacksonXmlProperty(localName = "translation", namespace = DxfNamespaces.DXF_2_0) + public Set getTranslations() { + return translations.getTranslations(); + } + + @Override + public void setTranslations(Set translations) { + this.translations.setTranslations(translations); + } + + @Override + @Sortable(value = false) + @Gist(included = Include.FALSE) + @JsonProperty + @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) + public Sharing getSharing() { + if (sharing == null) { + sharing = new Sharing(); + } + + return sharing; + } + + @Override + public void setSharing(Sharing sharing) { + this.sharing = sharing; + } + + @Override + public void setUser(User user) { + // TODO remove this after implementing functions for using Owner + setCreatedBy(getCreatedBy() == null ? user : getCreatedBy()); + setOwner(user != null ? user.getUid() : null); + } + + @Override + public void setOwner(String userId) { + getSharing().setOwner(userId); + } + + public void setPublicAccess(String access) { + getSharing().setPublicAccess(access); + } + + public String getPublicAccess() { + if (sharing != null) { + return sharing.getPublicAccess(); + } + + return null; + } + + public Collection getUserAccesses() { + if (sharing == null || getSharing().getUsers() == null) { + return Collections.emptyList(); + } + + return getSharing().getUsers().values(); + } + + public Collection getUserGroupAccesses() { + if (sharing == null || getSharing().getUserGroups() == null) { + return Collections.emptyList(); + } + + return getSharing().getUserGroups().values(); + } + + @Override + public String getPropertyValue(IdScheme idScheme) { + if (idScheme.isNull() || idScheme.is(IdentifiableProperty.UID)) { + return getUid(); + } else if (idScheme.is(IdentifiableProperty.CODE)) { + return code; + } else if (idScheme.is(IdentifiableProperty.NAME)) { + return name; + } else if (idScheme.is(IdentifiableProperty.ID)) { + return id > 0 ? String.valueOf(id) : null; + } else if (idScheme.is(IdentifiableProperty.ATTRIBUTE)) { + return attributeValues.get(idScheme.getAttribute()); + } + return null; + } + + @Override + public String getDisplayPropertyValue(IdScheme idScheme) { + if (idScheme.is(IdentifiableProperty.NAME)) { + return getDisplayName(); + } else { + return getPropertyValue(idScheme); + } + } + + // ------------------------------------------------------------------------- + // hashCode, equals + // ------------------------------------------------------------------------- + + @Override + public int hashCode() { + int result = getUid() != null ? getUid().hashCode() : 0; + result = 31 * result + (getCode() != null ? getCode().hashCode() : 0); + result = 31 * result + (getName() != null ? getName().hashCode() : 0); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof IdentifiableObject identifiableObject)) return false; + return getRealClass(this) == getRealClass(obj) && typedEquals(identifiableObject); + } + + public final boolean typedEquals(IdentifiableObject other) { + if (other == null) { + return false; + } + return Objects.equals(getUid(), other.getUid()) + && Objects.equals(getCode(), other.getCode()) + && Objects.equals(getName(), other.getName()); } } diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSetElement.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSetElement.java index 07b0ff3f4e57..f355ea8a13c5 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSetElement.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/DataSetElement.java @@ -50,7 +50,6 @@ import org.hibernate.annotations.Cache; import org.hibernate.annotations.CacheConcurrencyStrategy; import org.hisp.dhis.category.CategoryCombo; -import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.DxfNamespaces; import org.hisp.dhis.common.EmbeddedObject; import org.hisp.dhis.common.IdentifiableObject; @@ -175,7 +174,7 @@ public void setId(int id) { } @JsonProperty - @JsonSerialize(as = BaseIdentifiableObject.class) + @JsonSerialize(as = IdentifiableObject.class) @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) public DataSet getDataSet() { return dataSet; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/LockException.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/LockException.java index 68462da4e718..1928ac793b34 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/LockException.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/LockException.java @@ -39,6 +39,7 @@ import lombok.ToString; import org.hisp.dhis.common.BaseIdentifiableObject; import org.hisp.dhis.common.DxfNamespaces; +import org.hisp.dhis.common.IdentifiableObject; import org.hisp.dhis.common.PrimaryKeyObject; import org.hisp.dhis.common.adapter.JacksonExtendedPeriodSerializer; import org.hisp.dhis.common.adapter.JacksonPeriodDeserializer; @@ -106,7 +107,7 @@ public OrganisationUnit getOrganisationUnit() { } @JsonProperty - @JsonSerialize(as = BaseIdentifiableObject.class) + @JsonSerialize(as = IdentifiableObject.class) @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) public DataSet getDataSet() { return dataSet; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/Section.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/Section.java index 44db9ce3a002..f750970d05eb 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/Section.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/dataset/Section.java @@ -270,7 +270,7 @@ public String getDescription() { } @JsonProperty - @JsonSerialize(as = BaseIdentifiableObject.class) + @JsonSerialize(as = IdentifiableObject.class) @JacksonXmlProperty(namespace = DxfNamespaces.DXF_2_0) public DataSet getDataSet() { return dataSet; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/indicator/Indicator.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/indicator/Indicator.java index 0adb2d6d8053..833c64dd2094 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/indicator/Indicator.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/indicator/Indicator.java @@ -285,7 +285,7 @@ public void setGroups(Set groups) { } @JsonProperty - @JsonSerialize(contentAs = BaseIdentifiableObject.class) + @JsonSerialize(contentAs = IdentifiableObject.class) @JacksonXmlElementWrapper(localName = "dataSets", namespace = DxfNamespaces.DXF_2_0) @JacksonXmlProperty(localName = "dataSet", namespace = DxfNamespaces.DXF_2_0) public Set getDataSets() { diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/interpretation/Interpretation.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/interpretation/Interpretation.java index adc72c28e008..33816826b61b 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/interpretation/Interpretation.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/interpretation/Interpretation.java @@ -342,7 +342,7 @@ public void setEventChart(EventChart eventChart) { } @JsonProperty - @JsonSerialize(as = BaseIdentifiableObject.class) + @JsonSerialize(as = IdentifiableObject.class) @JacksonXmlProperty(namespace = DXF_2_0) public DataSet getDataSet() { return dataSet; diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/organisationunit/OrganisationUnit.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/organisationunit/OrganisationUnit.java index c93742927096..3522db84c382 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/organisationunit/OrganisationUnit.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/organisationunit/OrganisationUnit.java @@ -951,7 +951,7 @@ public void setGroups(Set groups) { } @JsonProperty - @JsonSerialize(contentAs = BaseIdentifiableObject.class) + @JsonSerialize(contentAs = IdentifiableObject.class) @JacksonXmlElementWrapper(localName = "dataSets", namespace = DxfNamespaces.DXF_2_0) @JacksonXmlProperty(localName = "dataSet", namespace = DxfNamespaces.DXF_2_0) public Set getDataSets() { diff --git a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/sms/command/SMSCommand.java b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/sms/command/SMSCommand.java index 125c6cab1ca9..8be027d116c8 100644 --- a/dhis-2/dhis-api/src/main/java/org/hisp/dhis/sms/command/SMSCommand.java +++ b/dhis-2/dhis-api/src/main/java/org/hisp/dhis/sms/command/SMSCommand.java @@ -137,7 +137,7 @@ public SMSCommand() {} // ------------------------------------------------------------------------- @JsonProperty - @JsonSerialize(contentAs = BaseIdentifiableObject.class) + @JsonSerialize(contentAs = IdentifiableObject.class) @JacksonXmlProperty(localName = "dataset", namespace = DxfNamespaces.DXF_2_0) public DataSet getDataset() { return dataset; diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataset/hibernate/DataInputPeriod.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataset/hibernate/DataInputPeriod.hbm.xml deleted file mode 100644 index ccf3e457e8c0..000000000000 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataset/hibernate/DataInputPeriod.hbm.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataset/hibernate/DataSet.hbm.xml b/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataset/hibernate/DataSet.hbm.xml deleted file mode 100644 index 2dad9217f907..000000000000 --- a/dhis-2/dhis-services/dhis-service-core/src/main/resources/org/hisp/dhis/dataset/hibernate/DataSet.hbm.xml +++ /dev/null @@ -1,142 +0,0 @@ - -] - > - - - - - - - - - - &identifiableProperties; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dhis-2/dhis-support/dhis-support-artemis/src/main/java/org/hisp/dhis/artemis/audit/listener/AbstractHibernateListener.java b/dhis-2/dhis-support/dhis-support-artemis/src/main/java/org/hisp/dhis/artemis/audit/listener/AbstractHibernateListener.java index e05daa102ec7..6b5f3bcdcf6f 100644 --- a/dhis-2/dhis-support/dhis-support-artemis/src/main/java/org/hisp/dhis/artemis/audit/listener/AbstractHibernateListener.java +++ b/dhis-2/dhis-support/dhis-support-artemis/src/main/java/org/hisp/dhis/artemis/audit/listener/AbstractHibernateListener.java @@ -191,7 +191,7 @@ protected Object createAuditEntry( continue; } - if (shouldInitializeProxy(value) || property.isEmbeddedObject()) { + if (shouldRefetchThroughProxy(value, property)) { if (entityProxy == null) { entityProxy = createProxy(id, session, persister); } @@ -294,6 +294,18 @@ private boolean shouldInitializeProxy(Object value) { return true; } + /** + * An embedded collection that is already initialized (e.g. an inline {@code state} snapshot + * captured at flush time) reflects the current, correct association. Re-resolving it through a + * freshly created, uninitialized entity proxy re-queries the collection and can race with the + * not-yet-visible write, wiping out data that was already there. Only force the proxy re-fetch + * for a singular embedded property or a collection that isn't loaded yet. + */ + private boolean shouldRefetchThroughProxy(Object value, Property property) { + return shouldInitializeProxy(value) + || (property.isEmbeddedObject() && !property.isCollection()); + } + private Object getId(Object object) { if (object instanceof IdentifiableObject obj) return obj.getUid(); return object; diff --git a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataset/DataSetStoreTest.java b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataset/DataSetStoreTest.java index 06520e861938..b7cbbac0eaf6 100644 --- a/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataset/DataSetStoreTest.java +++ b/dhis-2/dhis-test-integration/src/test/java/org/hisp/dhis/dataset/DataSetStoreTest.java @@ -34,17 +34,27 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import jakarta.persistence.PersistenceException; import java.util.HashSet; import java.util.List; +import java.util.Set; import org.hisp.dhis.category.CategoryCombo; import org.hisp.dhis.common.IdentifiableObjectManager; +import org.hisp.dhis.dataapproval.DataApprovalWorkflow; import org.hisp.dhis.dataelement.DataElement; +import org.hisp.dhis.dataelement.DataElementOperand; import org.hisp.dhis.dataentryform.DataEntryForm; import org.hisp.dhis.dataentryform.DataEntryFormService; +import org.hisp.dhis.indicator.Indicator; +import org.hisp.dhis.indicator.IndicatorType; +import org.hisp.dhis.legend.LegendSet; import org.hisp.dhis.organisationunit.OrganisationUnit; import org.hisp.dhis.organisationunit.OrganisationUnitStore; +import org.hisp.dhis.period.Period; +import org.hisp.dhis.period.PeriodService; import org.hisp.dhis.period.PeriodType; import org.hisp.dhis.period.PeriodTypeEnum; import org.hisp.dhis.test.integration.PostgresIntegrationTestBase; @@ -73,6 +83,8 @@ class DataSetStoreTest extends PostgresIntegrationTestBase { @Autowired private IdentifiableObjectManager manager; + @Autowired private PeriodService periodService; + // ------------------------------------------------------------------------- // Supportive methods // ------------------------------------------------------------------------- @@ -220,4 +232,291 @@ private void createDataSetElementAndSave(DataElement de, DataSet ds) { ds.addDataSetElement(dse); manager.save(ds); } + + // ------------------------------------------------------------------------- + // JPA migration verification + // ------------------------------------------------------------------------- + + @Test + @DisplayName("SEQUENCE id generation produces positive, increasing identifiers") + void testJpaIdGeneration() { + DataSet dataSetA = addDataSet('A'); + DataSet dataSetB = addDataSet('B'); + + assertTrue(dataSetA.getId() > 0); + assertTrue(dataSetB.getId() > dataSetA.getId()); + } + + @Test + @DisplayName( + "periodType (EAGER) and categoryCombo (LAZY) many-to-one associations survive a reload") + void testJpaManyToOneAssociationsSurviveReload() { + DataSet dataSet = addDataSet('A'); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertEquals(PERIOD_TYPE.getName(), reloaded.getPeriodType().getName()); + assertNotNull(reloaded.getCategoryCombo()); + } + + @Test + @DisplayName("dataInputPeriods (owned, cascade all-delete-orphan) round-trips after a reload") + void testJpaDataInputPeriods() { + // Use PeriodType#createPeriod() so both startDate and endDate are populated - + // HibernatePeriodStore#save() binds a null endDate incorrectly via native SQL, which is a + // pre-existing quirk unrelated to this migration. + Period period = PERIOD_TYPE.createPeriod(); + periodService.addPeriod(period); + + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + DataInputPeriod dip = new DataInputPeriod(); + dip.setPeriod(period); + dataSet.addDataInputPeriod(dip); + dataSetStore.save(dataSet); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertEquals(1, reloaded.getDataInputPeriods().size()); + } + + @Test + @DisplayName("dataSetElements owns the foreign key and deletes orphaned elements after reload") + void testJpaDataSetElementsOwnershipAndOrphanRemoval() { + DataElement first = createDataElementAndSave('A'); + DataElement second = createDataElementAndSave('B'); + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSet.getDataSetElements().add(new DataSetElement(null, first)); + dataSet.getDataSetElements().add(new DataSetElement(null, second)); + dataSetStore.save(dataSet); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertContainsOnly(List.of(first, second), reloaded.getDataElements()); + reloaded + .getDataSetElements() + .forEach(element -> assertEquals(id, element.getDataSet().getId())); + + reloaded.getDataSetElements().clear(); + dataSetStore.update(reloaded); + clearSession(); + + assertTrue(dataSetStore.get(id).getDataSetElements().isEmpty()); + assertEquals( + 0L, + entityManager + .createQuery("select count(e) from DataSetElement e", Long.class) + .getSingleResult()); + assertNotNull(manager.get(DataElement.class, first.getUid())); + assertNotNull(manager.get(DataElement.class, second.getUid())); + } + + @Test + @DisplayName("indicators many-to-many join table round-trips after a reload") + void testJpaIndicators() { + IndicatorType type = createIndicatorType('A'); + manager.save(type); + Indicator indicator = createIndicator('A', type); + manager.save(indicator); + + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSet.getIndicators().add(indicator); + dataSetStore.save(dataSet); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertEquals(1, reloaded.getIndicators().size()); + assertEquals(indicator.getUid(), reloaded.getIndicators().iterator().next().getUid()); + } + + @Test + @DisplayName( + "compulsoryDataElementOperands (many-to-many with Hibernate ALL+DELETE_ORPHAN cascade) " + + "round-trips and removing an operand deletes the orphaned row, not just the join row") + void testJpaCompulsoryDataElementOperandsCascadeDeleteOrphan() { + DataElement de = createDataElementAndSave('A'); + + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + DataElementOperand operand = new DataElementOperand(de); + dataSet.addCompulsoryDataElementOperand(operand); + dataSetStore.save(dataSet); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertEquals(1, reloaded.getCompulsoryDataElementOperands().size()); + + reloaded.getCompulsoryDataElementOperands().clear(); + dataSetStore.update(reloaded); + entityManager.flush(); + clearSession(); + + DataSet reloadedAgain = dataSetStore.get(id); + assertTrue(reloadedAgain.getCompulsoryDataElementOperands().isEmpty()); + + Long remaining = + entityManager + .createQuery("select count(o) from DataElementOperand o", Long.class) + .getSingleResult(); + assertEquals(0L, remaining); + } + + @Test + @DisplayName("sources (inverse OrganisationUnit.dataSets, owned by DataSet) round-trips") + void testJpaSources() { + OrganisationUnit ouA = createOrganisationUnit('A'); + unitStore.save(ouA); + + DataSet dataSet = addDataSet('A', ouA); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertEquals(1, reloaded.getSources().size()); + assertTrue(reloaded.hasOrganisationUnit(ouA)); + } + + @Test + @DisplayName("sections (inverse OneToMany, ordered by sortOrder) round-trip in order") + void testJpaSectionsOrdering() { + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSetStore.save(dataSet); + + Section section2 = createSection('2', dataSet, List.of(), List.of()); + section2.setSortOrder(2); + Section section1 = createSection('1', dataSet, List.of(), List.of()); + section1.setSortOrder(1); + manager.save(section2); + manager.save(section1); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + List names = reloaded.getSections().stream().map(Section::getName).toList(); + assertEquals(List.of("Section1", "Section2"), names); + } + + @Test + @DisplayName( + "legendSets (ordered many-to-many, @OrderColumn base 0) round-trip with order preserved") + void testJpaLegendSetsOrdering() { + LegendSet legendSetC = createLegendSet('C'); + LegendSet legendSetA = createLegendSet('A'); + LegendSet legendSetB = createLegendSet('B'); + manager.save(legendSetC); + manager.save(legendSetA); + manager.save(legendSetB); + + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSet.getLegendSets().addAll(List.of(legendSetC, legendSetA, legendSetB)); + dataSetStore.save(dataSet); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + List uids = reloaded.getLegendSets().stream().map(LegendSet::getUid).toList(); + assertEquals(List.of(legendSetC.getUid(), legendSetA.getUid(), legendSetB.getUid()), uids); + } + + @Test + @DisplayName("workflow many-to-one and its inverse DataApprovalWorkflow.dataSets stay consistent") + void testJpaWorkflowAssociation() { + CategoryCombo cc = createCategoryCombo('W'); + manager.save(cc); + DataApprovalWorkflow workflow = + new DataApprovalWorkflow("WorkflowA", PERIOD_TYPE, cc, Set.of()); + manager.save(workflow); + + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSet.assignWorkflow(workflow); + dataSetStore.save(dataSet); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertNotNull(reloaded.getWorkflow()); + assertEquals(workflow.getUid(), reloaded.getWorkflow().getUid()); + } + + @Test + @DisplayName("attributeValues (jsonb) round-trips after a reload") + void testJpaAttributeValuesRoundTrip() { + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSetStore.save(dataSet); + long id = dataSet.getId(); + + clearSession(); + + DataSet reloaded = dataSetStore.get(id); + assertNotNull(reloaded.getAttributeValues()); + } + + @Test + @DisplayName("periodType is a required (NOT NULL) association") + void testJpaPeriodTypeNotNull() { + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSet.setPeriodType(null); + + assertThrows( + Exception.class, + () -> { + dataSetStore.save(dataSet); + entityManager.flush(); + }); + } + + @Test + @DisplayName("categoryCombo is a required (NOT NULL) association") + void testJpaCategoryComboNotNull() { + DataSet dataSet = createDataSet('A', PERIOD_TYPE); + dataSet.setCategoryCombo(null); + + assertThrows( + Exception.class, + () -> { + dataSetStore.save(dataSet); + entityManager.flush(); + }); + } + + @Test + @DisplayName("shortName has a unique constraint") + void testJpaShortNameUnique() { + DataSet dataSetA = addDataSet('A'); + + DataSet dataSetB = createDataSet('B', PERIOD_TYPE); + dataSetB.setShortName(dataSetA.getShortName()); + + assertThrows( + PersistenceException.class, + () -> { + dataSetStore.save(dataSetB); + entityManager.flush(); + }); + } + + @Test + @DisplayName( + "getDimensionItem/getUid stay computed and typedEquals matches the same-identity DataSet") + void testJpaDimensionalItemAndTypedEquals() { + DataSet dataSetA = addDataSet('A'); + + clearSession(); + + DataSet reloaded = dataSetStore.get(dataSetA.getId()); + assertEquals(dataSetA.getUid(), reloaded.getDimensionItem()); + assertTrue(reloaded.typedEquals(dataSetA)); + } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MetadataImportExportControllerTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MetadataImportExportControllerTest.java index 008d1c651a5c..48021840b8c1 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MetadataImportExportControllerTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/controller/MetadataImportExportControllerTest.java @@ -1349,6 +1349,73 @@ void removeDataSetIndicatorTest() { .contains("IndUid000x1")); } + @Test + @DisplayName("Imported DataSetElements belong to their DataSet without explicit back-references") + void importDataSetElementsWithoutBackReferencesTest() { + DataElement first = createDataElement('A'); + first.setUid("DeUid0000x1"); + DataElement second = createDataElement('B'); + second.setUid("DeUid0000x2"); + manager.save(first); + manager.save(second); + + String metadata = + """ + {'dataSets': [{ + 'id': 'dsUid0000x1', + 'name': 'test ds 1', + 'shortName': 'test ds 1', + 'periodType': 'Monthly', + 'dataSetElements': [%s] + }]} + """; + String firstElement = "{'dataElement': {'id': 'DeUid0000x1'}}"; + String secondElement = "{'dataElement': {'id': 'DeUid0000x2'}}"; + + POST("/metadata", metadata.formatted(firstElement + "," + secondElement)) + .content(HttpStatus.OK); + startNewRequestSession(); + + JsonDataSet dataSet = GET("/dataSets/dsUid0000x1").content(HttpStatus.OK).as(JsonDataSet.class); + assertEquals( + Set.of("DeUid0000x1", "DeUid0000x2"), + dataSet.getDatSetElements().stream() + .map(element -> element.getDatElement().getId()) + .collect(Collectors.toSet())); + + POST("/metadata", metadata.formatted(firstElement)).content(HttpStatus.OK); + startNewRequestSession(); + + JsonDataSet updated = GET("/dataSets/dsUid0000x1").content(HttpStatus.OK).as(JsonDataSet.class); + assertEquals( + List.of("DeUid0000x1"), + updated.getDatSetElements().stream() + .map(element -> element.getDatElement().getId()) + .toList()); + assertEquals( + 1L, + entityManager + .createQuery("select count(e) from DataSetElement e", Long.class) + .getSingleResult()); + assertNotNull(manager.get(DataElement.class, "DeUid0000x2")); + + POST("/metadata", metadata.formatted("")).content(HttpStatus.OK); + startNewRequestSession(); + + assertTrue( + GET("/dataSets/dsUid0000x1") + .content(HttpStatus.OK) + .as(JsonDataSet.class) + .getDatSetElements() + .isEmpty()); + assertEquals( + 0L, + entityManager + .createQuery("select count(e) from DataSetElement e", Long.class) + .getSingleResult()); + assertNotNull(manager.get(DataElement.class, "DeUid0000x1")); + } + @Test void testSaveNotificationTemplateWithDeliveryChannels() { POST("/metadata", Path.of("program/program_notification_template.json")).content(HttpStatus.OK);