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 uniqueIndexNames() throws Exception { + List names = new ArrayList<>(); + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection(); + ResultSet indexes = connection.getMetaData() + .getIndexInfo(null, connection.getSchema(), TABLE_NAME, true, false)) { + while (indexes.next()) { + String name = indexes.getString("INDEX_NAME"); + if (null != name && !names.contains(name)) { + names.add(name); + } + } + } + return names; + } + + private void insertRow(String project, String employee, String company, String number) throws Exception { + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection(); + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO \"" + TABLE_NAME + "\" (\"PROJECT\", \"EMPLOYEE\", \"COMPANY\", \"NUMBER\") VALUES (?, ?, ?, ?)")) { + statement.setString(1, project); + statement.setString(2, employee); + statement.setString(3, company); + statement.setString(4, number); + statement.executeUpdate(); + } + } + + /** + * The message the database refuses the insert with - it names the violated key on every vendor, so + * the assertion is about the key that fired and not merely about something having gone wrong. + */ + private String refusalOf(String project, String employee, String company, String number) { + try { + insertRow(project, employee, company, number); + throw new AssertionError("the insert was accepted - the key does not constrain anything"); + } catch (SQLException refused) { + return refused.getMessage(); + } catch (Exception unexpected) { + throw new IllegalStateException("the insert failed for another reason than the key", unexpected); + } + } + + @AfterEach + void cleanUp() throws Exception { + repository.removeResource(SCHEMA_PATH); + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP TABLE IF EXISTS \"" + TABLE_NAME + "\""); + } + } +}