IGNITE-26706 Migration Tools: Allow coping with unnecessary third-party classes from SqlFunctionClasses (#6768)

diff --git a/migration-tools/modules/migration-tools-commons-tests/build.gradle b/migration-tools/modules/migration-tools-commons-tests/build.gradle
index 214e1f2..1f2ee83 100644
--- a/migration-tools/modules/migration-tools-commons-tests/build.gradle
+++ b/migration-tools/modules/migration-tools-commons-tests/build.gradle
@@ -23,6 +23,7 @@
 
 configurations {
     unpackDependencies { transitive = false}
+    fullSampleClusterDependencies
 }
 
 dependencies {
@@ -37,6 +38,8 @@
     implementation files(layout.buildDirectory.dir("generated/unpackClassesFromDependencies"))
 
     unpackDependencies libs.ignite2.core
+
+    fullSampleClusterDependencies project(':migration-tools-e2e-implementations-custom-classes')
 }
 
 def unpackTask = tasks.register('unpackClassesFromDependencies', Copy) {
@@ -53,10 +56,30 @@
             "org/apache/ignite/cache/query/annotations/QuerySqlField\$Group.class"
 }
 
+def createFullSampleClusterResources = tasks.register('createFullSampleClusterResources') {
+    description = 'Generates the "fullsamplecluster" resource file.'
+
+    def outputFile = file("$buildDir/resources/main/fullsamplecluster")
+    outputs.file(outputFile)
+
+    doLast {
+        def depList = configurations.fullSampleClusterDependencies.resolvedConfiguration.resolvedArtifacts.collect { artifact ->
+            artifact.file.path
+        }
+
+        outputFile.parentFile.mkdirs()
+        outputFile.text = depList.join('\n')
+    }
+}
+
 compileJava {
     dependsOn unpackTask
 }
 
+processResources {
+    dependsOn createFullSampleClusterResources
+}
+
 /*sourceSets {
     main {
         java {
diff --git a/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/clusters/FullSampleCluster.java b/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/clusters/FullSampleCluster.java
index daad32a..3543092 100644
--- a/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/clusters/FullSampleCluster.java
+++ b/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/clusters/FullSampleCluster.java
@@ -17,10 +17,19 @@
 
 package org.apache.ignite.migrationtools.tests.clusters;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.stream.Collectors;
 import org.apache.ignite.migrationtools.tests.containers.Ignite2ClusterContainer;
 import org.apache.ignite.migrationtools.tests.containers.Ignite2ClusterWithSamples;
+import org.jetbrains.annotations.Nullable;
+import org.testcontainers.utility.MountableFile;
 
 /** Cluster with all the samples from all the caches. */
 public class FullSampleCluster extends Ignite2ClusterWithSamples {
@@ -42,10 +51,32 @@
 
     @Override
     protected Ignite2ClusterContainer createClusterContainers() {
-        return new Ignite2ClusterContainer(
+        var cluster = new Ignite2ClusterContainer(
                 CLUSTER_CFG_PATH,
                 TEST_CLUSTER_PATH,
                 clusterNodeIds
         );
+
+        List<String> dependencies;
+        @Nullable InputStream rs = FullSampleCluster.class.getResourceAsStream("/fullsamplecluster");
+        if (rs == null) {
+            throw new IllegalStateException("Could not find required resource for loading dependencies.");
+        }
+
+        try (
+                rs;
+                InputStreamReader irs = new InputStreamReader(rs, UTF_8);
+                BufferedReader r = new BufferedReader(irs)
+        ) {
+            dependencies = r.lines().map(String::trim).collect(Collectors.toList());
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        for (var path : dependencies) {
+            cluster.withFileInClasspath(MountableFile.forHostPath(path));
+        }
+
+        return cluster;
     }
 }
diff --git a/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterContainer.java b/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterContainer.java
index 1597a2f..d46f1ed 100644
--- a/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterContainer.java
+++ b/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterContainer.java
@@ -40,6 +40,7 @@
 import org.testcontainers.containers.wait.strategy.Wait;
 import org.testcontainers.lifecycle.Startable;
 import org.testcontainers.lifecycle.Startables;
+import org.testcontainers.utility.DockerImageName;
 import org.testcontainers.utility.MountableFile;
 
 /** Container of an Ignite 2 cluster. */
@@ -53,6 +54,8 @@
 
     private final boolean storagePathMappedToExternal;
 
+    private final String igniteHome;
+
     /**
      * Port on host which binds container's 10800.
      */
@@ -80,11 +83,18 @@
         this.containers = new ArrayList<>(nodeIds.size());
         this.storagePathMappedToExternal = storagePathOnHost != null;
 
+        String dockerImageName = System.getProperty("ignite2.docker.image");
+        assert dockerImageName != null : "ignite2.docker.image must be defined";
+        DockerImageName dockerImage = DockerImageName.parse(dockerImageName);
+
+        this.igniteHome = "/opt/ignite/apache-ignite";
+
         for (int i = 0; i < nodeIds.size(); i++) {
             String hostname = "node" + (1 + i);
             String nodeId = nodeIds.get(i);
 
             var nodeContainer = createIgnite2Container(
+                    dockerImage,
                     hostname,
                     nodeId,
                     cfgFilePath,
@@ -103,6 +113,7 @@
     }
 
     private GenericContainer<?> createIgnite2Container(
+            DockerImageName dockerImage,
             String hostName,
             String nodeId,
             Path cfgFilePath,
@@ -110,10 +121,8 @@
     ) {
         Consumer<OutputFrame> logConsumer = new CheckpointerLogConsumer();
         String heapSize = System.getProperty("ai2.sampleCluster.Xmx", "10g");
-        String ignite2DockerImage = System.getProperty("ignite2.docker.image");
-        assert ignite2DockerImage != null : "ignite2.docker.image must be defined";
 
-        GenericContainer<?> container = new GenericContainer<>(ignite2DockerImage);
+        GenericContainer<?> container = new GenericContainer<>(dockerImage);
 
         if (storagePathMappedToExternal) {
             container.withFileSystemBind(storagePathOnHost.toString(), "/storage", BindMode.READ_WRITE)
@@ -251,6 +260,17 @@
         return dockerHost;
     }
 
+    /**
+     * Copies the supplied file to all the containers classpath.
+     *
+     * @param fileToCopy File to copy.
+     */
+    public void withFileInClasspath(MountableFile fileToCopy) {
+        for (var container : this.containers) {
+            container.withCopyFileToContainer(fileToCopy, this.igniteHome + "/libs/");
+        }
+    }
+
     private static class CheckpointerLogConsumer implements Consumer<OutputFrame> {
 
         private List<Runnable> listeners = new CopyOnWriteArrayList<>();
diff --git a/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterWithSamples.java b/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterWithSamples.java
index 68066c4..519f3dc 100644
--- a/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterWithSamples.java
+++ b/migration-tools/modules/migration-tools-commons-tests/src/main/java/org/apache/ignite/migrationtools/tests/containers/Ignite2ClusterWithSamples.java
@@ -28,11 +28,9 @@
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.extension.BeforeAllCallback;
 import org.junit.jupiter.api.extension.ExtensionContext;
-import org.testcontainers.containers.BindMode;
 import org.testcontainers.containers.GenericContainer;
 import org.testcontainers.containers.Network;
 import org.testcontainers.containers.output.OutputFrame;
-import org.testcontainers.containers.wait.strategy.Wait;
 import org.testcontainers.utility.MountableFile;
 
 /** Ignite2ClusterWithSamples. */
@@ -59,22 +57,6 @@
         return false;
     }
 
-    private static GenericContainer createIgnite2Container(String nodeId, String nodeName, Network network,
-            Consumer<OutputFrame> logConsumer) {
-        return new GenericContainer<>("apacheignite/ignite:2.15.0-jdk11")
-                .withLabel("ai2.sample-cluster.node", nodeName)
-                .withNetwork(network)
-                .withNetworkAliases(nodeName)
-                .withCopyFileToContainer(MountableFile.forHostPath(FullSampleCluster.CLUSTER_CFG_PATH), "/config-file.xml")
-                .withFileSystemBind(FullSampleCluster.TEST_CLUSTER_PATH.toString(), "/storage", BindMode.READ_WRITE)
-                .withEnv("CONFIG_URI", "/config-file.xml")
-                .withEnv("IGNITE_WORK_DIR", "/storage")
-                .withEnv("IGNITE_QUIET", "false")
-                .withEnv("IGNITE_NODE_NAME", nodeId)
-                .withLogConsumer(logConsumer)
-                .waitingFor(Wait.forLogMessage(".*Node started .*", 1));
-    }
-
     protected abstract Ignite2ClusterContainer createClusterContainers();
 
     private void recreateClusterFolder() throws InterruptedException, IOException {
diff --git a/migration-tools/modules/migration-tools-persistence/build.gradle b/migration-tools/modules/migration-tools-persistence/build.gradle
index f8ff15b..2e2da91 100644
--- a/migration-tools/modules/migration-tools-persistence/build.gradle
+++ b/migration-tools/modules/migration-tools-persistence/build.gradle
@@ -32,6 +32,7 @@
     implementation libs.commons.collections4
     implementation libs.jackson.databind
     implementation libs.slf4j.api
+    implementation libs.bytebuddy
     compileOnly libs.spotbugs.annotations
 
     testImplementation project(":migration-tools-commons-tests")
diff --git a/migration-tools/modules/migration-tools-persistence/src/integrationTest/java/org/apache/ignite/migrationtools/persistence/MarshallerTest.java b/migration-tools/modules/migration-tools-persistence/src/integrationTest/java/org/apache/ignite/migrationtools/persistence/MarshallerTest.java
new file mode 100644
index 0000000..ee6d105
--- /dev/null
+++ b/migration-tools/modules/migration-tools-persistence/src/integrationTest/java/org/apache/ignite/migrationtools/persistence/MarshallerTest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.migrationtools.persistence;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.ignite.IgniteCheckedException;
+import org.apache.ignite.internal.processors.cache.CacheType;
+import org.apache.ignite.internal.processors.cache.DynamicCacheDescriptor;
+import org.apache.ignite.migrationtools.tests.clusters.FullSampleCluster;
+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.FieldSource;
+
+@ExtendWith(FullSampleCluster.class)
+class MarshallerTest {
+    private static final List<String> CACHE_NAMES = List.of(
+            "MySimpleMap",
+            "MyPersonPojoCache",
+            "MyOrganizations",
+            "MyIntArrCache",
+            "MyListArrCache",
+            "MyBinaryPersonPojoCache",
+            "MyBinaryOrganizationCache",
+            "MyBinaryTestCache"
+    );
+
+    @ExtendWith(BasePersistentTestContext.class)
+    private List<MigrationKernalContext> nodeContexts;
+
+    private MigrationKernalContext nodeCtx;
+
+    @BeforeEach
+    void beforeEach() throws IgniteCheckedException {
+        nodeCtx = nodeContexts.get(0);
+        nodeCtx.start();
+    }
+
+    @Test
+    void loadAllTest() throws IgniteCheckedException {
+        ((MigrationCacheProcessor) nodeCtx.cache()).loadAllDescriptors();
+
+        Collection<DynamicCacheDescriptor> descriptors = nodeCtx.cache().persistentCaches().stream()
+                .filter(c -> c.cacheType() == CacheType.USER)
+                .collect(Collectors.toList());
+
+        assertThat(descriptors).hasSize(8);
+
+        assertThat(descriptors)
+                .map(DynamicCacheDescriptor::cacheConfiguration)
+                .doesNotContainNull();
+    }
+
+    @ParameterizedTest
+    @FieldSource("CACHE_NAMES")
+    void loadEachTest(String cacheName) {
+        var cacheDescriptor = nodeCtx.cache().cacheDescriptor(cacheName);
+        assertThat(cacheDescriptor).isNotNull();
+        assertThat(cacheDescriptor.cacheConfiguration()).isNotNull();
+    }
+}
diff --git a/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/MigrationKernalContext.java b/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/MigrationKernalContext.java
index 9f40393..23ecb5b 100644
--- a/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/MigrationKernalContext.java
+++ b/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/MigrationKernalContext.java
@@ -63,6 +63,7 @@
 import org.apache.ignite.internal.processors.subscription.GridInternalSubscriptionProcessor;
 import org.apache.ignite.internal.processors.timeout.GridTimeoutProcessor;
 import org.apache.ignite.internal.util.IgniteUtils;
+import org.apache.ignite.migrationtools.persistence.marshallers.ForeignJdkMarshaller;
 import org.apache.ignite.spi.deployment.local.LocalDeploymentSpi;
 import org.apache.ignite.spi.encryption.noop.NoopEncryptionSpi;
 import org.apache.ignite.spi.eventstorage.NoopEventStorageSpi;
@@ -82,6 +83,8 @@
 
     private static final Field MARSH_CTX_FIELD;
 
+    private static final Field JDK_MARSHALLER_FIELD;
+
     static {
         try {
             CFG_FIELD = GridKernalContextImpl.class.getDeclaredField("cfg");
@@ -92,6 +95,9 @@
 
             MARSH_CTX_FIELD = GridKernalContextImpl.class.getDeclaredField("marshCtx");
             MARSH_CTX_FIELD.setAccessible(true);
+
+            JDK_MARSHALLER_FIELD = MarshallerContextImpl.class.getDeclaredField("jdkMarsh");
+            JDK_MARSHALLER_FIELD.setAccessible(true);
         } catch (NoSuchFieldException e) {
             throw new RuntimeException(e);
         }
@@ -127,6 +133,7 @@
 
         try {
             CFG_FIELD.set(this, adaptedConfiguration);
+            JDK_MARSHALLER_FIELD.set(marshCtx, new ForeignJdkMarshaller());
             MARSH_CTX_FIELD.set(this, marshCtx);
 
             // Unnecessarily required by CacheObjectBinaryProcessorImpl & by GridCacheDefaultAffinityKeyMapper#ignite
diff --git a/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/marshallers/ForeignJdkMarshaller.java b/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/marshallers/ForeignJdkMarshaller.java
new file mode 100644
index 0000000..d21d6e8
--- /dev/null
+++ b/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/marshallers/ForeignJdkMarshaller.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.migrationtools.persistence.marshallers;
+
+import java.io.InputStream;
+import org.apache.ignite.IgniteCheckedException;
+import org.apache.ignite.marshaller.jdk.JdkMarshaller;
+import org.jetbrains.annotations.Nullable;
+
+/** {@link JdkMarshaller} implementation which uses {@link ForeignObjectInputStream}. */
+public class ForeignJdkMarshaller extends JdkMarshaller {
+    @Override
+    protected <T> T unmarshal0(InputStream in, @Nullable ClassLoader clsLdr) throws IgniteCheckedException {
+        // Essentially the same impl as JdkMarshaller but with a different underlying ObjectInputStream.
+        assert in != null;
+
+        ClassLoader localClassLoader = (clsLdr != null) ? clsLdr : getClass().getClassLoader();
+
+        try (var objIn = new ForeignObjectInputStream(in, localClassLoader)) {
+            return (T) objIn.readObject();
+        } catch (ClassNotFoundException e) {
+            throw new IgniteCheckedException("Failed to find class with given class loader for unmarshalling "
+                    + "[clsLdr=" + localClassLoader + ", cls=" + e.getMessage() + "]", e);
+        } catch (Exception e) {
+            throw new IgniteCheckedException("Failed to deserialize object with given class loader: " + localClassLoader, e);
+        }
+    }
+}
diff --git a/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/marshallers/ForeignObjectInputStream.java b/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/marshallers/ForeignObjectInputStream.java
new file mode 100644
index 0000000..a15a610
--- /dev/null
+++ b/migration-tools/modules/migration-tools-persistence/src/main/java/org/apache/ignite/migrationtools/persistence/marshallers/ForeignObjectInputStream.java
@@ -0,0 +1,97 @@
+/*
+ * 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.migrationtools.persistence.marshallers;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectStreamClass;
+import java.util.ArrayList;
+import java.util.List;
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.description.modifier.Visibility;
+import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
+import net.bytebuddy.dynamic.scaffold.subclass.ConstructorStrategy.Default;
+import net.bytebuddy.implementation.MethodCall;
+import net.bytebuddy.implementation.SuperMethodCall;
+
+/**
+ * {@link ObjectInputStream} implementation which prevents {@link ClassNotFoundException}s from custom client libraries.
+ */
+public final class ForeignObjectInputStream extends ObjectInputStream {
+    private final List<Class<?>> dummyClasses;
+
+    private final ClassLoader publicClassloader;
+
+    /**
+     * Constructor.
+     *
+     * @param in Base input stream.
+     * @param classLoader classloader.
+     * @throws IOException may be thrown.
+     */
+    public ForeignObjectInputStream(InputStream in, ClassLoader classLoader) throws IOException {
+        super(in);
+        this.enableResolveObject(true);
+        this.dummyClasses = new ArrayList<>();
+        this.publicClassloader = classLoader;
+    }
+
+    @Override
+    protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
+        Class<?> c;
+        try {
+            c = super.resolveClass(desc);
+        } catch (ClassNotFoundException ex) {
+            String className = desc.getName();
+            if (!className.startsWith("org.apache.ignite.")) {
+                throw ex;
+            }
+
+            c = new ByteBuddy()
+                    .subclass(Object.class, Default.NO_CONSTRUCTORS)
+                    .name(className)
+                    .defineConstructor(Visibility.PRIVATE)
+                    .intercept(SuperMethodCall.INSTANCE.andThen(MethodCall.run(() -> {
+                        throw new RuntimeException(String.format("Cannot instantiate dummy object for class '%s'."
+                                + " This class could not be found and a placeholder was generated.", className));
+                    })))
+                    .make()
+                    .load(publicClassloader, ClassLoadingStrategy.Default.WRAPPER)
+                    .getLoaded();
+
+            this.dummyClasses.add(c);
+        }
+
+        return c;
+    }
+
+    @Override
+    protected Object resolveObject(Object obj) {
+        for (Class<?> klass : this.dummyClasses) {
+            if (klass.isInstance(obj)) {
+                // Found instance of a dummy object. This should not happen.
+                throw new IllegalStateException("Found instance of dummy object: " + klass.getName());
+            }
+
+            // We could also replace the dummy object with null.
+        }
+
+        return obj;
+    }
+}
diff --git a/migration-tools/resources/configs-custom/ignite-config.0.xml b/migration-tools/resources/configs-custom/ignite-config.0.xml
index 1a9a390..eae91c6 100644
--- a/migration-tools/resources/configs-custom/ignite-config.0.xml
+++ b/migration-tools/resources/configs-custom/ignite-config.0.xml
@@ -58,6 +58,8 @@
                     <!-- Setting schema to PUBLIC. Otherwise, you need to add the cache name as a schema name in your
                     queries -->
                     <property name="sqlSchema" value="PUBLIC"/>
+                    <!-- Setting a custom SQL function class -->
+                    <property name="SqlFunctionClasses" value="org.apache.ignite.migrationtools.tests.e2e.custom.MySqlFunctions"/>
 
                     <!-- Setting QueryEntities -->
                     <property name="queryEntities">
diff --git a/migration-tools/tools/e2e-tests-framework/ai2-runner/build.gradle b/migration-tools/tools/e2e-tests-framework/ai2-runner/build.gradle
index 16ff19b..c0cb2d4 100644
--- a/migration-tools/tools/e2e-tests-framework/ai2-runner/build.gradle
+++ b/migration-tools/tools/e2e-tests-framework/ai2-runner/build.gradle
@@ -29,6 +29,7 @@
 dependencies {
     implementation project(":migration-tools-e2e-core")
     implementation project(":migration-tools-e2e-implementations")
+    implementation project(":migration-tools-e2e-implementations-custom-classes")
 
     implementation runtimeApacheIgnite2.ignite2.core
 
diff --git a/migration-tools/tools/e2e-tests-framework/custom-classes/build.gradle b/migration-tools/tools/e2e-tests-framework/custom-classes/build.gradle
new file mode 100644
index 0000000..3496841
--- /dev/null
+++ b/migration-tools/tools/e2e-tests-framework/custom-classes/build.gradle
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+apply from: "$rootDir/buildscripts/java-core.gradle"
+apply from: "$rootDir/buildscripts/publishing.gradle"
+
+description = 'migration-tools-e2e-implementations-custom-classes'
+
+dependencies {
+    compileOnly libs.ignite2.core
+}
diff --git a/migration-tools/tools/e2e-tests-framework/custom-classes/src/main/java/org/apache/ignite/migrationtools/tests/e2e/custom/MySqlFunctions.java b/migration-tools/tools/e2e-tests-framework/custom-classes/src/main/java/org/apache/ignite/migrationtools/tests/e2e/custom/MySqlFunctions.java
new file mode 100644
index 0000000..cfda659
--- /dev/null
+++ b/migration-tools/tools/e2e-tests-framework/custom-classes/src/main/java/org/apache/ignite/migrationtools/tests/e2e/custom/MySqlFunctions.java
@@ -0,0 +1,28 @@
+/*
+ * 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.migrationtools.tests.e2e.custom;
+
+import org.apache.ignite.cache.query.annotations.QuerySqlFunction;
+
+/** Custom Sql function. */
+public class MySqlFunctions {
+    @QuerySqlFunction
+    public static int sqr(int x) {
+        return x * x;
+    }
+}
diff --git a/migration-tools/tools/e2e-tests-framework/implementations/build.gradle b/migration-tools/tools/e2e-tests-framework/implementations/build.gradle
index c4eddc9..bdf873e 100644
--- a/migration-tools/tools/e2e-tests-framework/implementations/build.gradle
+++ b/migration-tools/tools/e2e-tests-framework/implementations/build.gradle
@@ -25,6 +25,7 @@
     annotationProcessor libs.auto.service
 
     compileOnly project(":ignite-api")
+    compileOnly project(":migration-tools-e2e-implementations-custom-classes")
     compileOnly libs.ignite2.core
     compileOnly libs.ignite2.spring
     compileOnly libs.spotbugs.annotations
diff --git a/migration-tools/tools/e2e-tests-framework/implementations/src/main/java/org/apache/ignite/migrationtools/tests/e2e/impl/MySimpleMapCacheTest.java b/migration-tools/tools/e2e-tests-framework/implementations/src/main/java/org/apache/ignite/migrationtools/tests/e2e/impl/MySimpleMapCacheTest.java
index 8840f30..1d49f2b 100644
--- a/migration-tools/tools/e2e-tests-framework/implementations/src/main/java/org/apache/ignite/migrationtools/tests/e2e/impl/MySimpleMapCacheTest.java
+++ b/migration-tools/tools/e2e-tests-framework/implementations/src/main/java/org/apache/ignite/migrationtools/tests/e2e/impl/MySimpleMapCacheTest.java
@@ -24,6 +24,7 @@
 import java.sql.SQLException;
 import java.util.Map;
 import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.migrationtools.tests.e2e.custom.MySqlFunctions;
 import org.apache.ignite.migrationtools.tests.e2e.framework.core.ExampleBasedCacheTest;
 import org.jetbrains.annotations.Nullable;
 
@@ -40,6 +41,7 @@
     public CacheConfiguration<String, Integer> cacheConfiguration() {
         CacheConfiguration<String, Integer> cfg = super.cacheConfiguration();
         cfg.setSqlSchema(SCHEMA_NAME_CASED);
+        cfg.setSqlFunctionClasses(MySqlFunctions.class);
         return cfg;
     }
 
diff --git a/settings.gradle b/settings.gradle
index 9726370..21f1459 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -191,6 +191,7 @@
 // End 2 End test stuff
 include(":migration-tools-e2e-core")
 include(":migration-tools-e2e-implementations")
+include(":migration-tools-e2e-implementations-custom-classes")
 include(":migration-tools-e2e-ai2-runner")
 include(":migration-tools-e2e-ai3-tests")
 
@@ -206,6 +207,7 @@
 project(":migration-tools-packaging-cli").projectDir = file('migration-tools/packaging/cli')
 project(":migration-tools-e2e-core").projectDir = file('migration-tools/tools/e2e-tests-framework/framework-core')
 project(":migration-tools-e2e-implementations").projectDir = file('migration-tools/tools/e2e-tests-framework/implementations')
+project(":migration-tools-e2e-implementations-custom-classes").projectDir = file('migration-tools/tools/e2e-tests-framework/custom-classes')
 project(":migration-tools-e2e-ai2-runner").projectDir = file('migration-tools/tools/e2e-tests-framework/ai2-runner')
 project(":migration-tools-e2e-ai3-tests").projectDir = file('migration-tools/modules/e2e-ai3-tests')