Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 $$;"
}
}
]
}
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* {@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<String> uniqueIndexNames() throws Exception {
List<String> 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 + "\"");
}
}
}
Loading