From 63f592c14c7cc4b0de08072de8f6507521d95d15 Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 12 Sep 2026 08:55:25 +0300 Subject: [PATCH] data-structures: a table may carry more than one unique constraint (#7343) TableConstraint, the @MappedSuperclass behind the unique / foreign-key / check / primary-key constraints, joined its owning TableConstraints with @OneToOne. The relation is many-to-one - one table's TableConstraints owns a LIST of uniques, a list of foreign keys and a list of checks - and the @OneToOne made Hibernate put a unique index on the join column, so each of those tables could hold at most one row per table. A table declaring a second unique key therefore failed its second insert with a duplicate key, SchemasSynchronizer failed the whole .schema, and on a fresh instance none of that module's tables were ever created. Nothing in front of it said so: the model, the generated .schema and the publish were all green, and the key the author declared simply constrained nothing. The mapping is @ManyToOne now. The index it created is already materialized on every deployed instance, so it is dropped by three changesets naming the constraints this changelog created, plus an H2 and a PostgreSQL sweep for a deployment bootstrapped by hbm2ddl before the changelog existed, whose index carries the name Hibernate generated for it. Both sweeps are no-ops where nothing is left, so the changelog stays idempotent. SchemaTwoUniqueConstraintsIT publishes a .schema with two unique keys on one table and asserts both reach the database and each refuses its own duplicate. It fails on master with the reported 23505 and no table created at all. Co-Authored-By: Claude Opus 5 --- .../db/changelog/dirigible-system.json | 130 ++++++++++++++ .../structures/domain/TableConstraint.java | 8 +- .../api/SchemaTwoUniqueConstraintsIT.java | 170 ++++++++++++++++++ 3 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/SchemaTwoUniqueConstraintsIT.java 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 + "\""); + } + } +}