diff --git a/components/core/core-liquibase/src/main/resources/db/changelog/dirigible-system.json b/components/core/core-liquibase/src/main/resources/db/changelog/dirigible-system.json index 85f69956059..851a9d183e9 100644 --- a/components/core/core-liquibase/src/main/resources/db/changelog/dirigible-system.json +++ b/components/core/core-liquibase/src/main/resources/db/changelog/dirigible-system.json @@ -10391,6 +10391,136 @@ } ] } + }, + { + "changeSet": { + "id": "drop-UK_DIRIGIBLE_DATA_TABLE_UNIQUES_CONSTRAINTS_ID", + "author": "dirigible", + "comment": "TableConstraint joined its owning TableConstraints with @OneToOne, so Hibernate put a unique index on the join column and a table could carry at most ONE unique index, ONE foreign key and ONE check - the second insert failed with a duplicate key and the whole .schema was never synchronized. The mapping is @ManyToOne now; the index it created is already materialized on every deployed instance and is dropped here.", + "preConditions": [ + { + "onFail": "MARK_RAN" + }, + { + "uniqueConstraintExists": { + "tableName": "DIRIGIBLE_DATA_TABLE_UNIQUES", + "constraintName": "UK_DIRIGIBLE_DATA_TABLE_UNIQUES_CONSTRAINTS_ID" + } + } + ], + "changes": [ + { + "dropUniqueConstraint": { + "constraintName": "UK_DIRIGIBLE_DATA_TABLE_UNIQUES_CONSTRAINTS_ID", + "tableName": "DIRIGIBLE_DATA_TABLE_UNIQUES" + } + } + ] + } + }, + { + "changeSet": { + "id": "drop-UK_DIRIGIBLE_DATA_TABLE_FOREIGNKEYS_CONSTRAINTS_ID", + "author": "dirigible", + "comment": "TableConstraint joined its owning TableConstraints with @OneToOne, so Hibernate put a unique index on the join column and a table could carry at most ONE unique index, ONE foreign key and ONE check - the second insert failed with a duplicate key and the whole .schema was never synchronized. The mapping is @ManyToOne now; the index it created is already materialized on every deployed instance and is dropped here.", + "preConditions": [ + { + "onFail": "MARK_RAN" + }, + { + "uniqueConstraintExists": { + "tableName": "DIRIGIBLE_DATA_TABLE_FOREIGNKEYS", + "constraintName": "UK_DIRIGIBLE_DATA_TABLE_FOREIGNKEYS_CONSTRAINTS_ID" + } + } + ], + "changes": [ + { + "dropUniqueConstraint": { + "constraintName": "UK_DIRIGIBLE_DATA_TABLE_FOREIGNKEYS_CONSTRAINTS_ID", + "tableName": "DIRIGIBLE_DATA_TABLE_FOREIGNKEYS" + } + } + ] + } + }, + { + "changeSet": { + "id": "drop-UK_DIRIGIBLE_DATA_TABLE_CHECKS_CONSTRAINTS_ID", + "author": "dirigible", + "comment": "TableConstraint joined its owning TableConstraints with @OneToOne, so Hibernate put a unique index on the join column and a table could carry at most ONE unique index, ONE foreign key and ONE check - the second insert failed with a duplicate key and the whole .schema was never synchronized. The mapping is @ManyToOne now; the index it created is already materialized on every deployed instance and is dropped here.", + "preConditions": [ + { + "onFail": "MARK_RAN" + }, + { + "uniqueConstraintExists": { + "tableName": "DIRIGIBLE_DATA_TABLE_CHECKS", + "constraintName": "UK_DIRIGIBLE_DATA_TABLE_CHECKS_CONSTRAINTS_ID" + } + } + ], + "changes": [ + { + "dropUniqueConstraint": { + "constraintName": "UK_DIRIGIBLE_DATA_TABLE_CHECKS_CONSTRAINTS_ID", + "tableName": "DIRIGIBLE_DATA_TABLE_CHECKS" + } + } + ] + } + }, + { + "changeSet": { + "id": "drop-legacy-named-UK_DIRIGIBLE_DATA_TABLE_CONSTRAINTS_ID-h2", + "author": "dirigible", + "comment": "A deployment bootstrapped by hbm2ddl before this changelog existed carries the same unique index under the name Hibernate generated for it, which the drop-by-name changesets above cannot address. It is dropped here by the column it covers. A no-op where no such index is left, so the changelog stays idempotent; MSSQL is not swept - it has no CI leg to verify the statement against.", + "preConditions": [ + { + "onFail": "MARK_RAN" + }, + { + "dbms": { + "type": "h2" + } + } + ], + "changes": [ + { + "sql": { + "dbms": "h2", + "splitStatements": true, + "sql": "EXECUTE IMMEDIATE (SELECT COALESCE(MAX('ALTER TABLE \"' || tc.TABLE_NAME || '\" DROP CONSTRAINT \"' || tc.CONSTRAINT_NAME || '\"'), 'SET @DIRIGIBLE_NOOP = 1') FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k ON k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND k.TABLE_NAME = tc.TABLE_NAME WHERE tc.CONSTRAINT_TYPE = 'UNIQUE' AND UPPER(tc.TABLE_NAME) = 'DIRIGIBLE_DATA_TABLE_UNIQUES' AND UPPER(k.COLUMN_NAME) = 'CONSTRAINTS_ID'); EXECUTE IMMEDIATE (SELECT COALESCE(MAX('ALTER TABLE \"' || tc.TABLE_NAME || '\" DROP CONSTRAINT \"' || tc.CONSTRAINT_NAME || '\"'), 'SET @DIRIGIBLE_NOOP = 1') FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k ON k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND k.TABLE_NAME = tc.TABLE_NAME WHERE tc.CONSTRAINT_TYPE = 'UNIQUE' AND UPPER(tc.TABLE_NAME) = 'DIRIGIBLE_DATA_TABLE_FOREIGNKEYS' AND UPPER(k.COLUMN_NAME) = 'CONSTRAINTS_ID'); EXECUTE IMMEDIATE (SELECT COALESCE(MAX('ALTER TABLE \"' || tc.TABLE_NAME || '\" DROP CONSTRAINT \"' || tc.CONSTRAINT_NAME || '\"'), 'SET @DIRIGIBLE_NOOP = 1') FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE k ON k.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND k.TABLE_NAME = tc.TABLE_NAME WHERE tc.CONSTRAINT_TYPE = 'UNIQUE' AND UPPER(tc.TABLE_NAME) = 'DIRIGIBLE_DATA_TABLE_CHECKS' AND UPPER(k.COLUMN_NAME) = 'CONSTRAINTS_ID');" + } + } + ] + } + }, + { + "changeSet": { + "id": "drop-legacy-named-UK_DIRIGIBLE_DATA_TABLE_CONSTRAINTS_ID-postgresql", + "author": "dirigible", + "comment": "A deployment bootstrapped by hbm2ddl before this changelog existed carries the same unique index under the name Hibernate generated for it, which the drop-by-name changesets above cannot address. It is dropped here by the column it covers. A no-op where no such index is left, so the changelog stays idempotent; MSSQL is not swept - it has no CI leg to verify the statement against.", + "preConditions": [ + { + "onFail": "MARK_RAN" + }, + { + "dbms": { + "type": "postgresql" + } + } + ], + "changes": [ + { + "sql": { + "dbms": "postgresql", + "splitStatements": false, + "sql": "DO $$\nDECLARE r record;\nBEGIN\n FOR r IN SELECT tc.table_name, tc.constraint_name\n FROM information_schema.table_constraints tc\n JOIN information_schema.key_column_usage k\n ON k.constraint_name = tc.constraint_name AND k.table_name = tc.table_name\n WHERE tc.constraint_type = 'UNIQUE'\n AND upper(tc.table_name) IN ('DIRIGIBLE_DATA_TABLE_UNIQUES','DIRIGIBLE_DATA_TABLE_FOREIGNKEYS','DIRIGIBLE_DATA_TABLE_CHECKS')\n AND upper(k.column_name) = 'CONSTRAINTS_ID'\n LOOP\n EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', r.table_name, r.constraint_name);\n END LOOP;\nEND $$;" + } + } + ] + } } ] } diff --git a/components/data/data-structures/src/main/java/org/eclipse/dirigible/components/data/structures/domain/TableConstraint.java b/components/data/data-structures/src/main/java/org/eclipse/dirigible/components/data/structures/domain/TableConstraint.java index 978349184e8..cbb37fe4cc8 100644 --- a/components/data/data-structures/src/main/java/org/eclipse/dirigible/components/data/structures/domain/TableConstraint.java +++ b/components/data/data-structures/src/main/java/org/eclipse/dirigible/components/data/structures/domain/TableConstraint.java @@ -45,8 +45,12 @@ public abstract class TableConstraint { @Expose protected String[] columns; - /** The constraints. */ - @OneToOne(fetch = FetchType.EAGER, optional = false) + /** + * The constraints. Many-to-one on purpose: one table's {@link TableConstraints} owns a list of + * uniques, a list of foreign keys and a list of checks. A {@code @OneToOne} here made Hibernate put + * a unique index on the join column, so a table could carry at most one of each. + */ + @ManyToOne(fetch = FetchType.EAGER, optional = false) @JoinColumn(name = "CONSTRAINTS_ID", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) @JsonIgnore diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SchemaTwoUniqueConstraintsIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SchemaTwoUniqueConstraintsIT.java new file mode 100644 index 00000000000..d61deecfa6e --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SchemaTwoUniqueConstraintsIT.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; +import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * A table may declare more than one unique constraint, and every one of them reaches the database. + * + *
+ * {@code TableConstraint} joined its owning {@code TableConstraints} with {@code @OneToOne}, so
+ * Hibernate put a unique index on the join column and the second constraint of a table could not be
+ * persisted at all: the insert failed with a duplicate key, the whole {@code .schema} was marked
+ * failed, and on a fresh instance none of its tables were ever created (#7343). Nothing in front of
+ * that said so - the model, the generated {@code .schema} and the publish were all green, and the
+ * key the author declared simply constrained nothing.
+ */
+class SchemaTwoUniqueConstraintsIT extends IntegrationTest {
+
+ private static final String PROJECT = "schema-two-unique-constraints-it";
+
+ private static final String SCHEMA_PATH = IRepositoryStructure.PATH_REGISTRY_PUBLIC + "/" + PROJECT + "/two-unique-constraints.schema";
+
+ private static final String TABLE_NAME = "TWO_KEYS_TIMESHEET";
+
+ private static final String FIRST_KEY = "TwoKeysTimesheet_Project_Employee";
+
+ private static final String SECOND_KEY = "TwoKeysTimesheet_Company_Number";
+
+ private static final String SCHEMA_SOURCE = """
+ {
+ "schema": {
+ "structures": [
+ {
+ "name": "TWO_KEYS_TIMESHEET",
+ "type": "TABLE",
+ "columns": [
+ { "name": "ID", "type": "INTEGER", "primaryKey": true, "identity": true, "nullable": false },
+ { "name": "PROJECT", "type": "VARCHAR", "length": "50", "nullable": true },
+ { "name": "EMPLOYEE", "type": "VARCHAR", "length": "50", "nullable": true },
+ { "name": "COMPANY", "type": "VARCHAR", "length": "50", "nullable": true },
+ { "name": "NUMBER", "type": "VARCHAR", "length": "50", "nullable": true }
+ ],
+ "constraints": {
+ "uniqueIndexes": [
+ {
+ "name": "TwoKeysTimesheet_Project_Employee",
+ "columns": ["PROJECT", "EMPLOYEE"]
+ },
+ {
+ "name": "TwoKeysTimesheet_Company_Number",
+ "columns": ["COMPANY", "NUMBER"]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ """;
+
+ @Autowired
+ private IRepository repository;
+
+ @Autowired
+ private SynchronizationProcessor synchronizationProcessor;
+
+ @Autowired
+ private DataSourcesManager dataSourcesManager;
+
+ @Test
+ void bothDeclaredKeysReachTheCreatedTableAndRefuseADuplicate() throws Exception {
+ repository.createResource(SCHEMA_PATH, SCHEMA_SOURCE.getBytes(StandardCharsets.UTF_8), false, "application/json", true);
+ synchronizationProcessor.forceProcessSynchronizers();
+
+ assertThat(uniqueIndexNames()).as("both declared keys must be created on [%s]", TABLE_NAME)
+ .contains(FIRST_KEY, SECOND_KEY);
+
+ insertRow("P1", "E1", "C1", "N1");
+
+ assertThat(refusalOf("P2", "E2", "C1", "N1")).as("the second key must refuse a duplicate (company, number)")
+ .contains(SECOND_KEY);
+ assertThat(refusalOf("P1", "E1", "C2", "N2")).as("the first key must refuse a duplicate (project, employee)")
+ .contains(FIRST_KEY);
+ }
+
+ /**
+ * A declared unique key materializes as a unique index (that is the DDL
+ * {@code TableCreateProcessor} emits), so the database's own index catalog is what proves it
+ * reached the table - on every vendor, through one JDBC call.
+ */
+ private List