This is an automated email from the ASF dual-hosted git repository.

ibessonov pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new 4c61ee327c IGNITE-19229 Schema validation during tx processing: 
validators (#2872)
4c61ee327c is described below

commit 4c61ee327cd38483e7f431641f4dbdf1d3e4b3ac
Author: Roman Puchkovskiy <[email protected]>
AuthorDate: Tue Nov 28 11:03:08 2023 +0400

    IGNITE-19229 Schema validation during tx processing: validators (#2872)
---
 .../commands/AlterTableAlterColumnCommand.java     |  30 +-
 .../internal/catalog/commands/CatalogUtils.java    |  73 +++
 .../commands/TypeChangeValidationListener.java     |  43 ++
 .../ItSchemaForwardCompatibilityTest.java          | 175 ++++++
 .../replicator/PartitionReplicaListener.java       |   2 +
 .../replicator/SchemaCompatibilityValidator.java   | 191 ++++++-
 .../schema/CatalogValidationSchemasSource.java     |   1 +
 .../distributed/schema/ColumnDefinitionDiff.java   |  51 +-
 .../table/distributed/schema/FullTableSchema.java  |  21 +-
 .../distributed/schema/TableDefinitionDiff.java    |  48 +-
 .../replication/PartitionReplicaListenerTest.java  |   4 +-
 .../SchemaCompatibilityValidatorTest.java          | 594 +++++++++++++++++++++
 .../distributed/schema/FullTableSchemaTest.java    |  36 +-
 .../table/impl/DummyValidationSchemasSource.java   |   6 +-
 14 files changed, 1188 insertions(+), 87 deletions(-)

diff --git 
a/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/AlterTableAlterColumnCommand.java
 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/AlterTableAlterColumnCommand.java
index 1906ea3d59..6f7f3c42e1 100644
--- 
a/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/AlterTableAlterColumnCommand.java
+++ 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/AlterTableAlterColumnCommand.java
@@ -44,6 +44,10 @@ public class AlterTableAlterColumnCommand extends 
AbstractTableCommand {
         return new Builder();
     }
 
+    private static final TypeChangeValidationListener 
TYPE_CHANGE_VALIDATION_HANDLER = (pattern, originalType, newType) -> {
+        throw new CatalogValidationException(format(pattern, originalType, 
newType));
+    };
+
     private final String columnName;
 
     private final @Nullable ColumnType type;
@@ -145,31 +149,7 @@ public class AlterTableAlterColumnCommand extends 
AbstractTableCommand {
     }
 
     private void validateColumnChange(CatalogTableColumnDescriptor origin) {
-        if (type != null && type != origin.type()) {
-            if (!CatalogUtils.isSupportedColumnTypeChange(origin.type(), 
type)) {
-                throw new CatalogValidationException(format("Changing the type 
from {} to {} is not allowed", origin.type(), type));
-            }
-        }
-
-        if (precision != null && precision != origin.precision() && 
origin.type() != ColumnType.DECIMAL) {
-            throw new CatalogValidationException(format("Changing the 
precision for column of type '{}' is not allowed", origin.type()));
-        }
-
-        if (precision != null && precision < origin.precision()) {
-            throw new CatalogValidationException("Decreasing the precision is 
not allowed");
-        }
-
-        if (scale != null && scale != origin.scale()) {
-            throw new CatalogValidationException("Changing the scale is not 
allowed");
-        }
-
-        if (length != null && length != origin.length() && origin.type() != 
ColumnType.STRING && origin.type() != ColumnType.BYTE_ARRAY) {
-            throw new CatalogValidationException(format("Changing the length 
for column of type '{}' is not allowed", origin.type()));
-        }
-
-        if (length != null && length < origin.length()) {
-            throw new CatalogValidationException("Decreasing the length is not 
allowed");
-        }
+        CatalogUtils.validateColumnChange(origin, type, precision, scale, 
length, TYPE_CHANGE_VALIDATION_HANDLER);
 
         if (nullable != null && !nullable && origin.nullable()) {
             throw new CatalogValidationException("Adding NOT NULL constraint 
is not allowed");
diff --git 
a/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/CatalogUtils.java
 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/CatalogUtils.java
index 670c8eebb9..408c16647c 100644
--- 
a/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/CatalogUtils.java
+++ 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/CatalogUtils.java
@@ -211,6 +211,79 @@ public class CatalogUtils {
         return supportedTransitions != null && 
supportedTransitions.contains(target);
     }
 
+    /**
+     * Validates a column change. If something is not valid, the supplied 
listener is invoked with information about the exact reason.
+     *
+     * @param origin Original column definition.
+     * @param newType New type.
+     * @param newPrecision New column precision.
+     * @param newScale New column scale.
+     * @param newLength New column length.
+     * @param listener Listener to invoke on a validation failure.
+     * @return {@code true} iff the proposed change is valid.
+     */
+    static boolean validateColumnChange(
+            CatalogTableColumnDescriptor origin,
+            @Nullable ColumnType newType,
+            @Nullable Integer newPrecision,
+            @Nullable Integer newScale,
+            @Nullable Integer newLength,
+            TypeChangeValidationListener listener
+    ) {
+        if (newType != null && newType != origin.type()) {
+            if (!isSupportedColumnTypeChange(origin.type(), newType)) {
+                listener.onFailure("Changing the type from {} to {} is not 
allowed", origin.type(), newType);
+                return false;
+            }
+        }
+
+        if (newPrecision != null && newPrecision != origin.precision() && 
origin.type() != ColumnType.DECIMAL) {
+            listener.onFailure("Changing the precision for column of type '{}' 
is not allowed", origin.type(), newType);
+            return false;
+        }
+
+        if (newPrecision != null && newPrecision < origin.precision()) {
+            listener.onFailure("Decreasing the precision is not allowed", 
origin.type(), newType);
+            return false;
+        }
+
+        if (newScale != null && newScale != origin.scale()) {
+            listener.onFailure("Changing the scale is not allowed", 
origin.type(), newType);
+            return false;
+        }
+
+        if (newLength != null && newLength != origin.length()
+                && origin.type() != ColumnType.STRING && origin.type() != 
ColumnType.BYTE_ARRAY) {
+            listener.onFailure("Changing the length for column of type '{}' is 
not allowed", origin.type(), newType);
+            return false;
+        }
+
+        if (newLength != null && newLength < origin.length()) {
+            listener.onFailure("Decreasing the length is not allowed", 
origin.type(), newType);
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Returns whether the proposed column type change is supported.
+     *
+     * @param oldColumn Original column definition.
+     * @param newColumn New column definition.
+     * @return {@code true} iff the proposed change is supported.
+     */
+    public static boolean 
isColumnTypeChangeSupported(CatalogTableColumnDescriptor oldColumn, 
CatalogTableColumnDescriptor newColumn) {
+        return validateColumnChange(
+                oldColumn,
+                newColumn.type(),
+                newColumn.precision(),
+                newColumn.scale(),
+                newColumn.length(),
+                TypeChangeValidationListener.NO_OP
+        );
+    }
+
     /**
      * Returns a list of schemas, replacing any schema with {@code newSchema} 
if its id equal to {@code newSchema.id()}.
      *
diff --git 
a/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/TypeChangeValidationListener.java
 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/TypeChangeValidationListener.java
new file mode 100644
index 0000000000..8910b48bc1
--- /dev/null
+++ 
b/modules/catalog/src/main/java/org/apache/ignite/internal/catalog/commands/TypeChangeValidationListener.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.catalog.commands;
+
+import org.apache.ignite.sql.ColumnType;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Listener that gets invoked when column type change fails for some reason 
with the exact reason of the failure.
+ */
+@SuppressWarnings("InterfaceMayBeAnnotatedFunctional")
+public interface TypeChangeValidationListener {
+    /**
+     * A listener that does nothing.
+     */
+    TypeChangeValidationListener NO_OP = (pattern, originalType, newType) -> {
+        // No-op.
+    };
+
+    /**
+     * Gets invoked on a validation failure.
+     *
+     * @param pattern String pattern that denotes the validation failure 
reason (placeholders are marked with {}).
+     * @param originalType Original column type.
+     * @param newType New column type.
+     */
+    void onFailure(String pattern, ColumnType originalType, @Nullable 
ColumnType newType);
+}
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/schemasync/ItSchemaForwardCompatibilityTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/schemasync/ItSchemaForwardCompatibilityTest.java
new file mode 100644
index 0000000000..563b97b375
--- /dev/null
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/schemasync/ItSchemaForwardCompatibilityTest.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.schemasync;
+
+import static org.apache.ignite.internal.SessionUtils.executeUpdate;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.ignite.internal.Cluster;
+import org.apache.ignite.internal.ClusterPerTestIntegrationTest;
+import org.apache.ignite.internal.app.IgniteImpl;
+import org.apache.ignite.internal.table.TableViewInternal;
+import org.apache.ignite.internal.tx.InternalTransaction;
+import org.apache.ignite.internal.tx.TxState;
+import org.apache.ignite.lang.ErrorGroups.Transactions;
+import org.apache.ignite.table.Table;
+import org.apache.ignite.table.Tuple;
+import org.apache.ignite.tx.Transaction;
+import org.apache.ignite.tx.TransactionException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Tests about forward compatibility of table schemas as defined by IEP-110.
+ *
+ * @see <a 
href="https://cwiki.apache.org/confluence/display/IGNITE/IEP-110%3A+Schema+synchronization%3A+basic+schema+changes";>IEP-110</a>
+ */
+class ItSchemaForwardCompatibilityTest extends ClusterPerTestIntegrationTest {
+    private static final int NODES_TO_START = 1;
+
+    private static final String TABLE_NAME = "test";
+
+    private IgniteImpl node;
+
+    @Override
+    protected int initialNodes() {
+        return NODES_TO_START;
+    }
+
+    @BeforeEach
+    void assignNode() {
+        node = cluster.node(0);
+    }
+
+    /**
+     * Makes sure forward-compatible schema changes happenning between 
transaction operations and
+     * commit do not prevent a commit from happening.
+     */
+    @ParameterizedTest
+    @EnumSource(ForwardCompatibleDdl.class)
+    void forwardCompatibleSchemaChangesAllowCommitting(ForwardCompatibleDdl 
ddl) {
+        createTable();
+
+        Transaction tx = node.transactions().begin();
+
+        writeIn(tx);
+
+        ddl.executeOn(cluster);
+
+        assertDoesNotThrow(tx::commit);
+    }
+
+    @SuppressWarnings("resource")
+    private void writeIn(Transaction tx) {
+        putInTx(cluster.node(0).tables().table(TABLE_NAME), tx);
+    }
+
+    /**
+     * Makes sure forward-incompatible schema changes happenning between 
transaction operations and
+     * commit prevent a commit from happening: instead, the transaction is 
aborted.
+     */
+    @ParameterizedTest
+    @EnumSource(ForwardIncompatibleDdl.class)
+    void 
forwardIncompatibleSchemaChangesDoNotAllowCommitting(ForwardIncompatibleDdl 
ddl) {
+        createTable();
+
+        Table table = node.tables().table(TABLE_NAME);
+
+        InternalTransaction tx = (InternalTransaction) 
node.transactions().begin();
+
+        writeIn(tx);
+
+        ddl.executeOn(cluster);
+
+        int tableId = ((TableViewInternal) table).tableId();
+
+        TransactionException ex = assertThrows(TransactionException.class, 
tx::commit);
+        assertThat(
+                ex.getMessage(),
+                containsString(String.format(
+                        "Commit failed because schema 1 is not 
forward-compatible with 2 for table %d",
+                        tableId
+                ))
+        );
+
+        assertThat(ex.code(), is(Transactions.TX_COMMIT_ERR));
+
+        assertThat(tx.state(), is(TxState.ABORTED));
+    }
+
+    private void createTable() {
+        cluster.doInSession(0, session -> {
+            executeUpdate(
+                    "CREATE TABLE " + TABLE_NAME + " (id INT PRIMARY KEY, 
not_null_int INT NOT NULL, int_with_default INT DEFAULT 1)",
+                    session
+            );
+        });
+    }
+
+    private static void putInTx(Table table, Transaction tx) {
+        table.keyValueView().put(tx, Tuple.create().set("id", 1), 
Tuple.create().set("not_null_int", 1));
+    }
+
+    private enum ForwardCompatibleDdl {
+        ADD_NULLABLE_COLUMN("ALTER TABLE " + TABLE_NAME + " ADD COLUMN new_col 
INT"),
+        ADD_COLUMN_WITH_DEFAULT("ALTER TABLE " + TABLE_NAME + " ADD COLUMN 
new_col INT NOT NULL DEFAULT 42"),
+        // TODO: IGNITE-19485, IGNITE-20315 - Uncomment this after column 
rename support gets aded.
+        //RENAME_COLUMN("ALTER TABLE " + TABLE_NAME + " RENAME COLUMN 
not_null_int to new_col"),
+        DROP_NOT_NULL("ALTER TABLE " + TABLE_NAME + " ALTER COLUMN 
not_null_int DROP NOT NULL");
+        // TODO: Uncomment after 
https://issues.apache.org/jira/browse/IGNITE-20906 is fixed.
+        //WIDEN_COLUMN_TYPE("ALTER TABLE " + TABLE_NAME + " ALTER COLUMN 
not_null_int SET DATA TYPE BIGINT"),
+
+        private final String ddl;
+
+        ForwardCompatibleDdl(String ddl) {
+            this.ddl = ddl;
+        }
+
+        void executeOn(Cluster cluster) {
+            cluster.doInSession(0, session -> {
+                executeUpdate(ddl, session);
+            });
+        }
+    }
+
+    private enum ForwardIncompatibleDdl {
+        // TODO: Enable after 
https://issues.apache.org/jira/browse/IGNITE-19484 is fixed.
+        //RENAME_TABLE("RENAME TABLE " + TABLE_NAME + " to new_table"),
+        DROP_COLUMN("ALTER TABLE " + TABLE_NAME + " DROP COLUMN not_null_int"),
+        ADD_DEFAULT("ALTER TABLE " + TABLE_NAME + " ALTER COLUMN not_null_int 
SET DEFAULT 102"),
+        CHANGE_DEFAULT("ALTER TABLE " + TABLE_NAME + " ALTER COLUMN 
int_with_default SET DEFAULT 102"),
+        DROP_DEFAULT("ALTER TABLE " + TABLE_NAME + " ALTER COLUMN 
int_with_default DROP DEFAULT");
+
+        private final String ddl;
+
+        ForwardIncompatibleDdl(String ddl) {
+            this.ddl = ddl;
+        }
+
+        void executeOn(Cluster cluster) {
+            cluster.doInSession(0, session -> {
+                executeUpdate(ddl, session);
+            });
+        }
+    }
+}
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
index ed862682fa..29e9483ef0 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/PartitionReplicaListener.java
@@ -1472,10 +1472,12 @@ public class PartitionReplicaListener implements 
ReplicaListener {
     private static void 
throwIfSchemaValidationOnCommitFailed(CompatValidationResult validationResult) {
         if (!validationResult.isSuccessful()) {
             if (validationResult.isTableDropped()) {
+                // TODO: IGNITE-20966 - improve error message.
                 throw new IncompatibleSchemaAbortException(
                         format("Commit failed because a table was already 
dropped [tableId={}]", validationResult.failedTableId())
                 );
             } else {
+                // TODO: IGNITE-20966 - improve error message.
                 throw new IncompatibleSchemaAbortException("Commit failed 
because schema "
                         + validationResult.fromSchemaVersion() + " is not 
forward-compatible with "
                         + validationResult.toSchemaVersion() + " for table " + 
validationResult.failedTableId());
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatibilityValidator.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatibilityValidator.java
index 724cf28f1a..c90a8239e7 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatibilityValidator.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatibilityValidator.java
@@ -27,9 +27,11 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import org.apache.ignite.internal.catalog.CatalogService;
+import 
org.apache.ignite.internal.catalog.descriptors.CatalogTableColumnDescriptor;
 import org.apache.ignite.internal.catalog.descriptors.CatalogTableDescriptor;
 import org.apache.ignite.internal.hlc.HybridTimestamp;
 import org.apache.ignite.internal.replicator.TablePartitionId;
+import 
org.apache.ignite.internal.table.distributed.schema.ColumnDefinitionDiff;
 import org.apache.ignite.internal.table.distributed.schema.FullTableSchema;
 import org.apache.ignite.internal.table.distributed.schema.SchemaSyncService;
 import org.apache.ignite.internal.table.distributed.schema.TableDefinitionDiff;
@@ -47,6 +49,13 @@ class SchemaCompatibilityValidator {
     // TODO: Remove entries from cache when compacting schemas in 
SchemaManager https://issues.apache.org/jira/browse/IGNITE-20789
     private final ConcurrentMap<TableDefinitionDiffKey, TableDefinitionDiff> 
diffCache = new ConcurrentHashMap<>();
 
+    private static final List<ForwardCompatibilityValidator> 
FORWARD_COMPATIBILITY_VALIDATORS = List.of(
+            new RenameTableValidator(),
+            new AddColumnsValidator(),
+            new DropColumnsValidator(),
+            new ChangeColumnsValidator()
+    );
+
     /** Constructor. */
     SchemaCompatibilityValidator(
             ValidationSchemasSource validationSchemasSource,
@@ -149,8 +158,24 @@ class SchemaCompatibilityValidator {
                 key -> nextSchema.diffFrom(prevSchema)
         );
 
-        // TODO: IGNITE-19229 - more sophisticated logic.
-        return diff.isEmpty();
+        boolean accepted = false;
+
+        for (ForwardCompatibilityValidator validator : 
FORWARD_COMPATIBILITY_VALIDATORS) {
+            switch (validator.compatible(diff)) {
+                case COMPATIBLE:
+                    accepted = true;
+                    break;
+                case INCOMPATIBLE:
+                    return false;
+                default:
+                    break;
+            }
+        }
+
+        assert accepted : "Table schema changed from " + 
prevSchema.schemaVersion() + " and " + nextSchema.schemaVersion()
+                + ", but no schema change validator voted for any change. Some 
schema validator is missing.";
+
+        return true;
     }
 
     /**
@@ -187,25 +212,14 @@ class SchemaCompatibilityValidator {
                 tupleSchemaVersion
         );
 
-        if (tableSchemas.isEmpty()) {
+        if (tableSchemas.size() < 2) {
             // The tuple was not written with a future schema.
             return CompatValidationResult.success();
         }
 
-        for (int i = 0; i < tableSchemas.size() - 1; i++) {
-            FullTableSchema oldSchema = tableSchemas.get(i);
-            FullTableSchema newSchema = tableSchemas.get(i + 1);
-            if (!isBackwardCompatible(oldSchema, newSchema)) {
-                return CompatValidationResult.incompatibleChange(tableId, 
oldSchema.schemaVersion(), newSchema.schemaVersion());
-            }
-        }
-
-        return CompatValidationResult.success();
-    }
-
-    private boolean isBackwardCompatible(FullTableSchema oldSchema, 
FullTableSchema newSchema) {
-        // TODO: IGNITE-19229 - is backward compatibility always symmetric 
with the forward compatibility?
-        return isForwardCompatible(newSchema, oldSchema);
+        FullTableSchema oldSchema = tableSchemas.get(0);
+        FullTableSchema newSchema = tableSchemas.get(1);
+        return CompatValidationResult.incompatibleChange(tableId, 
oldSchema.schemaVersion(), newSchema.schemaVersion());
     }
 
     void failIfSchemaChangedAfterTxStart(UUID txId, HybridTimestamp 
operationTimestamp, int tableId) {
@@ -259,4 +273,147 @@ class SchemaCompatibilityValidator {
             throw new InternalSchemaVersionMismatchException();
         }
     }
+
+    private enum ValidatorVerdict {
+        /**
+         * Validator accepts a change: it's compatible.
+         */
+        COMPATIBLE,
+        /**
+         * Validator rejects a change: it's incompatible.
+         */
+        INCOMPATIBLE,
+        /**
+         * Validator does not know how to handle a change.
+         */
+        DONT_CARE
+    }
+
+    @SuppressWarnings("InterfaceMayBeAnnotatedFunctional")
+    private interface ForwardCompatibilityValidator {
+        ValidatorVerdict compatible(TableDefinitionDiff diff);
+    }
+
+    private static class RenameTableValidator implements 
ForwardCompatibilityValidator {
+        @Override
+        public ValidatorVerdict compatible(TableDefinitionDiff diff) {
+            return diff.nameDiffers() ? ValidatorVerdict.INCOMPATIBLE : 
ValidatorVerdict.DONT_CARE;
+        }
+    }
+
+    private static class AddColumnsValidator implements 
ForwardCompatibilityValidator {
+        @Override
+        public ValidatorVerdict compatible(TableDefinitionDiff diff) {
+            if (diff.addedColumns().isEmpty()) {
+                return ValidatorVerdict.DONT_CARE;
+            }
+
+            for (CatalogTableColumnDescriptor column : diff.addedColumns()) {
+                if (!column.nullable() && column.defaultValue() == null) {
+                    return ValidatorVerdict.INCOMPATIBLE;
+                }
+            }
+
+            return ValidatorVerdict.COMPATIBLE;
+        }
+    }
+
+    private static class DropColumnsValidator implements 
ForwardCompatibilityValidator {
+        @Override
+        public ValidatorVerdict compatible(TableDefinitionDiff diff) {
+            return diff.removedColumns().isEmpty() ? 
ValidatorVerdict.DONT_CARE : ValidatorVerdict.INCOMPATIBLE;
+        }
+    }
+
+    @SuppressWarnings("InterfaceMayBeAnnotatedFunctional")
+    private interface ColumnChangeCompatibilityValidator {
+        ValidatorVerdict compatible(ColumnDefinitionDiff diff);
+    }
+
+    private static class ChangeColumnsValidator implements 
ForwardCompatibilityValidator {
+        private final List<ColumnChangeCompatibilityValidator> validators = 
List.of(
+                // TODO: https://issues.apache.org/jira/browse/IGNITE-20948 - 
add validator that says that column rename is compatible.
+                new ChangeNullabilityValidator(),
+                new ChangeDefaultValueValidator(),
+                new ChangeColumnTypeValidator()
+        );
+
+        @Override
+        public ValidatorVerdict compatible(TableDefinitionDiff diff) {
+            if (diff.changedColumns().isEmpty()) {
+                return ValidatorVerdict.DONT_CARE;
+            }
+
+            boolean accepted = false;
+
+            for (ColumnDefinitionDiff columnDiff : diff.changedColumns()) {
+                switch (compatible(columnDiff)) {
+                    case COMPATIBLE:
+                        accepted = true;
+                        break;
+                    case INCOMPATIBLE:
+                        return ValidatorVerdict.INCOMPATIBLE;
+                    default:
+                        break;
+                }
+            }
+
+            assert accepted : "Table schema changed from " + 
diff.oldSchemaVersion() + " and " + diff.newSchemaVersion()
+                    + ", but no column change validator voted for any change. 
Some schema validator is missing.";
+
+            return ValidatorVerdict.COMPATIBLE;
+        }
+
+        private ValidatorVerdict compatible(ColumnDefinitionDiff columnDiff) {
+            boolean accepted = false;
+
+            for (ColumnChangeCompatibilityValidator validator : validators) {
+                switch (validator.compatible(columnDiff)) {
+                    case COMPATIBLE:
+                        accepted = true;
+                        break;
+                    case INCOMPATIBLE:
+                        return ValidatorVerdict.INCOMPATIBLE;
+                    default:
+                        break;
+                }
+            }
+
+            return accepted ? ValidatorVerdict.COMPATIBLE : 
ValidatorVerdict.DONT_CARE;
+        }
+    }
+
+    private static class ChangeDefaultValueValidator implements 
ColumnChangeCompatibilityValidator {
+        @Override
+        public ValidatorVerdict compatible(ColumnDefinitionDiff diff) {
+            return diff.defaultChanged() ? ValidatorVerdict.INCOMPATIBLE : 
ValidatorVerdict.DONT_CARE;
+        }
+    }
+
+    private static class ChangeNullabilityValidator implements 
ColumnChangeCompatibilityValidator {
+        @Override
+        public ValidatorVerdict compatible(ColumnDefinitionDiff diff) {
+            if (diff.notNullAdded()) {
+                return ValidatorVerdict.INCOMPATIBLE;
+            }
+            if (diff.notNullDropped()) {
+                return ValidatorVerdict.COMPATIBLE;
+            }
+
+            assert !diff.nullabilityChanged() : diff;
+
+            return ValidatorVerdict.DONT_CARE;
+        }
+    }
+
+    private static class ChangeColumnTypeValidator implements 
ColumnChangeCompatibilityValidator {
+        @Override
+        public ValidatorVerdict compatible(ColumnDefinitionDiff diff) {
+            if (!diff.typeChanged()) {
+                return ValidatorVerdict.DONT_CARE;
+            }
+
+            return diff.typeChangeIsSupported() ? ValidatorVerdict.COMPATIBLE 
: ValidatorVerdict.INCOMPATIBLE;
+        }
+    }
 }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/CatalogValidationSchemasSource.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/CatalogValidationSchemasSource.java
index e0ca393a60..83b838b632 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/CatalogValidationSchemasSource.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/CatalogValidationSchemasSource.java
@@ -131,6 +131,7 @@ public class CatalogValidationSchemasSource implements 
ValidationSchemasSource {
         return new FullTableSchema(
                 tableDescriptor.tableVersion(),
                 tableDescriptor.id(),
+                tableDescriptor.name(),
                 tableDescriptor.columns()
         );
     }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/ColumnDefinitionDiff.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/ColumnDefinitionDiff.java
index 4a5f79889e..0590ad4f2e 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/ColumnDefinitionDiff.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/ColumnDefinitionDiff.java
@@ -17,21 +17,64 @@
 
 package org.apache.ignite.internal.table.distributed.schema;
 
+import java.util.Objects;
+import org.apache.ignite.internal.catalog.commands.CatalogUtils;
 import 
org.apache.ignite.internal.catalog.descriptors.CatalogTableColumnDescriptor;
 
 /**
  * Captures a difference between 'old' and 'new' versions of the same column 
definition.
  */
 public class ColumnDefinitionDiff {
-    @SuppressWarnings("PMD.UnusedPrivateField")
     private final CatalogTableColumnDescriptor oldColumn;
-    @SuppressWarnings("PMD.UnusedPrivateField")
     private final CatalogTableColumnDescriptor newColumn;
 
-    // TODO: IGNITE-19229 - extend
-
     public ColumnDefinitionDiff(CatalogTableColumnDescriptor oldColumn, 
CatalogTableColumnDescriptor newColumn) {
         this.oldColumn = oldColumn;
         this.newColumn = newColumn;
     }
+
+    /**
+     * Returns whether nullability has been changed on the column.
+     */
+    public boolean nullabilityChanged() {
+        return oldColumn.nullable() != newColumn.nullable();
+    }
+
+    /**
+     * Returns whether NOT NULL constraint has been dropped from the column.
+     */
+    public boolean notNullDropped() {
+        return !oldColumn.nullable() && newColumn.nullable();
+    }
+
+    /**
+     * Returns whether NOT NULL constraint has been added to the column.
+     */
+    public boolean notNullAdded() {
+        return oldColumn.nullable() && !newColumn.nullable();
+    }
+
+    /**
+     * Returns whether column type (including precision, scale, length) has 
been changed.
+     */
+    public boolean typeChanged() {
+        return oldColumn.type() != newColumn.type()
+                || oldColumn.precision() != newColumn.precision()
+                || oldColumn.scale() != newColumn.scale()
+                || oldColumn.length() != newColumn.length();
+    }
+
+    /**
+     * Returns whether type change is supported.
+     */
+    public boolean typeChangeIsSupported() {
+        return CatalogUtils.isColumnTypeChangeSupported(oldColumn, newColumn);
+    }
+
+    /**
+     * Returns whether the default value has been changed.
+     */
+    public boolean defaultChanged() {
+        return !Objects.equals(oldColumn.defaultValue(), 
newColumn.defaultValue());
+    }
 }
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchema.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchema.java
index 630d3716a7..84a00601a5 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchema.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchema.java
@@ -38,15 +38,17 @@ import 
org.apache.ignite.internal.catalog.descriptors.CatalogTableColumnDescript
 public class FullTableSchema {
     private final int schemaVersion;
     private final int tableId;
+    private final String tableName;
 
     private final List<CatalogTableColumnDescriptor> columns;
 
     /**
      * Constructor.
      */
-    public FullTableSchema(int schemaVersion, int tableId, 
List<CatalogTableColumnDescriptor> columns) {
+    public FullTableSchema(int schemaVersion, int tableId, String tableName, 
List<CatalogTableColumnDescriptor> columns) {
         this.schemaVersion = schemaVersion;
         this.tableId = tableId;
+        this.tableName = tableName;
         this.columns = List.copyOf(columns);
     }
 
@@ -68,6 +70,13 @@ public class FullTableSchema {
         return tableId;
     }
 
+    /**
+     * Returns name of the table.
+     */
+    public String tableName() {
+        return tableName;
+    }
+
     /**
      * Returns definitions of the columns of the table.
      *
@@ -101,7 +110,15 @@ public class FullTableSchema {
             }
         }
 
-        return new TableDefinitionDiff(addedColumns, removedColumns, 
changedColumns);
+        return new TableDefinitionDiff(
+                prevSchema.schemaVersion(),
+                this.schemaVersion,
+                prevSchema.tableName(),
+                this.tableName(),
+                addedColumns,
+                removedColumns,
+                changedColumns
+        );
     }
 
     private static <T> Map<String, T> toMapByName(List<T> elements, 
Function<T, String> nameExtractor) {
diff --git 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/TableDefinitionDiff.java
 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/TableDefinitionDiff.java
index b4faf5ac64..28e634cc06 100644
--- 
a/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/TableDefinitionDiff.java
+++ 
b/modules/table/src/main/java/org/apache/ignite/internal/table/distributed/schema/TableDefinitionDiff.java
@@ -27,15 +27,17 @@ import 
org.apache.ignite.internal.catalog.descriptors.CatalogTableColumnDescript
  */
 public class TableDefinitionDiff {
     private static final TableDefinitionDiff EMPTY = new TableDefinitionDiff(
-            emptyList(), emptyList(), emptyList()
+            -1, -1, "name", "name", emptyList(), emptyList(), emptyList()
     );
 
+    private final int oldSchemaVersion;
+    private final int newSchemaVersion;
+
+    private final boolean nameDiffers;
     private final List<CatalogTableColumnDescriptor> addedColumns;
     private final List<CatalogTableColumnDescriptor> removedColumns;
     private final List<ColumnDefinitionDiff> changedColumns;
 
-    // TODO: IGNITE-19229 - other change types
-
     /**
      * Returns an empty diff (meaning there is no difference).
      *
@@ -49,15 +51,44 @@ public class TableDefinitionDiff {
      * Constructor.
      */
     public TableDefinitionDiff(
+            int oldSchemaVersion,
+            int newSchemaVersion,
+            String oldName,
+            String newName,
             List<CatalogTableColumnDescriptor> addedColumns,
             List<CatalogTableColumnDescriptor> removedColumns,
             List<ColumnDefinitionDiff> changedColumns
     ) {
+        this.oldSchemaVersion = oldSchemaVersion;
+        this.newSchemaVersion = newSchemaVersion;
+
+        nameDiffers = !oldName.equals(newName);
         this.addedColumns = List.copyOf(addedColumns);
         this.removedColumns = List.copyOf(removedColumns);
         this.changedColumns = List.copyOf(changedColumns);
     }
 
+    /**
+     * Returns old schema version.
+     */
+    public int oldSchemaVersion() {
+        return oldSchemaVersion;
+    }
+
+    /**
+     * Returns new schema version.
+     */
+    public int newSchemaVersion() {
+        return newSchemaVersion;
+    }
+
+    /**
+     * Returns whether name of the table has been changed.
+     */
+    public boolean nameDiffers() {
+        return nameDiffers;
+    }
+
     /**
      * Returns columns that were added.
      */
@@ -78,15 +109,4 @@ public class TableDefinitionDiff {
     public List<ColumnDefinitionDiff> changedColumns() {
         return changedColumns;
     }
-
-    /**
-     * Returns whether this diff is empty (so no difference at all).
-     *
-     * @return Whether this diff is empty (so no difference at all).
-     */
-    public boolean isEmpty() {
-        return addedColumns.isEmpty()
-                && removedColumns.isEmpty()
-                && changedColumns.isEmpty();
-    }
 }
diff --git 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
index 261170da84..500c78ee67 100644
--- 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
+++ 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replication/PartitionReplicaListenerTest.java
@@ -1481,7 +1481,7 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
     }
 
     private static FullTableSchema tableSchema(int schemaVersion, 
List<CatalogTableColumnDescriptor> columns) {
-        return new FullTableSchema(schemaVersion, 1, columns);
+        return new FullTableSchema(schemaVersion, TABLE_ID, "test", columns);
     }
 
     private AtomicReference<Boolean> interceptFinishTxCommand() {
@@ -1518,11 +1518,11 @@ public class PartitionReplicaListenerTest extends 
IgniteAbstractTest {
     }
 
     @Test
-    @Disabled("IGNITE-19229")
     public void commitsOnCompatibleSchemaChangeSuccessfully() {
         when(validationSchemasSource.tableSchemaVersionsBetween(anyInt(), 
any(), any(HybridTimestamp.class)))
                 .thenReturn(List.of(
                         tableSchema(CURRENT_SCHEMA_VERSION, 
List.of(nullableColumn("col1"))),
+                        // Addition of a nullable column is forward-compatible.
                         tableSchema(FUTURE_SCHEMA_VERSION, 
List.of(nullableColumn("col1"), nullableColumn("col2")))
                 ));
 
diff --git 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatibilityValidatorTest.java
 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatibilityValidatorTest.java
new file mode 100644
index 0000000000..f3072bedc5
--- /dev/null
+++ 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/replicator/SchemaCompatibilityValidatorTest.java
@@ -0,0 +1,594 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.table.distributed.replicator;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+import static 
org.apache.ignite.internal.catalog.commands.CatalogUtils.DEFAULT_LENGTH;
+import static 
org.apache.ignite.internal.catalog.commands.CatalogUtils.DEFAULT_PRECISION;
+import static 
org.apache.ignite.internal.catalog.commands.CatalogUtils.DEFAULT_SCALE;
+import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
+import static org.apache.ignite.sql.ColumnType.BITMASK;
+import static org.apache.ignite.sql.ColumnType.BOOLEAN;
+import static org.apache.ignite.sql.ColumnType.BYTE_ARRAY;
+import static org.apache.ignite.sql.ColumnType.DECIMAL;
+import static org.apache.ignite.sql.ColumnType.DOUBLE;
+import static org.apache.ignite.sql.ColumnType.DURATION;
+import static org.apache.ignite.sql.ColumnType.FLOAT;
+import static org.apache.ignite.sql.ColumnType.INT16;
+import static org.apache.ignite.sql.ColumnType.INT32;
+import static org.apache.ignite.sql.ColumnType.INT64;
+import static org.apache.ignite.sql.ColumnType.INT8;
+import static org.apache.ignite.sql.ColumnType.NUMBER;
+import static org.apache.ignite.sql.ColumnType.PERIOD;
+import static org.apache.ignite.sql.ColumnType.STRING;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.ignite.internal.catalog.CatalogService;
+import org.apache.ignite.internal.catalog.commands.DefaultValue;
+import 
org.apache.ignite.internal.catalog.descriptors.CatalogTableColumnDescriptor;
+import org.apache.ignite.internal.catalog.descriptors.CatalogTableDescriptor;
+import org.apache.ignite.internal.hlc.HybridTimestamp;
+import org.apache.ignite.internal.lang.IgniteBiTuple;
+import org.apache.ignite.internal.replicator.TablePartitionId;
+import 
org.apache.ignite.internal.table.distributed.schema.AlwaysSyncedSchemaSyncService;
+import org.apache.ignite.internal.table.distributed.schema.FullTableSchema;
+import 
org.apache.ignite.internal.table.distributed.schema.ValidationSchemasSource;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.apache.ignite.internal.tx.TransactionIds;
+import org.apache.ignite.sql.ColumnType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class SchemaCompatibilityValidatorTest extends BaseIgniteAbstractTest {
+    @Mock
+    private ValidationSchemasSource schemasSource;
+
+    @Mock
+    private CatalogService catalogService;
+
+    private SchemaCompatibilityValidator validator;
+
+    private final HybridTimestamp beginTimestamp = new HybridTimestamp(1, 1);
+
+    private final HybridTimestamp commitTimestamp = new HybridTimestamp(2, 2);
+
+    private final UUID txId = TransactionIds.transactionId(beginTimestamp, 0);
+
+    private static final int TABLE_ID = 1;
+    private static final String TABLE_NAME = "test";
+    private static final String ANOTHER_NAME = "another";
+
+    private final TablePartitionId tablePartitionId = new 
TablePartitionId(TABLE_ID, 0);
+
+    @BeforeEach
+    void createValidatorAndInitMocks() {
+        lenient().when(catalogService.table(TABLE_ID, 
commitTimestamp.longValue())).thenReturn(mock(CatalogTableDescriptor.class));
+
+        validator = new SchemaCompatibilityValidator(schemasSource, 
catalogService, new AlwaysSyncedSchemaSyncService());
+    }
+
+    @ParameterizedTest
+    @EnumSource(ForwardCompatibleChange.class)
+    void forwardCompatibleChangesAllowCommitting(ForwardCompatibleChange 
change) {
+        assertForwardCompatibleChangeAllowsCommitting(change);
+    }
+
+    private void 
assertForwardCompatibleChangeAllowsCommitting(SchemaChangeSource changeSource) {
+        when(schemasSource.tableSchemaVersionsBetween(TABLE_ID, 
beginTimestamp, commitTimestamp))
+                .thenReturn(changeSource.schemaVersions());
+
+        CompletableFuture<CompatValidationResult> resultFuture = 
validator.validateCommit(txId, List.of(tablePartitionId), commitTimestamp);
+
+        assertThat(resultFuture, willCompleteSuccessfully());
+
+        CompatValidationResult result = resultFuture.getNow(null);
+        assertThat(result, is(notNullValue()));
+
+        assertThat("Change is incompatible", result.isSuccessful(), is(true));
+    }
+
+    @ParameterizedTest
+    @EnumSource(ForwardIncompatibleChange.class)
+    void 
forwardIncompatibleChangesDisallowCommitting(ForwardIncompatibleChange change) {
+        assertForwardIncompatibleChangeDisallowsCommitting(change);
+    }
+
+    private void 
assertForwardIncompatibleChangeDisallowsCommitting(SchemaChangeSource 
changeSource) {
+        when(schemasSource.tableSchemaVersionsBetween(TABLE_ID, 
beginTimestamp, commitTimestamp))
+                .thenReturn(changeSource.schemaVersions());
+
+        CompletableFuture<CompatValidationResult> resultFuture = 
validator.validateCommit(txId, List.of(tablePartitionId), commitTimestamp);
+
+        assertThat(resultFuture, willCompleteSuccessfully());
+
+        CompatValidationResult result = resultFuture.getNow(null);
+        assertThat(result, is(notNullValue()));
+
+        assertThat("Change is compatible", result.isSuccessful(), is(false));
+        assertThat(result.isTableDropped(), is(false));
+
+        assertThat(result.failedTableId(), is(TABLE_ID));
+        assertThat(result.fromSchemaVersion(), is(1));
+        assertThat(result.toSchemaVersion(), is(2));
+    }
+
+    @ParameterizedTest
+    @MethodSource("exactColumnTypeChanges")
+    void exactColumnTypeChangesAllowCommitting(ColumnTypeChange change) {
+        assertForwardCompatibleChangeAllowsCommitting(change);
+    }
+
+    private static Stream<Arguments> exactColumnTypeChanges() {
+        List<ColumnTypeChange> changes = new ArrayList<>();
+
+        for (IgniteBiTuple<ColumnType, ColumnType> pair : 
simpleTypeCompatibleChanges()) {
+            //noinspection ConstantConditions
+            changes.add(new ColumnTypeChange(pair.get1(), pair.get2()));
+        }
+
+        // Increasing precision.
+        changes.add(new ColumnTypeChange(decimal(10, 5), decimal(11, 5)));
+
+        // Increasing length.
+        changes.add(new ColumnTypeChange(string(10), string(20)));
+        changes.add(new ColumnTypeChange(byteArray(10), byteArray(20)));
+
+        return changes.stream().map(Arguments::of);
+    }
+
+    private static List<IgniteBiTuple<ColumnType, ColumnType>> 
simpleTypeCompatibleChanges() {
+        List<IgniteBiTuple<ColumnType, ColumnType>> changes = new 
ArrayList<>();
+
+        List<ColumnType> intTypes = List.of(INT8, INT16, INT32, INT64);
+
+        // INT8->INT16->INT32->INT64.
+        for (int i = 0; i < intTypes.size() - 1; i++) {
+            ColumnType narrowerType = intTypes.get(i);
+
+            for (int j = i + 1; j < intTypes.size(); j++) {
+                ColumnType widerType = intTypes.get(j);
+                changes.add(new IgniteBiTuple<>(narrowerType, widerType));
+            }
+        }
+
+        changes.add(new IgniteBiTuple<>(FLOAT, DOUBLE));
+
+        return List.copyOf(changes);
+    }
+
+    private static Type decimal(int precision, int scale) {
+        return new Type(DECIMAL, precision, scale, DEFAULT_LENGTH);
+    }
+
+    private static Type number(int precision) {
+        return new Type(NUMBER, precision, DEFAULT_SCALE, DEFAULT_LENGTH);
+    }
+
+    private static Type string(int length) {
+        return new Type(STRING, DEFAULT_PRECISION, DEFAULT_SCALE, length);
+    }
+
+    private static Type byteArray(int length) {
+        return new Type(BYTE_ARRAY, DEFAULT_PRECISION, DEFAULT_SCALE, length);
+    }
+
+    @ParameterizedTest
+    @MethodSource("nonExactColumnTypeChanges")
+    void nonExactColumnTypeChangesDisallowCommitting(ColumnTypeChange change) {
+        assertForwardIncompatibleChangeDisallowsCommitting(change);
+    }
+
+    private static Stream<Arguments> nonExactColumnTypeChanges() {
+        List<ColumnTypeChange> changes = new ArrayList<>();
+
+        List<ColumnType> simpleTypes = Arrays.stream(ColumnType.values())
+                .filter(type -> !type.precisionAllowed() && 
!type.scaleAllowed() && !type.lengthAllowed())
+                .collect(toList());
+
+        List<IgniteBiTuple<ColumnType, ColumnType>> 
simpleTypeCompatibleChanges = simpleTypeCompatibleChanges();
+        Map<ColumnType, Set<ColumnType>> typeToTypes = 
simpleTypeCompatibleChanges.stream()
+                .collect(groupingBy(IgniteBiTuple::get1, 
Collectors.mapping(IgniteBiTuple::get2, toSet())));
+
+        for (ColumnType type1 : simpleTypes) {
+            for (ColumnType type2 : simpleTypes) {
+                if (type1 == type2) {
+                    continue;
+                }
+                Set<ColumnType> types = typeToTypes.get(type1);
+                if (types == null || !types.contains(type2)) {
+                    changes.add(new ColumnTypeChange(type1, type2));
+                }
+            }
+        }
+
+        changes.add(new ColumnTypeChange(INT8, number(100)));
+        changes.add(new ColumnTypeChange(INT16, number(100)));
+        changes.add(new ColumnTypeChange(INT32, number(100)));
+        changes.add(new ColumnTypeChange(INT64, number(100)));
+        changes.add(new ColumnTypeChange(INT8, decimal(100, 0)));
+        changes.add(new ColumnTypeChange(INT16, decimal(100, 0)));
+        changes.add(new ColumnTypeChange(INT32, decimal(100, 0)));
+        changes.add(new ColumnTypeChange(INT64, decimal(100, 0)));
+
+        changes.add(new ColumnTypeChange(number(10), decimal(100, 0)));
+
+        // Decreasing precision.
+        changes.add(new ColumnTypeChange(decimal(10, 5), decimal(9, 5)));
+        changes.add(new ColumnTypeChange(number(10), number(9)));
+
+        // Decreasing length.
+        changes.add(new ColumnTypeChange(string(10), string(9)));
+        changes.add(new ColumnTypeChange(byteArray(10), byteArray(9)));
+
+        // Conversions to STRING.
+        changes.add(new ColumnTypeChange(INT8, string(100)));
+        changes.add(new ColumnTypeChange(INT16, string(100)));
+        changes.add(new ColumnTypeChange(INT32, string(100)));
+        changes.add(new ColumnTypeChange(INT64, string(100)));
+        changes.add(new ColumnTypeChange(decimal(10, 0), string(100)));
+        changes.add(new ColumnTypeChange(number(10), string(100)));
+        changes.add(new ColumnTypeChange(ColumnType.UUID, string(100)));
+
+        // Conversions from STRING.
+        changes.add(new ColumnTypeChange(string(1), INT8));
+        changes.add(new ColumnTypeChange(string(1), INT16));
+        changes.add(new ColumnTypeChange(string(1), INT32));
+        changes.add(new ColumnTypeChange(string(1), INT64));
+        changes.add(new ColumnTypeChange(string(1), decimal(10, 0)));
+        changes.add(new ColumnTypeChange(string(1), number(10)));
+        changes.add(new ColumnTypeChange(string(36), ColumnType.UUID));
+
+        for (ColumnType columnType : ColumnType.values()) {
+            addInconvertible(columnType, BOOLEAN, changes);
+            addInconvertible(columnType, ColumnType.UUID, changes);
+            addInconvertible(columnType, BITMASK, changes);
+            addInconvertible(columnType, BYTE_ARRAY, changes);
+            addInconvertible(columnType, PERIOD, changes);
+            addInconvertible(columnType, DURATION, changes);
+        }
+
+        return changes.stream().map(Arguments::of);
+    }
+
+    private static void addInconvertible(ColumnType type1, ColumnType type2, 
List<ColumnTypeChange> changes) {
+        if (type1 != type2) {
+            changes.add(new ColumnTypeChange(type2, type1));
+            changes.add(new ColumnTypeChange(type1, type2));
+        }
+    }
+
+    @Test
+    void combinationOfForwardCompatibleChangesIsCompatible() {
+        assertForwardCompatibleChangeAllowsCommitting(() -> List.of(
+                tableSchema(1, List.of(column(INT32, false))),
+                // Type is widened, NOT NULL dropped.
+                tableSchema(2, List.of(column(INT64, true)))
+        ));
+    }
+
+    private static CatalogTableColumnDescriptor column(ColumnType type, 
boolean nullable) {
+        return new CatalogTableColumnDescriptor(
+                "col",
+                type,
+                nullable,
+                DEFAULT_PRECISION,
+                DEFAULT_SCALE,
+                DEFAULT_LENGTH,
+                null
+        );
+    }
+
+    @Test
+    void oneForwardIncompatibleChangeMakesCombinationIncompatible() {
+        assertForwardIncompatibleChangeDisallowsCommitting(() -> List.of(
+                tableSchema(1, List.of(column(INT32, true))),
+                // Type is widened (compatible), but NOT NULL added 
(incompatible).
+                tableSchema(2, List.of(column(INT64, false)))
+        ));
+    }
+
+    @Test
+    void forwardCompatibleChangeIsNotBackwardCompatible() {
+        
assertChangeIsNotBackwardCompatible(ForwardCompatibleChange.ADD_NULLABLE_COLUMN);
+    }
+
+    @Test
+    void forwardIncompatibleChangeIsNotBackwardCompatible() {
+        
assertChangeIsNotBackwardCompatible(ForwardIncompatibleChange.DROP_COLUMN);
+    }
+
+    @Test
+    void changeOppositeToForwardCompatibleChangeIsNotBackwardCompatible() {
+        assertChangeIsNotBackwardCompatible(() -> List.of(
+                tableSchema(1, List.of(
+                        intColumn("col1"),
+                        intColumnWithDefault("col2", 42)
+                )),
+                tableSchema(2, List.of(
+                        intColumn("col1")
+                ))
+        ));
+    }
+
+    private void assertChangeIsNotBackwardCompatible(SchemaChangeSource 
changeSource) {
+        when(schemasSource.tableSchemaVersionsBetween(TABLE_ID, 
beginTimestamp, 2))
+                .thenReturn(changeSource.schemaVersions());
+        when(schemasSource.waitForSchemaAvailability(anyInt(), 
anyInt())).thenReturn(completedFuture(null));
+
+        CompletableFuture<CompatValidationResult> resultFuture = 
validator.validateBackwards(2, TABLE_ID, txId);
+
+        assertThat(resultFuture, willCompleteSuccessfully());
+
+        CompatValidationResult result = resultFuture.getNow(null);
+        assertThat(result, is(notNullValue()));
+
+        assertThat("Change is compatible", result.isSuccessful(), is(false));
+        assertThat(result.isTableDropped(), is(false));
+
+        assertThat(result.failedTableId(), is(TABLE_ID));
+        assertThat(result.fromSchemaVersion(), is(1));
+        assertThat(result.toSchemaVersion(), is(2));
+    }
+
+    private static CatalogTableColumnDescriptor intColumn(String columnName) {
+        return new CatalogTableColumnDescriptor(
+                columnName,
+                INT32,
+                false,
+                DEFAULT_PRECISION,
+                DEFAULT_SCALE,
+                DEFAULT_LENGTH,
+                null
+        );
+    }
+
+    private static CatalogTableColumnDescriptor nullableIntColumn(String 
columnName) {
+        return new CatalogTableColumnDescriptor(columnName, INT32, true, 
DEFAULT_PRECISION, DEFAULT_SCALE, DEFAULT_LENGTH, null);
+    }
+
+    private static CatalogTableColumnDescriptor intColumnWithDefault(String 
columnName, int defaultValue) {
+        return new CatalogTableColumnDescriptor(
+                columnName,
+                INT32,
+                false,
+                DEFAULT_PRECISION,
+                DEFAULT_SCALE,
+                DEFAULT_LENGTH,
+                DefaultValue.constant(defaultValue)
+        );
+    }
+
+    private static FullTableSchema tableSchema(int schemaVersion, 
List<CatalogTableColumnDescriptor> columns) {
+        return tableSchema(schemaVersion, TABLE_NAME, columns);
+    }
+
+    private static FullTableSchema tableSchema(int schemaVersion, String name, 
List<CatalogTableColumnDescriptor> columns) {
+        return new FullTableSchema(schemaVersion, TABLE_ID, name, columns);
+    }
+
+    @FunctionalInterface
+    private interface SchemaChangeSource {
+        List<FullTableSchema> schemaVersions();
+    }
+
+    private enum ForwardCompatibleChange implements SchemaChangeSource {
+        ADD_NULLABLE_COLUMN(List.of(
+                tableSchema(1, List.of(
+                        intColumn("col1")
+                )),
+                tableSchema(2, List.of(
+                        intColumn("col1"),
+                        nullableIntColumn("col2")
+                ))
+        )),
+        ADD_COLUMN_WITH_DEFAULT(List.of(
+                tableSchema(1, List.of(
+                        intColumn("col1")
+                )),
+                tableSchema(2, List.of(
+                        intColumn("col1"),
+                        intColumnWithDefault("col2", 42)
+                ))
+        )),
+        // TODO: https://issues.apache.org/jira/browse/IGNITE-20948 - 
uncomment this.
+        //RENAME_COLUMN(List.of(
+        //        tableSchema(1, List.of(
+        //                intColumn("col1")
+        //        )),
+        //        tableSchema(2, List.of(
+        //                intColumn("col2")
+        //        ))
+        //)),
+        DROP_NOT_NULL(List.of(
+                tableSchema(1, List.of(
+                        intColumn("col1")
+                )),
+                tableSchema(2, List.of(
+                        nullableIntColumn("col1")
+                ))
+        ));
+
+        private final List<FullTableSchema> schemaVersions;
+
+        ForwardCompatibleChange(List<FullTableSchema> schemaVersions) {
+            this.schemaVersions = schemaVersions;
+        }
+
+        @Override
+        public List<FullTableSchema> schemaVersions() {
+            return schemaVersions;
+        }
+    }
+
+    private enum ForwardIncompatibleChange implements SchemaChangeSource {
+        RENAME_TABLE(List.of(
+                tableSchema(1, TABLE_NAME, List.of(
+                        intColumn("col1")
+                )),
+                tableSchema(2, ANOTHER_NAME, List.of(
+                        intColumn("col1")
+                ))
+        )),
+        DROP_COLUMN(List.of(
+                tableSchema(1, List.of(
+                        intColumn("col1"),
+                        intColumn("col2")
+                )),
+                tableSchema(2, List.of(
+                        intColumn("col1")
+                ))
+        )),
+        ADD_DEFAULT(List.of(
+                tableSchema(1, List.of(
+                        intColumn("col1")
+                )),
+                tableSchema(2, List.of(
+                        intColumnWithDefault("col1", 42)
+                ))
+        )),
+        CHANGE_DEFAULT(List.of(
+                tableSchema(1, List.of(
+                        intColumnWithDefault("col1", 1)
+                )),
+                tableSchema(2, List.of(
+                        intColumnWithDefault("col1", 2)
+                ))
+        )),
+        DROP_DEFAULT(List.of(
+                tableSchema(1, List.of(
+                        intColumnWithDefault("col1", 42)
+                )),
+                tableSchema(2, List.of(
+                        intColumn("col1")
+                ))
+        ));
+
+        private final List<FullTableSchema> schemaVersions;
+
+        ForwardIncompatibleChange(List<FullTableSchema> schemaVersions) {
+            this.schemaVersions = schemaVersions;
+        }
+
+        @Override
+        public List<FullTableSchema> schemaVersions() {
+            return schemaVersions;
+        }
+    }
+
+    private static class Type {
+        private final ColumnType columnType;
+        private final int precision;
+        private final int scale;
+        private final int length;
+
+        private Type(ColumnType columnType) {
+            this(columnType, DEFAULT_PRECISION, DEFAULT_SCALE, DEFAULT_LENGTH);
+        }
+
+        private Type(ColumnType columnType, int precision, int scale, int 
length) {
+            this.columnType = columnType;
+            this.precision = precision;
+            this.scale = scale;
+            this.length = length;
+        }
+
+        @Override
+        public String toString() {
+            if (precision == DEFAULT_PRECISION && scale == DEFAULT_SCALE && 
length == DEFAULT_LENGTH) {
+                return columnType.toString();
+            } else if (precision == DEFAULT_PRECISION && scale == 
DEFAULT_SCALE) {
+                return String.format("%s(%d)", columnType, length);
+            } else {
+                return String.format("%s(%d/%d/%d)", columnType, precision, 
scale, length);
+            }
+        }
+    }
+
+    private static class ColumnTypeChange implements SchemaChangeSource {
+        private final Type typeBefore;
+        private final Type typeAfter;
+
+        private ColumnTypeChange(ColumnType typeBefore, ColumnType typeAfter) {
+            this(new Type(typeBefore), new Type(typeAfter));
+        }
+
+        private ColumnTypeChange(ColumnType typeBefore, Type typeAfter) {
+            this(new Type(typeBefore), typeAfter);
+        }
+
+        private ColumnTypeChange(Type typeBefore, ColumnType typeAfter) {
+            this(typeBefore, new Type(typeAfter));
+        }
+
+        private ColumnTypeChange(Type typeBefore, Type typeAfter) {
+            this.typeBefore = typeBefore;
+            this.typeAfter = typeAfter;
+        }
+
+        @Override
+        public List<FullTableSchema> schemaVersions() {
+            return List.of(
+                    tableSchema(1, List.of(columnFromType(typeBefore))),
+                    tableSchema(2, List.of(columnFromType(typeAfter)))
+            );
+        }
+
+        private static CatalogTableColumnDescriptor columnFromType(Type type) {
+            return new CatalogTableColumnDescriptor(
+                    "col",
+                    type.columnType,
+                    true,
+                    type.precision,
+                    type.scale,
+                    type.length,
+                    null
+            );
+        }
+
+        @Override
+        public String toString() {
+            return String.format("%s -> %s", typeBefore, typeAfter);
+        }
+    }
+}
diff --git 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchemaTest.java
 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchemaTest.java
index a172121ccb..17cab220a6 100644
--- 
a/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchemaTest.java
+++ 
b/modules/table/src/test/java/org/apache/ignite/internal/table/distributed/schema/FullTableSchemaTest.java
@@ -29,17 +29,8 @@ import org.apache.ignite.sql.ColumnType;
 import org.junit.jupiter.api.Test;
 
 class FullTableSchemaTest {
-    @Test
-    void sameSchemasHaveEmptyDiff() {
-        CatalogTableColumnDescriptor column = someColumn("a");
-
-        var schema1 = new FullTableSchema(1, 1, List.of(column));
-        var schema2 = new FullTableSchema(2, 1, List.of(column));
-
-        TableDefinitionDiff diff = schema2.diffFrom(schema1);
-
-        assertThat(diff.isEmpty(), is(true));
-    }
+    private static final String TABLE_NAME1 = "test1";
+    private static final String TABLE_NAME2 = "test2";
 
     private static CatalogTableColumnDescriptor someColumn(String columnName) {
         return new CatalogTableColumnDescriptor(columnName, ColumnType.INT32, 
true, 0, 0, 0, DefaultValue.constant(null));
@@ -51,12 +42,11 @@ class FullTableSchemaTest {
         CatalogTableColumnDescriptor column2 = someColumn("b");
         CatalogTableColumnDescriptor column3 = someColumn("c");
 
-        var schema1 = new FullTableSchema(1, 1, List.of(column1, column2));
-        var schema2 = new FullTableSchema(2, 1, List.of(column2, column3));
+        var schema1 = new FullTableSchema(1, 1, TABLE_NAME1, List.of(column1, 
column2));
+        var schema2 = new FullTableSchema(2, 1, TABLE_NAME1, List.of(column2, 
column3));
 
         TableDefinitionDiff diff = schema2.diffFrom(schema1);
 
-        assertThat(diff.isEmpty(), is(false));
         assertThat(diff.addedColumns(), is(List.of(column3)));
         assertThat(diff.removedColumns(), is(List.of(column1)));
         assertThat(diff.changedColumns(), is(empty()));
@@ -66,16 +56,26 @@ class FullTableSchemaTest {
     void changedColumnsAreReflectedInDiff() {
         CatalogTableColumnDescriptor column1 = someColumn("a");
 
-        var schema1 = new FullTableSchema(1, 1, List.of(column1));
-        var schema2 = new FullTableSchema(2, 1,
+        var schema1 = new FullTableSchema(1, 1, TABLE_NAME1, List.of(column1));
+        var schema2 = new FullTableSchema(2, 1, TABLE_NAME1,
                 List.of(new CatalogTableColumnDescriptor("a", 
ColumnType.STRING, true, 0, 0, 10, DefaultValue.constant(null)))
         );
 
         TableDefinitionDiff diff = schema2.diffFrom(schema1);
 
-        assertThat(diff.isEmpty(), is(false));
-
         List<ColumnDefinitionDiff> changedColumns = diff.changedColumns();
         assertThat(changedColumns, is(hasSize(1)));
     }
+
+    @Test
+    void changedNameIsReflected() {
+        CatalogTableColumnDescriptor column = someColumn("a");
+
+        var schema1 = new FullTableSchema(1, 1, TABLE_NAME1, List.of(column));
+        var schema2 = new FullTableSchema(1, 1, TABLE_NAME2, List.of(column));
+
+        TableDefinitionDiff diff = schema2.diffFrom(schema1);
+
+        assertThat(diff.nameDiffers(), is(true));
+    }
 }
diff --git 
a/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyValidationSchemasSource.java
 
b/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyValidationSchemasSource.java
index 4d171a88fc..2f38eff1f7 100644
--- 
a/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyValidationSchemasSource.java
+++ 
b/modules/table/src/testFixtures/java/org/apache/ignite/internal/table/impl/DummyValidationSchemasSource.java
@@ -67,11 +67,7 @@ public class DummyValidationSchemasSource implements 
ValidationSchemasSource {
                 })
                 .collect(toList());
 
-        var fullSchema = new FullTableSchema(
-                1,
-                1,
-                columns
-        );
+        var fullSchema = new FullTableSchema(1, tableId, "test", columns);
 
         return List.of(fullSchema);
     }

Reply via email to