You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@ignite.apache.org by "oleg-vlsk (via GitHub)" <gi...@apache.org> on 2026/09/13 23:56:51 UTC

[PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

oleg-vlsk opened a new pull request, #13583:
URL: https://github.com/apache/ignite/pull/13583

   Thank you for submitting the pull request to the Apache Ignite.
   
   In order to streamline the review of the contribution 
   we ask you to ensure the following steps have been taken:
   
   ### The Contribution Checklist
   - [ ] There is a single JIRA ticket related to the pull request. 
   - [ ] The web-link to the pull request is attached to the JIRA ticket.
   - [ ] The JIRA ticket has the _Patch Available_ state.
   - [ ] The pull request body describes changes that have been made. 
   The description explains _WHAT_ and _WHY_ was made instead of _HOW_.
   - [ ] The pull request title is treated as the final commit message. 
   The following pattern must be used: `IGNITE-XXXX Change summary` where `XXXX` - number of JIRA issue.
   - [ ] A reviewer has been mentioned through the JIRA comments 
   (see [the Maintainers list](https://cwiki.apache.org/confluence/display/IGNITE/How+to+Contribute#HowtoContribute-ReviewProcessandMaintainers)) 
   - [ ] The pull request has been checked by the Teamcity Bot and 
   the `green visa` attached to the JIRA ticket (see tab `PR Check` at [TC.Bot - Instance 1](https://tcbot2.sbt-ignite-dev.ru/prs.html) or [TC.Bot - Instance 2](https://mtcga.gridgain.com/prs.html))
   
   ### Notes
   - [How to Contribute](https://cwiki.apache.org/confluence/display/IGNITE/How+to+Contribute)
   - [Coding abbreviation rules](https://cwiki.apache.org/confluence/display/IGNITE/Abbreviation+Rules)
   - [Coding Guidelines](https://cwiki.apache.org/confluence/display/IGNITE/Coding+Guidelines)
   - [Apache Ignite Teamcity Bot](https://cwiki.apache.org/confluence/display/IGNITE/Apache+Ignite+Teamcity+Bot)
   
   If you need any help, please email dev@ignite.apache.org or ask anу advice on http://asf.slack.com _#ignite_ channel.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-5658739411

   **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9344108](https://ci2.ignite.apache.org/build/9344108) · 147 suites ran, 0 reused
   
   ⚠️ **This run doesn't cover the PR fully:**
   - 2 suite(s) have no reliable result (compilation error, timeout, crash)
   - 1 suite(s) ran far fewer tests than the same suites on master
   - a newer run is still going — its unfinished suites can still fail
   
   Everything below is what it did manage to say.
   
   ⚠️ **2 broken suite(s)** (no reliable run):
   - Platform C++ CMake (Linux Clang): IgniteOdbcTest terminated abnormally - the test binary died, its remaining tests never ran · Number of tests 595 is 43% less than 1047 in build #43233 — ran 595 of master's 1047 tests
   - Platform C++ CMake (Win x64 | Release): non-zero exit code · Number of tests 465 is 56% less than 1047 in build #19133 — ran 465 of master's 1047 tests
   
   🔍 **1 suite(s) ran fewer tests than on master** (tests that never ran can't fail):
   - Platform C++ CMake (Linux): 595 tests vs 1047 on master (**−43%**)
   
   ❌ **8 blocker(s) in 5 suite(s):**
   - ZooKeeper (Discovery) 1: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite1: org.apache.ignite.spi.discovery.zk.internal.ZookeeperDiscoverySplitBrainTest.testSimpleSplitBrain`
   - ZooKeeper (Discovery) 1: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite1: org.apache.ignite.spi.discovery.zk.internal.ZookeeperDiscoverySplitBrainTest.testAlmostSplitBrain`
   - ZooKeeper (Discovery) 1: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite1: org.apache.ignite.spi.discovery.zk.internal.ZookeeperDiscoverySplitBrainTest.testNotActualSplitBrain`
   - Platform .NET (Core Linux): `Apache.Ignite.Core.Tests.DotNetCore: Apache.Ignite.Core.Tests.Client.Services.ServicesAwarenessTests.TestClusterTopologyChanges("PlatformTestService")`
   - Index Query API: `org.apache.ignite.cache.query.IndexQueryTestSuite: org.apache.ignite.cache.query.IndexQueryRangeTest.testRangeQueries[qryPar=1 atomicity=TRANSACTIONAL mode=REPLICATED node=CRD backups=0 idxName=PERSON_ID_IDX duplicates=1]`
   - Queries 6: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite6: org.apache.ignite.internal.processors.cache.index.DuplicateKeyValueClassesSelfTest.testDuplicateValueClass`
   - PDS (Indexing): `org.apache.ignite.testsuites.IgnitePdsWithIndexingCoreTestSuite: org.apache.ignite.internal.processors.cache.persistence.db.wal.IgniteWalRebalanceTest.testWithLocalWalChange`
   - PDS (Indexing): `org.apache.ignite.testsuites.IgnitePdsWithIndexingCoreTestSuite: org.apache.ignite.internal.processors.cache.persistence.IgnitePdsCorruptedIndexTest.testCorruption`
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub) via notifications" <no...@ignite.apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4433800925


##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2484,6 +2493,26 @@ public CacheConfiguration<K, V> setIndexPath(String idxPath) {
         return S.toString(CacheConfiguration.class, this);
     }
 
+    /** */
+    void replaceQueryEntities(Collection<QueryEntity> qryEntities) {
+        this.qryEntities = new ArrayList<>(qryEntities);
+    }
+
+    /** */
+    private void checkQueryEntityConfigurationSource(QueryEntityConfigurationSource src) {
+        if (qryEntityCfgSrc != null && qryEntityCfgSrc != src)
+            throw new CacheException(String.format(MIXED_QUERY_ENTITIES_API_ERROR_TEMPLATE, name));

Review Comment:
   Added fallback to `<unnamed>`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-5990350474

   **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9387293](https://ci2.ignite.apache.org/build/9387293) · 147 suites ran, 0 reused
   
   👀 **21 test(s) started failing on this code** — not proven blockers yet: too few runs of this revision to tell a break from a flake, so a re-run of the suite decides it.
   - Control Utility 2: `org.apache.ignite.testsuites.IgniteControlUtilityTestSuite2: org.apache.ignite.util.CacheMetricsCommandTest.testEnableDisable[cmdHnd=jmx]`
   - Queries 6: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite6: org.apache.ignite.internal.processors.cache.index.DynamicIndexClientBasicSelfTest.testCreateIndexWithParallelismReplicatedTransactional`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheMergeSqlQuerySelfTest.testPrimitives`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheMergeSqlQuerySelfTest.testMergeWithExplicitPrimitiveKey`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheMergeSqlQuerySelfTest.testFieldsCaseSensitivity`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheMergeSqlQuerySelfTest.testMergeFromSubquery`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheMergeSqlQuerySelfTest.testMergeWithDynamicKeyInstantiation`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheMergeSqlQuerySelfTest.testMergeWithExplicitKey`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheMergeSqlQueryFailingTest.testSqlMergeIntoQuery`
   - Queries 1: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite: org.apache.ignite.internal.processors.cache.IgniteCacheInsertSqlQuerySelfTest.testPrimitives`
   … and 11 more
   
   ⚠️ **No proven blocker yet** — this is not an all-clear: see the tests above. 14 pre-existing/flaky filtered out.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180711387


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<Integer, Person>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, Person.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(1, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Annotation index and configured composite index must coexist. */
+    @Test
+    public void testAnnotationAndConfiguredIndexesAreCreated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        String annotationIdxName = annotationIndexName();
+
+        assertTrue(hasIndex(indexes, annotationIdxName));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Reverse order must work as well: queryEntities first, indexedTypes second. */
+    @Test
+    public void testReverseConfigurationOrder() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ))
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, annotationIndexName()));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Metadata added by several consecutive setQueryEntities calls must accumulate. */
+    @Test
+    public void testSeveralSetQueryEntitiesCreateAllIndexes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(NAME_IDX, NAME_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(AGE_IDX, AGE_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, NAME_IDX));
+        assertTrue(hasIndex(indexes, AGE_IDX));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(3, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Identical index definitions must be deduplicated. */
+    @Test
+    public void testSameIndexDefinitionIsDeduplicated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String idxName = "DUP_IDX";
+
+        QueryIndex idx = index(idxName, NAME_FIELD);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, idxName));
+
+        assertEquals(1, indexes.stream().filter(i -> !i.isPk()).count());
+    }
+
+    /** Same index name with different definition is a conflict. */
+    @Test
+    public void testSameIndexNameWithDifferentDefinitionFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String ageField = AGE_FIELD;
+        String conflictIdx = "CONFLICT_IDX";
+
+        QueryEntity first = configuredEntity(Person.class, index(conflictIdx, NAME_FIELD));
+        QueryEntity second = configuredEntity(Person.class, index(conflictIdx, ageField));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=index[%s], " +
+                "existingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1], " +
+                "incomingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1]]",
+            CACHE_NAME, conflictIdx, conflictIdx, NAME_FIELD, conflictIdx, ageField);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Indexes without explicitly configured names are merged correctly. */
+    @Test
+    public void testIndexesWithoutExplicitNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryIndex firstIdx = new QueryIndex(Collections.singletonList(NAME_FIELD), QueryIndexType.SORTED);
+        assertNull(firstIdx.getName());
+
+        QueryIndex secondIdx = new QueryIndex(Arrays.asList(NAME_FIELD, AGE_FIELD), QueryIndexType.SORTED);
+        assertNull(secondIdx.getName());
+
+        QueryIndex thirdIdx = index(AGE_IDX, AGE_FIELD);
+        assertNotNull(thirdIdx.getName());
+
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, firstIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, secondIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, thirdIdx)));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+
+        assertEquals(3, entity.getIndexes().size());
+
+        for (QueryIndex idx : List.of(firstIdx, secondIdx, thirdIdx))
+            assertTrue(entity.getIndexes().stream().anyMatch(i -> i.getFields().equals(idx.getFields())));
+    }
+
+    /**
+     * Verifies that adding SQL metadata to a statically configured cache preserves {@link QueryEntityEx} and its
+     * extended metadata.
+     *
+     * <p>A schema-add operation patches both the runtime cache configuration and the cache descriptor configuration.
+     * These configurations must not share a mutable query-entity collection, otherwise the first patch may modify the
+     * descriptor configuration before the schema-finish phase and cause the same query entity to be merged again.</p>
+     */
+    @Test
+    public void testSchemaAddPreservesQueryEntityExMetadata() throws Exception {
+        staticCfg = true;
+
+        IgniteEx node = startGrid(0);
+
+        node.cluster().state(ACTIVE);
+
+        DynamicCacheDescriptor desc = node.context().cache().cacheDescriptor(CACHE_NAME);
+
+        assertTrue(desc.cacheConfiguration().getQueryEntities().isEmpty());
+
+        node.cache(CACHE_NAME).query(new SqlFieldsQuery(
+            "CREATE TABLE TEST_TBL (" +
+                "ID1 INT, " +
+                "ID2 INT, " +
+                "VAL VARCHAR NOT NULL, " +
+                "PRIMARY KEY (ID1, ID2)" +
+                ") WITH \"CACHE_NAME=" + CACHE_NAME + "\""
+        )).getAll();
+
+        Collection<QueryEntity> entities = node.context().cache()
+            .cacheDescriptor(CACHE_NAME)
+            .cacheConfiguration()
+            .getQueryEntities();

Review Comment:
   Code corrected.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4056491774


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/index/DuplicateKeyValueClassesSelfTest.java:
##########
@@ -42,32 +48,51 @@ public class DuplicateKeyValueClassesSelfTest extends AbstractIndexingCommonTest
         grid(0).destroyCache(CACHE_NAME);
     }
 
-    /**
-     * Test duplicate key class.
-     *
-     * @throws Exception If failed.
-     */
+    /** Checks that the same key class can be used with different value classes. */
     @Test
-    public void testDuplicateKeyClass() throws Exception {
+    public void testDuplicateKeyClass() {
         CacheConfiguration ccfg = new CacheConfiguration()
             .setName(CACHE_NAME)
             .setIndexedTypes(UUID.class, Clazz1.class, UUID.class, Clazz2.class);
 
         grid(0).createCache(ccfg);
+
+        Collection<QueryEntity> entities = grid(0).context().cache().cacheConfiguration(CACHE_NAME).getQueryEntities();
+
+        assertEquals(2, entities.size());
+
+        Set<String> valTypes = new HashSet<>();
+
+        for (QueryEntity entity : entities) {
+            assertEquals(UUID.class.getName(), entity.getKeyType());
+
+            valTypes.add(entity.getValueType());
+        }
+
+        Set<String> expValTypes = new HashSet<>(Arrays.asList(Clazz1.class.getName(), Clazz2.class.getName()));
+
+        assertEquals(expValTypes, valTypes);
     }
 
     /**
-     * Test duplicate value class.
-     *
-     * @throws Exception If failed.
+     * Checks that conflicting key types configured for the same value class are rejected instead of silently
+     * discarding one of the query entity configurations.
      */
     @Test
-    public void testDuplicateValueClass() throws Exception {
+    public void testConflictingKeyTypesForSameValueClass() {
         CacheConfiguration ccfg = new CacheConfiguration()
-            .setName(CACHE_NAME)
-            .setIndexedTypes(UUID.class, Clazz1.class, String.class, Clazz1.class);
+            .setName(CACHE_NAME);
 
-        grid(0).createCache(ccfg);
+        String msg = String.format("Failed to merge query entities due to conflicting metadata " +

Review Comment:
   Done.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4044263485


##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);

Review Comment:
   This copy always turns the merged object into a plain `QueryEntity`. However, `setQueryEntities` is also used while applying schema-add patches, where configurations can contain `QueryEntityEx` instances; those carry `fillAbsentPKsWithDefaults`, `preserveKeysOrder`, `implicitPk`, SQL state, and primary/affinity inline sizes. The merged configuration will therefore lose those flags, and the query type setup will stop applying them. Preserve and merge the extended `QueryEntityEx` metadata whenever either input is an extended entity.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180717749


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<Integer, Person>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, Person.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(1, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Annotation index and configured composite index must coexist. */
+    @Test
+    public void testAnnotationAndConfiguredIndexesAreCreated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        String annotationIdxName = annotationIndexName();
+
+        assertTrue(hasIndex(indexes, annotationIdxName));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Reverse order must work as well: queryEntities first, indexedTypes second. */
+    @Test
+    public void testReverseConfigurationOrder() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ))
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, annotationIndexName()));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Metadata added by several consecutive setQueryEntities calls must accumulate. */
+    @Test
+    public void testSeveralSetQueryEntitiesCreateAllIndexes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(NAME_IDX, NAME_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(AGE_IDX, AGE_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, NAME_IDX));
+        assertTrue(hasIndex(indexes, AGE_IDX));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(3, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Identical index definitions must be deduplicated. */
+    @Test
+    public void testSameIndexDefinitionIsDeduplicated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String idxName = "DUP_IDX";
+
+        QueryIndex idx = index(idxName, NAME_FIELD);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, idxName));
+
+        assertEquals(1, indexes.stream().filter(i -> !i.isPk()).count());
+    }
+
+    /** Same index name with different definition is a conflict. */
+    @Test
+    public void testSameIndexNameWithDifferentDefinitionFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String ageField = AGE_FIELD;
+        String conflictIdx = "CONFLICT_IDX";
+
+        QueryEntity first = configuredEntity(Person.class, index(conflictIdx, NAME_FIELD));
+        QueryEntity second = configuredEntity(Person.class, index(conflictIdx, ageField));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=index[%s], " +
+                "existingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1], " +
+                "incomingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1]]",
+            CACHE_NAME, conflictIdx, conflictIdx, NAME_FIELD, conflictIdx, ageField);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Indexes without explicitly configured names are merged correctly. */
+    @Test
+    public void testIndexesWithoutExplicitNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryIndex firstIdx = new QueryIndex(Collections.singletonList(NAME_FIELD), QueryIndexType.SORTED);
+        assertNull(firstIdx.getName());
+
+        QueryIndex secondIdx = new QueryIndex(Arrays.asList(NAME_FIELD, AGE_FIELD), QueryIndexType.SORTED);
+        assertNull(secondIdx.getName());
+
+        QueryIndex thirdIdx = index(AGE_IDX, AGE_FIELD);
+        assertNotNull(thirdIdx.getName());
+
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, firstIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, secondIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, thirdIdx)));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+
+        assertEquals(3, entity.getIndexes().size());
+
+        for (QueryIndex idx : List.of(firstIdx, secondIdx, thirdIdx))
+            assertTrue(entity.getIndexes().stream().anyMatch(i -> i.getFields().equals(idx.getFields())));
+    }
+
+    /**
+     * Verifies that adding SQL metadata to a statically configured cache preserves {@link QueryEntityEx} and its
+     * extended metadata.
+     *
+     * <p>A schema-add operation patches both the runtime cache configuration and the cache descriptor configuration.
+     * These configurations must not share a mutable query-entity collection, otherwise the first patch may modify the
+     * descriptor configuration before the schema-finish phase and cause the same query entity to be merged again.</p>
+     */
+    @Test
+    public void testSchemaAddPreservesQueryEntityExMetadata() throws Exception {
+        staticCfg = true;
+
+        IgniteEx node = startGrid(0);
+
+        node.cluster().state(ACTIVE);
+
+        DynamicCacheDescriptor desc = node.context().cache().cacheDescriptor(CACHE_NAME);
+
+        assertTrue(desc.cacheConfiguration().getQueryEntities().isEmpty());
+
+        node.cache(CACHE_NAME).query(new SqlFieldsQuery(
+            "CREATE TABLE TEST_TBL (" +
+                "ID1 INT, " +
+                "ID2 INT, " +
+                "VAL VARCHAR NOT NULL, " +
+                "PRIMARY KEY (ID1, ID2)" +
+                ") WITH \"CACHE_NAME=" + CACHE_NAME + "\""
+        )).getAll();
+
+        Collection<QueryEntity> entities = node.context().cache()
+            .cacheDescriptor(CACHE_NAME)
+            .cacheConfiguration()
+            .getQueryEntities();
+
+        assertEquals(1, entities.size());
+
+        QueryEntity entity = entities.iterator().next();
+
+        assertTrue(entity instanceof QueryEntityEx);
+
+        QueryEntityEx entityEx = (QueryEntityEx)entity;
+
+        assertTrue(entityEx.sql());
+        assertTrue(entityEx.isPreserveKeysOrder());
+        assertTrue(entityEx.fillAbsentPKsWithDefaults());
+    }
+
+    /** */
+    private static QueryEntity personEntity(LinkedHashMap<String, String> fields, QueryIndex... indexes) {
+        QueryEntity entity = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setTableName(Person.class.getSimpleName())
+            .setFields(fields);
+
+        if (indexes.length != 0)
+            entity.setIndexes(Arrays.asList(indexes));
+
+        return entity;
+    }
+
+    /** */
+    private static QueryEntity configuredEntity(Class<?> valCls, QueryIndex... indexes) {
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+
+        fields.put(NAME_FIELD, String.class.getName());
+        fields.put(AGE_FIELD, Integer.class.getName());
+
+        QueryEntity res = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(valCls.getName())
+            .setTableName(valCls.getSimpleName())
+            .setFields(fields);
+
+        if (indexes.length != 0)
+            res.setIndexes(Arrays.asList(indexes));
+
+        return res;
+    }
+
+    /** */
+    private static QueryIndex index(String name, String... fields) {
+        return new QueryIndex(Arrays.asList(fields), QueryIndexType.SORTED).setName(name);
+    }
+
+    /** */
+    private static List<SqlIndexView> cacheIndexes(IgniteEx node) {
+        SystemView<SqlIndexView> indexes = node.context().systemView().view("indexes");
+        assertNotNull(indexes);
+
+        List<SqlIndexView> res = new ArrayList<>();
+
+        for (SqlIndexView idx : indexes) {
+            if (CACHE_NAME.equals(idx.cacheName()))
+                res.add(idx);
+        }
+
+        return res;
+    }
+
+    /** */
+    private static boolean hasIndex(List<SqlIndexView> indexes, String idxName) {
+        return indexes.stream().anyMatch(idx -> idxName.equalsIgnoreCase(idx.indexName()));
+    }
+
+    /** */
+    private static String annotationIndexName() {
+        QueryEntity entity = new QueryEntity(Integer.class, AnnotatedPerson.class);
+
+        QueryIndex idx = entity.getIndexes().iterator().next();
+
+        return QueryUtils.indexName(entity, idx);
+    }
+
+    /** */
+    private static LinkedHashMap<String, String> fields(Object... vals) {
+        assertTrue(vals.length % 2 == 0);
+
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+
+        for (int i = 0; i < vals.length; i += 2)
+            fields.put((String)vals[i], ((Class<?>)vals[i + 1]).getName());
+
+        return fields;
+    }
+
+    /** */
+    private static SqlTableView cacheTable(IgniteEx node) {
+        SystemView<SqlTableView> tables = node.context().systemView().view(SQL_TBLS_VIEW);
+        assertNotNull(tables);
+
+        SqlTableView res = null;
+
+        for (SqlTableView tbl : tables) {
+            if (CACHE_NAME.equals(tbl.cacheName()))
+                res = tbl;

Review Comment:
   This test has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180739348


##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);

Review Comment:
   This class has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4056496803


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /** Query entities with the same value type but different key type are a conflict. */
+    @Test
+    public void testConflictingKeyTypesFail() {

Review Comment:
   This test covers a different scenario and should remain. `testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged()` checks that we can have entities with the same key type but different value types. This test checks that 2 entities with the same value type are a conflict, even though they have different key types. Before my changes one of such entities was silently discarded.



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {

Review Comment:
   Done.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4172034109


##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2484,6 +2478,29 @@ public CacheConfiguration<K, V> setIndexPath(String idxPath) {
         return S.toString(CacheConfiguration.class, this);
     }
 
+    /** */
+    private QueryEntity findQueryEntity(String valType) {
+        if (qryEntities == null)
+            return null;
+
+        for (QueryEntity entity : qryEntities) {
+            if (Objects.equals(entity.findValueType(), valType))
+                return entity;
+        }
+
+        return null;

Review Comment:
   Method has been rewritten.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub) via notifications" <no...@ignite.apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4433799825


##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2075,42 +2090,36 @@ public CacheConfiguration<K, V> setPartitionLossPolicy(PartitionLossPolicy partL
 
     /**
      * Sets query entities configuration.
+     * <p>
+     * This method must not be used together with {@link #setIndexedTypes}.
+     * <p>
+     * Repeated calls replace the query entities configured by the previous call.
+     * Passing an empty collection clears the existing query entities.
      *
      * @param qryEntities Query entities.
      * @return {@code this} for chaining.
+     * @throws CacheException If query entities have already been configured through {@link #setIndexedTypes}.
      */
     public CacheConfiguration<K, V> setQueryEntities(Collection<QueryEntity> qryEntities) {
-        if (this.qryEntities == null) {
-            this.qryEntities = new ArrayList<>(qryEntities);
-
-            return this;
-        }
+        checkQueryEntityConfigurationSource(QueryEntityConfigurationSource.QUERY_ENTITIES);
 
-        for (QueryEntity entity : qryEntities) {
-            boolean found = false;
+        this.qryEntities = new ArrayList<>(qryEntities);
 
-            for (QueryEntity existing : this.qryEntities) {
-                if (Objects.equals(entity.findValueType(), existing.findValueType())) {
-                    found = true;
-
-                    break;
-                }
-            }
-
-            if (!found)
-                this.qryEntities.add(entity);
-        }
+        qryEntityCfgSrc = QueryEntityConfigurationSource.QUERY_ENTITIES;
 
         return this;
     }

Review Comment:
   Added fallback to an empty collection for the `null` argument + coverage in `CacheConfigurationQueryEntityTest#testEmptySetQueryEntitiesClearsPreviousEntities`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-5967750777

   **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9382694](https://ci2.ignite.apache.org/build/9382694) · 147 suites ran, 0 reused
   
   👀 **489 test(s) started failing on this code** — not proven blockers yet: too few runs of this revision to tell a break from a flake, so a re-run of the suite decides it.
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testLocalQueryWithoutExplicitFlag`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testDistributedQuery`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testToString`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testLostIterator`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testClientOnlyNode`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testNodeLeft`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testLocalQueryWithExplicitFlag`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testIterator`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testTwoObjectsTextSearch`
   - ZooKeeper (Discovery) 4: `org.apache.ignite.spi.discovery.zk.ZookeeperDiscoverySpiTestSuite4: org.apache.ignite.internal.processors.cache.distributed.replicated.IgniteCacheReplicatedQuerySelfTest.testIllegalBounds`
   … and 479 more
   
   ❌ **3 blocker(s) in 3 suite(s):**
   - Queries 3: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite3: org.apache.ignite.internal.processors.query.h2.sql.BaseH2CompareQueryTest.`
   - Queries 4: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite4: org.apache.ignite.internal.processors.query.h2.twostep.NoneOrSinglePartitionsQueryOptimizationsTest.`
   - Queries 5: `org.apache.ignite.testsuites.IgniteBinaryCacheQueryTestSuite5: org.apache.ignite.internal.processors.query.IgniteCacheGroupsCompareQueryTest.`
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-5976946887

   **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9385078](https://ci2.ignite.apache.org/build/9385078) · 147 suites ran, 0 reused
   
   ⚠️ **This run doesn't cover the PR fully:**
   - the RunAll was interrupted — 137 suite(s) never ran
   - 8 suite(s) have no reliable result (compilation error, timeout, crash)
   - 1 commit(s) pushed since this run — it tested older code
   
   Everything below is what it did manage to say.
   
   ⚠️ **8 broken suite(s)** (no reliable run):
   - Platform .NET (Core Linux): failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30308 [id 9384931]>
   - Thin client: Node.js: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30308 [id 9384931]>
   - Thin client: PHP: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30308 [id 9384931]>
   - Thin client: Python: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30308 [id 9384931]>
   - > Build: non-zero exit code
   - Platform .NET (Windows): failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30308 [id 9384931]>
   - Platform .NET (Windows) 2: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build with id: 9384931>
   - Platform .NET (Windows) 3: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30308 [id 9384931]>
   
   🔎 **No blockers found — but the run above can't prove the PR is clean.** 0 pre-existing/flaky tests filtered out. Re-run once the above is sorted out.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-5977854481

   **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9385276](https://ci2.ignite.apache.org/build/9385276) · 147 suites ran, 0 reused
   
   👀 **1 test(s) started failing on this code** — not proven blockers yet: too few runs of this revision to tell a break from a flake, so a re-run of the suite decides it.
   - Snapshots 3: `org.apache.ignite.testsuites.IgniteSnapshotTestSuite3: org.apache.ignite.internal.processors.cache.persistence.snapshot.EncryptedSnapshotTest.testStartFromSnapshotFailedWithOtherMasterKey[encryption=true, onlyPrimay=false]`
   
   ⚠️ **No proven blocker yet** — this is not an all-clear: see the tests above. 22 pre-existing/flaky filtered out.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180737306


##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;

Review Comment:
   This class has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180716275


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<Integer, Person>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, Person.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(1, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Annotation index and configured composite index must coexist. */
+    @Test
+    public void testAnnotationAndConfiguredIndexesAreCreated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        String annotationIdxName = annotationIndexName();
+
+        assertTrue(hasIndex(indexes, annotationIdxName));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Reverse order must work as well: queryEntities first, indexedTypes second. */
+    @Test
+    public void testReverseConfigurationOrder() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ))
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, annotationIndexName()));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Metadata added by several consecutive setQueryEntities calls must accumulate. */
+    @Test
+    public void testSeveralSetQueryEntitiesCreateAllIndexes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(NAME_IDX, NAME_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(AGE_IDX, AGE_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, NAME_IDX));
+        assertTrue(hasIndex(indexes, AGE_IDX));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(3, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Identical index definitions must be deduplicated. */
+    @Test
+    public void testSameIndexDefinitionIsDeduplicated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String idxName = "DUP_IDX";
+
+        QueryIndex idx = index(idxName, NAME_FIELD);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, idxName));
+
+        assertEquals(1, indexes.stream().filter(i -> !i.isPk()).count());
+    }
+
+    /** Same index name with different definition is a conflict. */
+    @Test
+    public void testSameIndexNameWithDifferentDefinitionFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String ageField = AGE_FIELD;
+        String conflictIdx = "CONFLICT_IDX";
+
+        QueryEntity first = configuredEntity(Person.class, index(conflictIdx, NAME_FIELD));
+        QueryEntity second = configuredEntity(Person.class, index(conflictIdx, ageField));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=index[%s], " +
+                "existingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1], " +
+                "incomingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1]]",
+            CACHE_NAME, conflictIdx, conflictIdx, NAME_FIELD, conflictIdx, ageField);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Indexes without explicitly configured names are merged correctly. */
+    @Test
+    public void testIndexesWithoutExplicitNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryIndex firstIdx = new QueryIndex(Collections.singletonList(NAME_FIELD), QueryIndexType.SORTED);
+        assertNull(firstIdx.getName());
+
+        QueryIndex secondIdx = new QueryIndex(Arrays.asList(NAME_FIELD, AGE_FIELD), QueryIndexType.SORTED);
+        assertNull(secondIdx.getName());
+
+        QueryIndex thirdIdx = index(AGE_IDX, AGE_FIELD);
+        assertNotNull(thirdIdx.getName());
+
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, firstIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, secondIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, thirdIdx)));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+
+        assertEquals(3, entity.getIndexes().size());
+
+        for (QueryIndex idx : List.of(firstIdx, secondIdx, thirdIdx))
+            assertTrue(entity.getIndexes().stream().anyMatch(i -> i.getFields().equals(idx.getFields())));
+    }
+
+    /**
+     * Verifies that adding SQL metadata to a statically configured cache preserves {@link QueryEntityEx} and its
+     * extended metadata.
+     *
+     * <p>A schema-add operation patches both the runtime cache configuration and the cache descriptor configuration.
+     * These configurations must not share a mutable query-entity collection, otherwise the first patch may modify the
+     * descriptor configuration before the schema-finish phase and cause the same query entity to be merged again.</p>
+     */
+    @Test
+    public void testSchemaAddPreservesQueryEntityExMetadata() throws Exception {
+        staticCfg = true;
+
+        IgniteEx node = startGrid(0);
+
+        node.cluster().state(ACTIVE);
+
+        DynamicCacheDescriptor desc = node.context().cache().cacheDescriptor(CACHE_NAME);
+
+        assertTrue(desc.cacheConfiguration().getQueryEntities().isEmpty());

Review Comment:
   I removed the `entities()` helper method from the test class.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4056496102


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {

Review Comment:
   Correct, we can have entities with the same key type, but with different value type. You’re right, this was working correctly before my changes. This is actually a regression test: I think it’s important to make sure that QueryEntityMerger doesn’t try to merge such entities and doesn’t view them as a conflict. I changed the name and javadoc to make it more obvious.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4313035322


##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2484,6 +2493,26 @@ public CacheConfiguration<K, V> setIndexPath(String idxPath) {
         return S.toString(CacheConfiguration.class, this);
     }
 
+    /** */
+    void replaceQueryEntities(Collection<QueryEntity> qryEntities) {
+        this.qryEntities = new ArrayList<>(qryEntities);
+    }
+
+    /** */
+    private void checkQueryEntityConfigurationSource(QueryEntityConfigurationSource src) {
+        if (qryEntityCfgSrc != null && qryEntityCfgSrc != src)
+            throw new CacheException(String.format(MIXED_QUERY_ENTITIES_API_ERROR_TEMPLATE, name));

Review Comment:
   If `name` hasn’t been set yet, the thrown message will contain `cacheName=null`, which is not very actionable. Consider falling back to a placeholder like `<unnamed>` or deriving the effective cache name (if available at that stage) to improve diagnostics.



##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfigurationInternalAccessor.java:
##########
@@ -0,0 +1,39 @@
+/*
+ * 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.configuration;
+
+import java.util.Collection;
+import org.apache.ignite.cache.QueryEntity;
+
+/** Provides internal access to {@link CacheConfiguration} implementation details. */
+public final class CacheConfigurationInternalAccessor {
+    /** */
+    private CacheConfigurationInternalAccessor() {
+        // No-op.
+    }
+
+    /**
+     * Replaces query entities without changing the query-entity configuration source.
+     *
+     * @param cfg Cache configuration.
+     * @param qryEntities Query entities.
+     */
+    public static void replaceQueryEntities(CacheConfiguration<?, ?> cfg, Collection<QueryEntity> qryEntities) {
+        cfg.replaceQueryEntities(qryEntities);
+    }
+}

Review Comment:
   This introduces a new `public` type in a public API package (`org.apache.ignite.configuration`) that is intended for internal use. To avoid unintentionally expanding the supported public surface, consider marking it explicitly as internal (project annotation/javadoc conventions), and/or limiting visibility if the project has a pattern for internal helpers in public packages.



##########
modules/spring/src/test/java/org/apache/ignite/internal/CacheConfigurationQueryEntityXmlTest.java:
##########
@@ -0,0 +1,80 @@
+/*
+ * 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;
+
+import javax.cache.CacheException;
+import org.apache.ignite.IgniteException;
+import org.apache.ignite.Ignition;
+import org.apache.ignite.internal.util.typedef.X;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+import org.springframework.beans.PropertyAccessException;
+import org.springframework.beans.PropertyBatchUpdateException;
+
+import static org.apache.ignite.configuration.CacheConfiguration.MIXED_QUERY_ENTITIES_API_ERROR_TEMPLATE;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests that Spring XML configuration cannot mix indexed types and explicit query entities. */
+public class CacheConfigurationQueryEntityXmlTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CONFIG_DIR = "modules/spring/src/test/config/query-entities/";

Review Comment:
   Using a hard-coded relative path for test resources can make the test sensitive to the JVM working directory (e.g., when executed from a different module or via IDE). Prefer resolving via Ignite home/test resource resolution utilities (e.g., resolving from classpath or `U.resolveIgnitePath(...)`) so the test is location-independent.



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/IgniteCacheAbstractQuerySelfTest.java:
##########
@@ -1186,7 +1219,7 @@ public void testTwoObjectsTextSearch() {
             new QueryEntity(String.class, ObjectValueOther.class)
         ));
 
-        IgniteCache<Object, Object> c = jcache(ignite(), conf, Object.class, Object.class);
+        IgniteCache<Object, Object> c = jcache(ignite(), conf, "Object-Object");

Review Comment:
   This call relies on a `jcache(...)` overload taking `(Ignite, CacheConfiguration, String)`. In this diff, a dedicated helper `jcacheWithSpecifiedName(...)` is introduced and used elsewhere for explicit naming; using that helper here would make the intent unambiguous and reduce coupling to overload resolution.



##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2075,42 +2090,36 @@ public CacheConfiguration<K, V> setPartitionLossPolicy(PartitionLossPolicy partL
 
     /**
      * Sets query entities configuration.
+     * <p>
+     * This method must not be used together with {@link #setIndexedTypes}.
+     * <p>
+     * Repeated calls replace the query entities configured by the previous call.
+     * Passing an empty collection clears the existing query entities.
      *
      * @param qryEntities Query entities.
      * @return {@code this} for chaining.
+     * @throws CacheException If query entities have already been configured through {@link #setIndexedTypes}.
      */
     public CacheConfiguration<K, V> setQueryEntities(Collection<QueryEntity> qryEntities) {
-        if (this.qryEntities == null) {
-            this.qryEntities = new ArrayList<>(qryEntities);
-
-            return this;
-        }
+        checkQueryEntityConfigurationSource(QueryEntityConfigurationSource.QUERY_ENTITIES);
 
-        for (QueryEntity entity : qryEntities) {
-            boolean found = false;
+        this.qryEntities = new ArrayList<>(qryEntities);
 
-            for (QueryEntity existing : this.qryEntities) {
-                if (Objects.equals(entity.findValueType(), existing.findValueType())) {
-                    found = true;
-
-                    break;
-                }
-            }
-
-            if (!found)
-                this.qryEntities.add(entity);
-        }
+        qryEntityCfgSrc = QueryEntityConfigurationSource.QUERY_ENTITIES;
 
         return this;
     }

Review Comment:
   `setQueryEntities` will throw a `NullPointerException` if called with `null` because of `new ArrayList<>(qryEntities)`. Since the method previously didn’t document nullability (and other setters often treat null/empty as 'clear'), consider either: (1) explicitly rejecting null with a clear `IllegalArgumentException`/`CacheException`, or (2) treating null as 'clear' (equivalent to empty), to avoid an unexpected NPE.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "zstan (via GitHub) via notifications" <no...@ignite.apache.org>.
zstan merged PR #13583:
URL: https://github.com/apache/ignite/pull/13583


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "zstan (via GitHub)" <gi...@apache.org>.
zstan commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4060001447


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());

Review Comment:
   let\`s add also ?
   
   assertEquals(1, entities(node).size());



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<Integer, Person>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, Person.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(1, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Annotation index and configured composite index must coexist. */
+    @Test
+    public void testAnnotationAndConfiguredIndexesAreCreated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        String annotationIdxName = annotationIndexName();
+
+        assertTrue(hasIndex(indexes, annotationIdxName));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Reverse order must work as well: queryEntities first, indexedTypes second. */
+    @Test
+    public void testReverseConfigurationOrder() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ))
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, annotationIndexName()));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Metadata added by several consecutive setQueryEntities calls must accumulate. */
+    @Test
+    public void testSeveralSetQueryEntitiesCreateAllIndexes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(NAME_IDX, NAME_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(AGE_IDX, AGE_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, NAME_IDX));
+        assertTrue(hasIndex(indexes, AGE_IDX));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(3, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Identical index definitions must be deduplicated. */
+    @Test
+    public void testSameIndexDefinitionIsDeduplicated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String idxName = "DUP_IDX";
+
+        QueryIndex idx = index(idxName, NAME_FIELD);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, idxName));
+
+        assertEquals(1, indexes.stream().filter(i -> !i.isPk()).count());
+    }
+
+    /** Same index name with different definition is a conflict. */
+    @Test
+    public void testSameIndexNameWithDifferentDefinitionFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String ageField = AGE_FIELD;
+        String conflictIdx = "CONFLICT_IDX";
+
+        QueryEntity first = configuredEntity(Person.class, index(conflictIdx, NAME_FIELD));
+        QueryEntity second = configuredEntity(Person.class, index(conflictIdx, ageField));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=index[%s], " +
+                "existingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1], " +
+                "incomingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1]]",
+            CACHE_NAME, conflictIdx, conflictIdx, NAME_FIELD, conflictIdx, ageField);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Indexes without explicitly configured names are merged correctly. */
+    @Test
+    public void testIndexesWithoutExplicitNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryIndex firstIdx = new QueryIndex(Collections.singletonList(NAME_FIELD), QueryIndexType.SORTED);
+        assertNull(firstIdx.getName());
+
+        QueryIndex secondIdx = new QueryIndex(Arrays.asList(NAME_FIELD, AGE_FIELD), QueryIndexType.SORTED);
+        assertNull(secondIdx.getName());
+
+        QueryIndex thirdIdx = index(AGE_IDX, AGE_FIELD);
+        assertNotNull(thirdIdx.getName());
+
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, firstIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, secondIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, thirdIdx)));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+
+        assertEquals(3, entity.getIndexes().size());
+
+        for (QueryIndex idx : List.of(firstIdx, secondIdx, thirdIdx))
+            assertTrue(entity.getIndexes().stream().anyMatch(i -> i.getFields().equals(idx.getFields())));
+    }
+
+    /**
+     * Verifies that adding SQL metadata to a statically configured cache preserves {@link QueryEntityEx} and its
+     * extended metadata.
+     *
+     * <p>A schema-add operation patches both the runtime cache configuration and the cache descriptor configuration.
+     * These configurations must not share a mutable query-entity collection, otherwise the first patch may modify the
+     * descriptor configuration before the schema-finish phase and cause the same query entity to be merged again.</p>
+     */
+    @Test
+    public void testSchemaAddPreservesQueryEntityExMetadata() throws Exception {
+        staticCfg = true;
+
+        IgniteEx node = startGrid(0);
+
+        node.cluster().state(ACTIVE);
+
+        DynamicCacheDescriptor desc = node.context().cache().cacheDescriptor(CACHE_NAME);
+
+        assertTrue(desc.cacheConfiguration().getQueryEntities().isEmpty());
+
+        node.cache(CACHE_NAME).query(new SqlFieldsQuery(
+            "CREATE TABLE TEST_TBL (" +
+                "ID1 INT, " +
+                "ID2 INT, " +
+                "VAL VARCHAR NOT NULL, " +
+                "PRIMARY KEY (ID1, ID2)" +
+                ") WITH \"CACHE_NAME=" + CACHE_NAME + "\""
+        )).getAll();
+
+        Collection<QueryEntity> entities = node.context().cache()
+            .cacheDescriptor(CACHE_NAME)
+            .cacheConfiguration()
+            .getQueryEntities();

Review Comment:
   ```suggestion
   Collection<QueryEntity> entities = entities(node);
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<Integer, Person>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, Person.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(1, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Annotation index and configured composite index must coexist. */
+    @Test
+    public void testAnnotationAndConfiguredIndexesAreCreated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        String annotationIdxName = annotationIndexName();
+
+        assertTrue(hasIndex(indexes, annotationIdxName));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Reverse order must work as well: queryEntities first, indexedTypes second. */
+    @Test
+    public void testReverseConfigurationOrder() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ))
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, annotationIndexName()));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Metadata added by several consecutive setQueryEntities calls must accumulate. */
+    @Test
+    public void testSeveralSetQueryEntitiesCreateAllIndexes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(NAME_IDX, NAME_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(AGE_IDX, AGE_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, NAME_IDX));
+        assertTrue(hasIndex(indexes, AGE_IDX));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(3, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Identical index definitions must be deduplicated. */
+    @Test
+    public void testSameIndexDefinitionIsDeduplicated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String idxName = "DUP_IDX";
+
+        QueryIndex idx = index(idxName, NAME_FIELD);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, idxName));
+
+        assertEquals(1, indexes.stream().filter(i -> !i.isPk()).count());
+    }
+
+    /** Same index name with different definition is a conflict. */
+    @Test
+    public void testSameIndexNameWithDifferentDefinitionFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String ageField = AGE_FIELD;
+        String conflictIdx = "CONFLICT_IDX";
+
+        QueryEntity first = configuredEntity(Person.class, index(conflictIdx, NAME_FIELD));
+        QueryEntity second = configuredEntity(Person.class, index(conflictIdx, ageField));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=index[%s], " +
+                "existingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1], " +
+                "incomingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1]]",
+            CACHE_NAME, conflictIdx, conflictIdx, NAME_FIELD, conflictIdx, ageField);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Indexes without explicitly configured names are merged correctly. */
+    @Test
+    public void testIndexesWithoutExplicitNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryIndex firstIdx = new QueryIndex(Collections.singletonList(NAME_FIELD), QueryIndexType.SORTED);
+        assertNull(firstIdx.getName());
+
+        QueryIndex secondIdx = new QueryIndex(Arrays.asList(NAME_FIELD, AGE_FIELD), QueryIndexType.SORTED);
+        assertNull(secondIdx.getName());
+
+        QueryIndex thirdIdx = index(AGE_IDX, AGE_FIELD);
+        assertNotNull(thirdIdx.getName());
+
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, firstIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, secondIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, thirdIdx)));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+
+        assertEquals(3, entity.getIndexes().size());
+
+        for (QueryIndex idx : List.of(firstIdx, secondIdx, thirdIdx))
+            assertTrue(entity.getIndexes().stream().anyMatch(i -> i.getFields().equals(idx.getFields())));
+    }
+
+    /**
+     * Verifies that adding SQL metadata to a statically configured cache preserves {@link QueryEntityEx} and its
+     * extended metadata.
+     *
+     * <p>A schema-add operation patches both the runtime cache configuration and the cache descriptor configuration.
+     * These configurations must not share a mutable query-entity collection, otherwise the first patch may modify the
+     * descriptor configuration before the schema-finish phase and cause the same query entity to be merged again.</p>
+     */
+    @Test
+    public void testSchemaAddPreservesQueryEntityExMetadata() throws Exception {
+        staticCfg = true;
+
+        IgniteEx node = startGrid(0);
+
+        node.cluster().state(ACTIVE);
+
+        DynamicCacheDescriptor desc = node.context().cache().cacheDescriptor(CACHE_NAME);
+
+        assertTrue(desc.cacheConfiguration().getQueryEntities().isEmpty());

Review Comment:
   ```suggestion
           assertTrue(entities(node).isEmpty());
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {

Review Comment:
   all merge exceptance need to be checked through :
   ```
   Collection<QueryEntity> entities = entities(node);
   assertEquals(EXCEPTED, entities.size());
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */

Review Comment:
   ```suggestion
       /** Different key field names for the same query entity are lead to conflict. */
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));

Review Comment:
   ```suggestion
           ccfg.setQueryEntities(List.of(first));
           ccfg.setQueryEntities(List.of(second));
   ```
   more shorter but it up to you



##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;

Review Comment:
   I see that result of this operation is used in : org.apache.ignite.cache.QueryEntity#setFields but there is no `@Nullable` annotation, thus you need to return smth like: `return U.newLinkedHashMap(0);`



##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;
+
+        LinkedHashMap<String, String> res = new LinkedHashMap<>();
+
+        if (existingFields != null)
+            res.putAll(existingFields);
+
+        if (incomingFields == null)
+            return res;
+
+        for (Map.Entry<String, String> entry : incomingFields.entrySet()) {
+            String field = entry.getKey();
+            String incomingType = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingType);
+
+                continue;
+            }
+
+            String existingType = res.get(field);
+
+            if (!Objects.equals(existingType, incomingType))
+                throw mergeConflict("fieldType[" + field + ']', existingType, incomingType);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Map<String, T> mergeMap(String propName, Map<String, T> existingVals, Map<String, T> incomingVals) {
+        if (existingVals == null && incomingVals == null)
+            return null;
+
+        Map<String, T> res = new HashMap<>();
+
+        if (existingVals != null)
+            res.putAll(existingVals);
+
+        if (incomingVals == null)
+            return res;
+
+        for (Map.Entry<String, T> entry : incomingVals.entrySet()) {
+            String field = entry.getKey();
+            T incomingVal = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingVal);
+
+                continue;
+            }
+
+            T existingVal = res.get(field);
+
+            if (!Objects.equals(existingVal, incomingVal))
+                throw mergeConflict(propName + '[' + field + ']', existingVal, incomingVal);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Set<T> mergeSet(Set<T> existing, Set<T> incoming) {
+        if (F.isEmpty(existing) && F.isEmpty(incoming))
+            return null;

Review Comment:
   ```suggestion
               return Set.of();
   ```



##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2484,6 +2478,29 @@ public CacheConfiguration<K, V> setIndexPath(String idxPath) {
         return S.toString(CacheConfiguration.class, this);
     }
 
+    /** */
+    private QueryEntity findQueryEntity(String valType) {
+        if (qryEntities == null)
+            return null;

Review Comment:
   I plan to use JSpecify Nullaway frameqork in future, thus we need to be accurate with return types if function is not annotated with `@Nullable` plz fix it.



##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);

Review Comment:
   probably it\`s better to rewrite like : 
   `QueryEntity res = ex instanceof QueryEntityEx ?  new QueryEntityEx(ex) : new QueryEntity(ex);`
   and roll back code in 
   `org.apache.ignite.internal.processors.cache.GridCacheUtils#patchCacheConfiguration`
   I think it become more clear, wdyt ?



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */

Review Comment:
   ```suggestion
       /** Different aliases for the same field are lead to conflict. */
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<Integer, Person>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, Person.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(1, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Annotation index and configured composite index must coexist. */
+    @Test
+    public void testAnnotationAndConfiguredIndexesAreCreated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        String annotationIdxName = annotationIndexName();
+
+        assertTrue(hasIndex(indexes, annotationIdxName));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Reverse order must work as well: queryEntities first, indexedTypes second. */
+    @Test
+    public void testReverseConfigurationOrder() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<Integer, AnnotatedPerson>(CACHE_NAME)
+            .setQueryEntities(Collections.singletonList(
+                configuredEntity(AnnotatedPerson.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+            ))
+            .setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, annotationIndexName()));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(2, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Metadata added by several consecutive setQueryEntities calls must accumulate. */
+    @Test
+    public void testSeveralSetQueryEntitiesCreateAllIndexes() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(NAME_IDX, NAME_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(AGE_IDX, AGE_FIELD))
+        ));
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(Person.class, index(COMPOSITE_IDX, NAME_FIELD, AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, NAME_IDX));
+        assertTrue(hasIndex(indexes, AGE_IDX));
+        assertTrue(hasIndex(indexes, COMPOSITE_IDX));
+
+        assertEquals(3, indexes.stream().filter(idx -> !idx.isPk()).count());
+    }
+
+    /** Identical index definitions must be deduplicated. */
+    @Test
+    public void testSameIndexDefinitionIsDeduplicated() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String idxName = "DUP_IDX";
+
+        QueryIndex idx = index(idxName, NAME_FIELD);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class), idx)));
+
+        node.createCache(ccfg);
+
+        List<SqlIndexView> indexes = cacheIndexes(node);
+
+        assertTrue(hasIndex(indexes, idxName));
+
+        assertEquals(1, indexes.stream().filter(i -> !i.isPk()).count());
+    }
+
+    /** Same index name with different definition is a conflict. */
+    @Test
+    public void testSameIndexNameWithDifferentDefinitionFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String ageField = AGE_FIELD;
+        String conflictIdx = "CONFLICT_IDX";
+
+        QueryEntity first = configuredEntity(Person.class, index(conflictIdx, NAME_FIELD));
+        QueryEntity second = configuredEntity(Person.class, index(conflictIdx, ageField));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=index[%s], " +
+                "existingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1], " +
+                "incomingValue=QueryIndex [name=%s, fields=LinkedHashMap {%s=true}, type=SORTED, inlineSize=-1]]",
+            CACHE_NAME, conflictIdx, conflictIdx, NAME_FIELD, conflictIdx, ageField);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Indexes without explicitly configured names are merged correctly. */
+    @Test
+    public void testIndexesWithoutExplicitNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryIndex firstIdx = new QueryIndex(Collections.singletonList(NAME_FIELD), QueryIndexType.SORTED);
+        assertNull(firstIdx.getName());
+
+        QueryIndex secondIdx = new QueryIndex(Arrays.asList(NAME_FIELD, AGE_FIELD), QueryIndexType.SORTED);
+        assertNull(secondIdx.getName());
+
+        QueryIndex thirdIdx = index(AGE_IDX, AGE_FIELD);
+        assertNotNull(thirdIdx.getName());
+
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, firstIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, secondIdx)));
+        ccfg.setQueryEntities(Collections.singletonList(configuredEntity(Person.class, thirdIdx)));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+
+        assertEquals(3, entity.getIndexes().size());
+
+        for (QueryIndex idx : List.of(firstIdx, secondIdx, thirdIdx))
+            assertTrue(entity.getIndexes().stream().anyMatch(i -> i.getFields().equals(idx.getFields())));
+    }
+
+    /**
+     * Verifies that adding SQL metadata to a statically configured cache preserves {@link QueryEntityEx} and its
+     * extended metadata.
+     *
+     * <p>A schema-add operation patches both the runtime cache configuration and the cache descriptor configuration.
+     * These configurations must not share a mutable query-entity collection, otherwise the first patch may modify the
+     * descriptor configuration before the schema-finish phase and cause the same query entity to be merged again.</p>
+     */
+    @Test
+    public void testSchemaAddPreservesQueryEntityExMetadata() throws Exception {
+        staticCfg = true;
+
+        IgniteEx node = startGrid(0);
+
+        node.cluster().state(ACTIVE);
+
+        DynamicCacheDescriptor desc = node.context().cache().cacheDescriptor(CACHE_NAME);
+
+        assertTrue(desc.cacheConfiguration().getQueryEntities().isEmpty());
+
+        node.cache(CACHE_NAME).query(new SqlFieldsQuery(
+            "CREATE TABLE TEST_TBL (" +
+                "ID1 INT, " +
+                "ID2 INT, " +
+                "VAL VARCHAR NOT NULL, " +
+                "PRIMARY KEY (ID1, ID2)" +
+                ") WITH \"CACHE_NAME=" + CACHE_NAME + "\""
+        )).getAll();
+
+        Collection<QueryEntity> entities = node.context().cache()
+            .cacheDescriptor(CACHE_NAME)
+            .cacheConfiguration()
+            .getQueryEntities();
+
+        assertEquals(1, entities.size());
+
+        QueryEntity entity = entities.iterator().next();
+
+        assertTrue(entity instanceof QueryEntityEx);
+
+        QueryEntityEx entityEx = (QueryEntityEx)entity;
+
+        assertTrue(entityEx.sql());
+        assertTrue(entityEx.isPreserveKeysOrder());
+        assertTrue(entityEx.fillAbsentPKsWithDefaults());
+    }
+
+    /** */
+    private static QueryEntity personEntity(LinkedHashMap<String, String> fields, QueryIndex... indexes) {
+        QueryEntity entity = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setTableName(Person.class.getSimpleName())
+            .setFields(fields);
+
+        if (indexes.length != 0)
+            entity.setIndexes(Arrays.asList(indexes));
+
+        return entity;
+    }
+
+    /** */
+    private static QueryEntity configuredEntity(Class<?> valCls, QueryIndex... indexes) {
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+
+        fields.put(NAME_FIELD, String.class.getName());
+        fields.put(AGE_FIELD, Integer.class.getName());
+
+        QueryEntity res = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(valCls.getName())
+            .setTableName(valCls.getSimpleName())
+            .setFields(fields);
+
+        if (indexes.length != 0)
+            res.setIndexes(Arrays.asList(indexes));
+
+        return res;
+    }
+
+    /** */
+    private static QueryIndex index(String name, String... fields) {
+        return new QueryIndex(Arrays.asList(fields), QueryIndexType.SORTED).setName(name);
+    }
+
+    /** */
+    private static List<SqlIndexView> cacheIndexes(IgniteEx node) {
+        SystemView<SqlIndexView> indexes = node.context().systemView().view("indexes");
+        assertNotNull(indexes);
+
+        List<SqlIndexView> res = new ArrayList<>();
+
+        for (SqlIndexView idx : indexes) {
+            if (CACHE_NAME.equals(idx.cacheName()))
+                res.add(idx);
+        }
+
+        return res;
+    }
+
+    /** */
+    private static boolean hasIndex(List<SqlIndexView> indexes, String idxName) {
+        return indexes.stream().anyMatch(idx -> idxName.equalsIgnoreCase(idx.indexName()));
+    }
+
+    /** */
+    private static String annotationIndexName() {
+        QueryEntity entity = new QueryEntity(Integer.class, AnnotatedPerson.class);
+
+        QueryIndex idx = entity.getIndexes().iterator().next();
+
+        return QueryUtils.indexName(entity, idx);
+    }
+
+    /** */
+    private static LinkedHashMap<String, String> fields(Object... vals) {
+        assertTrue(vals.length % 2 == 0);
+
+        LinkedHashMap<String, String> fields = new LinkedHashMap<>();
+
+        for (int i = 0; i < vals.length; i += 2)
+            fields.put((String)vals[i], ((Class<?>)vals[i + 1]).getName());
+
+        return fields;
+    }
+
+    /** */
+    private static SqlTableView cacheTable(IgniteEx node) {
+        SystemView<SqlTableView> tables = node.context().systemView().view(SQL_TBLS_VIEW);
+        assertNotNull(tables);
+
+        SqlTableView res = null;
+
+        for (SqlTableView tbl : tables) {
+            if (CACHE_NAME.equals(tbl.cacheName()))
+                res = tbl;

Review Comment:
   ```suggestion
               if (CACHE_NAME.equals(tbl.cacheName())) {
                   res = tbl;
                   break;
                 }
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */

Review Comment:
   ```suggestion
       /** Different configured table names are lead to conflict. */
   ```



##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;
+
+        LinkedHashMap<String, String> res = new LinkedHashMap<>();
+
+        if (existingFields != null)
+            res.putAll(existingFields);
+
+        if (incomingFields == null)
+            return res;
+
+        for (Map.Entry<String, String> entry : incomingFields.entrySet()) {
+            String field = entry.getKey();
+            String incomingType = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingType);
+
+                continue;
+            }
+
+            String existingType = res.get(field);
+
+            if (!Objects.equals(existingType, incomingType))
+                throw mergeConflict("fieldType[" + field + ']', existingType, incomingType);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Map<String, T> mergeMap(String propName, Map<String, T> existingVals, Map<String, T> incomingVals) {
+        if (existingVals == null && incomingVals == null)
+            return null;

Review Comment:
   ```suggestion
               return Map.of();
   ```



##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;
+
+        LinkedHashMap<String, String> res = new LinkedHashMap<>();
+
+        if (existingFields != null)
+            res.putAll(existingFields);
+
+        if (incomingFields == null)
+            return res;
+
+        for (Map.Entry<String, String> entry : incomingFields.entrySet()) {
+            String field = entry.getKey();
+            String incomingType = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingType);
+
+                continue;
+            }
+
+            String existingType = res.get(field);
+
+            if (!Objects.equals(existingType, incomingType))
+                throw mergeConflict("fieldType[" + field + ']', existingType, incomingType);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Map<String, T> mergeMap(String propName, Map<String, T> existingVals, Map<String, T> incomingVals) {
+        if (existingVals == null && incomingVals == null)
+            return null;
+
+        Map<String, T> res = new HashMap<>();
+
+        if (existingVals != null)
+            res.putAll(existingVals);
+
+        if (incomingVals == null)
+            return res;
+
+        for (Map.Entry<String, T> entry : incomingVals.entrySet()) {
+            String field = entry.getKey();
+            T incomingVal = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingVal);
+
+                continue;
+            }
+
+            T existingVal = res.get(field);
+
+            if (!Objects.equals(existingVal, incomingVal))
+                throw mergeConflict(propName + '[' + field + ']', existingVal, incomingVal);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Set<T> mergeSet(Set<T> existing, Set<T> incoming) {
+        if (F.isEmpty(existing) && F.isEmpty(incoming))
+            return null;
+
+        Set<T> res = new LinkedHashSet<>();
+
+        if (existing != null)
+            res.addAll(existing);
+
+        if (incoming != null)
+            res.addAll(incoming);
+
+        return res;
+    }
+
+    /** */
+    private Collection<QueryIndex> mergeIndexes(
+        QueryEntity entity,
+        Collection<QueryIndex> existingIndexes,
+        Collection<QueryIndex> incomingIndexes
+    ) {
+        if (F.isEmpty(existingIndexes) && F.isEmpty(incomingIndexes))
+            return null;

Review Comment:
   ```suggestion
               return List.of();
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */

Review Comment:
   ```suggestion
       /** Different precision definitions for the same field are lead to conflict. */
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {

Review Comment:
   does this test relates to query merge logic ?



##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2484,6 +2478,29 @@ public CacheConfiguration<K, V> setIndexPath(String idxPath) {
         return S.toString(CacheConfiguration.class, this);
     }
 
+    /** */
+    private QueryEntity findQueryEntity(String valType) {
+        if (qryEntities == null)
+            return null;
+
+        for (QueryEntity entity : qryEntities) {
+            if (Objects.equals(entity.findValueType(), valType))
+                return entity;
+        }
+
+        return null;

Review Comment:
   and it



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4056487116


##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);

Review Comment:
   I reproduced the `QueryEntityEx` metadata loss and investigated the schema-add and cache-configuration merge paths.
   
   The reported problem is real: with the current PR implementation, `QueryEntity res = new QueryEntity(ex)` can turn a `QueryEntityEx` into a plain `QueryEntity`. Which leads to losing `sql`, `preserveKeysOrder`, `fillAbsentPKsWithDefaults`, and the other extended metadata.
   
   However, after tracing the actual lifecycle, it looks like the schema-add case does not require defining merge rules for two different `QueryEntityEx` instances. The downgrade was caused by a different issue in `patchCacheConfiguration()`.
   
   The reproducer is `testSchemaAddPreservesQueryEntityExMetadata()`. It uses a statically configured cache with no query entities and then executes a query that creates a table. This DDL operation creates a `QueryEntityEx` with:
   - sql = true
   - preserveKeysOrder = true
   - fillAbsentPKsWithDefaults = true
   
   The schema-add lifecycle patches the cache configuration twice:
   ```text
   SchemaAddQueryEntityOperation
   >>>
   GridCacheContext.onSchemaAddQueryEntity() -> patchCacheConfiguration()
   >>>
   SchemaFinishDiscovery -> DynamicCacheDescriptor.schemaChangeFinish() → patchCacheConfiguration()
   ```
   
   Initially I expected this to be a legitimate merge of two extended entities, but it is not. The first and the second patch operate on different `CacheConfiguration` objects, but `CacheConfiguration` copy construction keeps the same mutable `qryEntities` collection. Therefore the first patch modifies the collection that is still visible from the descriptor configuration.
   
   So the "merge" in this scenario is actually an accidentally repeated usage of the same schema-add entity. It is not merging two independently defined `QueryEntityEx` configurations.
   
   I added an isolated regression test for the underlying issue:
   ```text
   GridCacheUtilsTest.testPatchCacheConfigurationDoesNotModifyOriginalQueryEntities
   ```
   
   The test prepares an `oldCfg` with a materialized empty query-entity collection, calls `GridCacheUtils.patchCacheConfiguration()`, and verifies that adding the new entity to the patched configuration does not modify `oldCfg`.
   
   On `master` / the previous implementation it fails on:
   ```java
   assertTrue(oldCfg.getQueryEntities().isEmpty());
   ```
   because both configurations share the same mutable `qryEntities` collection.
   
   After making `patchCacheConfiguration()` use an independent query-entity collection, this test passes.
   
   More importantly, after this change `testSchemaAddPreservesQueryEntityExMetadata` also passes. The schema-finish patch now sees an empty descriptor configuration and simply installs the incoming `QueryEntityEx`. It no longer tries to merge two identical entities.
   
   I also checked whether we have a supported runtime path that would require resolving a real conflict between two different `QueryEntityEx` objects. The relevant cases I checked were:
   1. The normal `QueryEntity` conflicts targeted by this PR are still real and are covered by `CacheConfigurationQueryEntityMergeTest`. 
   2. Adding SQL metadata dynamically to an already indexed cache is rejected, for example:
   ```text
   javax.cache.CacheException: Cache is already indexed: sql-cache
   ```
   3. Joining an active cluster with a static cache configuration that would require schema/configuration merging is rejected:
   ```text
   org.apache.ignite.spi.IgniteSpiException: Failed to join node to the active cluster (the config of the cache 'TEST_CACHE' has to be merged which is impossible on active grid). Deactivate grid and retry node join or clean the joining node.
   ```
   4. I also tested a more specific SQL-vs-API case: create the cache/table through `CREATE TABLE` on the existing node and configure the same cache statically on the joining node. This is rejected with:
   ```text
   org.apache.ignite.IgniteCheckedException: Cache configuration mismatch (local cache was created via Ignite API, while remote cache was created via CREATE TABLE): ...
   ```
   Again, `QueryEntityMerger` is not reached.
   5. It is possible to force `QueryEntityEx + QueryEntity` manually by obtaining the internal `CacheConfiguration` through `node.context().cache().cacheConfiguration(...)` and calling `setQueryEntities()` directly. In that artificial scenario the current merger can indeed downgrade the entity. 
   However, this mutates Ignite's internal runtime configuration directly. And as I understand it is not a supported public cache-configuration update path.
   
   I also reviewed the internal `setQueryEntities()` paths. Normalization replaces the entity collection after clearing it; creation of a new SQL cache installs entities into an initially empty configuration; schema-add is expected to add entities to an empty schema; and the join cases above are validated before conflicting configurations can reach the merger.
   
   Therefore I did not add rules for merging conflicting `QueryEntityEx`-specific fields. This would introduce new semantics, for example, for two different extended entities we would have to decide what these combinations mean:
   - sql: false vs true
   - implicitPk: false vs true
   - preserveKeysOrder: false vs true
   - fillAbsentPKsWithDefaults: false vs true
   - primaryKeyInlineSize: 10 vs 20
   - affinityKeyInlineSize: 10 vs 20
   
   It is not clear whether they should be conflicts, whether one side should overtake or whether some boolean fields should be combined. I could not find a supported runtime lifecycle that requires making those decisions.
   
   So the fix I propose is:
   - keep QueryEntityMerger responsible for actual QueryEntity metadata merging
   - make patchCacheConfiguration() independent from the original configuration's mutable query-entity collection
   - keep testSchemaAddPreservesQueryEntityExMetadata as the integration regression test
   - add GridCacheUtilsTest.testPatchCacheConfigurationDoesNotModifyOriginalQueryEntities
     as the focused regression test for the root cause.
   With this change, the `QueryEntityEx` created by the schema-add operation is processed correctly without introducing merge rules for its extended metadata.
   
   If there is a supported path where two independently created `QueryEntityEx` instances are expected to be merged, I can add handling for that path as well, but I could not reproduce or identify such a case in the current cache/schema lifecycle.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180731064


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */

Review Comment:
   This test has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-5988407597

   **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9386823](https://ci2.ignite.apache.org/build/9386823) · 147 suites ran, 0 reused
   
   ⚠️ **This run doesn't cover the PR fully:**
   - 1 suite(s) have no reliable result (compilation error, timeout, crash)
   - a newer run is still going — its unfinished suites can still fail
   - 2 commit(s) pushed since this run — it tested older code
   
   Everything below is what it did manage to say.
   
   ⚠️ **1 broken suite(s)** (no reliable run):
   - Cache (Failover) 4: execution timeout · non-zero exit code · Number of tests 17 is 55% less than 38 in build #1232 — ran 17 of master's 38 tests
   
   👀 **4 test(s) started failing on this code** — not proven blockers yet: too few runs of this revision to tell a break from a flake, so a re-run of the suite decides it.
   - Platform .NET (Windows): `Apache.Ignite.Core.Tests.exe: Apache.Ignite.Core.Tests.Cache.Platform.PlatformCacheTopologyChangeTest.TestContinuousTopologyChangeMaintainsCorrectPlatformCacheData(1)`
   - PDS 10: `org.apache.ignite.testsuites.IgnitePdsTestSuite10: org.apache.ignite.cdc.WalForCdcTest.testOnlyDataRecordWritten[mode=REPLICATED, atomicityMode=TRANSACTIONAL]`
   - Data Structures 2: `org.apache.ignite.testsuites.IgniteCacheDataStructuresSelfTestSuite2: org.apache.ignite.internal.processors.cache.datastructures.partitioned.GridCachePartitionedQueueEntryMoveSelfTest.testQueue`
   - Cache (Tx Recovery): `org.apache.ignite.testsuites.IgniteCacheTxRecoverySelfTestSuite: org.apache.ignite.internal.processors.cache.distributed.dht.IgniteCacheTxRecoveryRollbackTest.testNearTx1Optimistic`
   
   ⚠️ **No proven blocker yet** — this is not an all-clear: see the tests above. 18 pre-existing/flaky filtered out.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-5976643510

   **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9384769](https://ci2.ignite.apache.org/build/9384769) · 147 suites ran, 0 reused
   
   ⚠️ **This run doesn't cover the PR fully:**
   - the RunAll was interrupted — 137 suite(s) never ran
   - 8 suite(s) have no reliable result (compilation error, timeout, crash)
   
   Everything below is what it did manage to say.
   
   ⚠️ **8 broken suite(s)** (no reliable run):
   - Platform .NET (Core Linux): failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30306 [id 9384622]>
   - Thin client: Node.js: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30306 [id 9384622]>
   - Thin client: PHP: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30306 [id 9384622]>
   - Thin client: Python: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30306 [id 9384622]>
   - > Build: non-zero exit code
   - Platform .NET (Windows): failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30306 [id 9384622]>
   - Platform .NET (Windows) 2: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30306 [id 9384622]>
   - Platform .NET (Windows) 3: failed dependency · Failed to resolve artifacts from <[Apache Ignite 2.x / Tests] / > Build, build #30306 [id 9384622]>
   
   🔎 **No blockers found — but the run above can't prove the PR is clean.** 0 pre-existing/flaky tests filtered out. Re-run once the above is sorted out.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180737961


##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;
+
+        LinkedHashMap<String, String> res = new LinkedHashMap<>();
+
+        if (existingFields != null)
+            res.putAll(existingFields);
+
+        if (incomingFields == null)
+            return res;
+
+        for (Map.Entry<String, String> entry : incomingFields.entrySet()) {
+            String field = entry.getKey();
+            String incomingType = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingType);
+
+                continue;
+            }
+
+            String existingType = res.get(field);
+
+            if (!Objects.equals(existingType, incomingType))
+                throw mergeConflict("fieldType[" + field + ']', existingType, incomingType);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Map<String, T> mergeMap(String propName, Map<String, T> existingVals, Map<String, T> incomingVals) {
+        if (existingVals == null && incomingVals == null)
+            return null;

Review Comment:
   This class has been removed.



##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;
+
+        LinkedHashMap<String, String> res = new LinkedHashMap<>();
+
+        if (existingFields != null)
+            res.putAll(existingFields);
+
+        if (incomingFields == null)
+            return res;
+
+        for (Map.Entry<String, String> entry : incomingFields.entrySet()) {
+            String field = entry.getKey();
+            String incomingType = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingType);
+
+                continue;
+            }
+
+            String existingType = res.get(field);
+
+            if (!Objects.equals(existingType, incomingType))
+                throw mergeConflict("fieldType[" + field + ']', existingType, incomingType);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Map<String, T> mergeMap(String propName, Map<String, T> existingVals, Map<String, T> incomingVals) {
+        if (existingVals == null && incomingVals == null)
+            return null;
+
+        Map<String, T> res = new HashMap<>();
+
+        if (existingVals != null)
+            res.putAll(existingVals);
+
+        if (incomingVals == null)
+            return res;
+
+        for (Map.Entry<String, T> entry : incomingVals.entrySet()) {
+            String field = entry.getKey();
+            T incomingVal = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingVal);
+
+                continue;
+            }
+
+            T existingVal = res.get(field);
+
+            if (!Objects.equals(existingVal, incomingVal))
+                throw mergeConflict(propName + '[' + field + ']', existingVal, incomingVal);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Set<T> mergeSet(Set<T> existing, Set<T> incoming) {
+        if (F.isEmpty(existing) && F.isEmpty(incoming))
+            return null;

Review Comment:
   This class has been removed.



##########
modules/core/src/main/java/org/apache/ignite/internal/processors/query/QueryEntityMerger.java:
##########
@@ -0,0 +1,261 @@
+/*
+ * 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.processors.query;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.internal.util.typedef.F;
+
+/** Utility for merging compatible {@link QueryEntity} metadata. */
+public final class QueryEntityMerger {
+    /** */
+    public static final String CONFLICT_MESSAGE_TEMPLATE = "Failed to merge query entities due to conflicting metadata " +
+        "[cacheName=%s, property=%s, existingValue=%s, incomingValue=%s]";
+
+    /** */
+    private final String cacheName;
+
+    /** */
+    private QueryEntityMerger(String cacheName) {
+        this.cacheName = cacheName;
+    }
+
+    /**
+     * Merges incoming query entity metadata into existing entity.
+     *
+     * @param cacheName Cache name.
+     * @param existing Existing query entity.
+     * @param incoming Incoming query entity.
+     * @return Merged query entity.
+     * @throws CacheException If entities contain conflicting metadata.
+     */
+    public static QueryEntity merge(String cacheName, QueryEntity existing, QueryEntity incoming) {
+        return new QueryEntityMerger(cacheName).merge0(existing, incoming);
+    }
+
+    /** */
+    private QueryEntity merge0(QueryEntity ex, QueryEntity in) {
+        if (!Objects.equals(ex.findValueType(), in.findValueType())) {
+            throw new CacheException(
+                "Failed to merge query entities because value types differ " +
+                    "[cacheName=" + cacheName +
+                    ", existingValueType=" + ex.findValueType() +
+                    ", incomingValueType=" + in.findValueType() + ']'
+            );
+        }
+
+        QueryEntity res = new QueryEntity(ex);
+
+        res.setKeyType(mergeKeyType(ex, in));
+
+        res.setValueType(mergeProperty("valueType", ex.getValueType(), in.getValueType()));
+        res.setTableName(mergeProperty("tableName", ex.getTableName(), in.getTableName()));
+        res.setKeyFieldName(mergeProperty("keyFieldName", ex.getKeyFieldName(), in.getKeyFieldName()));
+        res.setValueFieldName(mergeProperty("valueFieldName", ex.getValueFieldName(), in.getValueFieldName()));
+
+        res.setFields(mergeFields(ex.getFields(), in.getFields()));
+
+        res.setKeyFields(mergeSet(ex.getKeyFields(), in.getKeyFields()));
+        res.setNotNullFields(mergeSet(ex.getNotNullFields(), in.getNotNullFields()));
+
+        res.setAliases(mergeMap("aliases", ex.getAliases(), in.getAliases()));
+        res.setDefaultFieldValues(mergeMap("defaultFieldValues", ex.getDefaultFieldValues(), in.getDefaultFieldValues()));
+        res.setFieldsPrecision(mergeMap("fieldsPrecision", ex.getFieldsPrecision(), in.getFieldsPrecision()));
+        res.setFieldsScale(mergeMap("fieldsScale", ex.getFieldsScale(), in.getFieldsScale()));
+
+        res.setIndexes(mergeIndexes(res, ex.getIndexes(), in.getIndexes()));
+
+        return res;
+    }
+
+    /** */
+    private String mergeKeyType(QueryEntity ex, QueryEntity in) {
+        String exKeyType = ex.findKeyType();
+        String inKeyType = in.findKeyType();
+
+        if (exKeyType != null && inKeyType != null && !Objects.equals(exKeyType, inKeyType)) {
+            throw mergeConflict(
+                "keyType",
+                exKeyType,
+                inKeyType
+            );
+        }
+
+        return ex.getKeyType() != null ? ex.getKeyType() : in.getKeyType();
+    }
+
+    /** */
+    private <T> T mergeProperty(String propName, T existingVal, T incomingVal) {
+        if (existingVal == null)
+            return incomingVal;
+
+        if (incomingVal == null)
+            return existingVal;
+
+        if (Objects.equals(existingVal, incomingVal))
+            return existingVal;
+
+        throw mergeConflict(propName, existingVal, incomingVal);
+    }
+
+    /** */
+    private LinkedHashMap<String, String> mergeFields(
+        Map<String, String> existingFields,
+        Map<String, String> incomingFields
+    ) {
+        if (existingFields == null && incomingFields == null)
+            return null;
+
+        LinkedHashMap<String, String> res = new LinkedHashMap<>();
+
+        if (existingFields != null)
+            res.putAll(existingFields);
+
+        if (incomingFields == null)
+            return res;
+
+        for (Map.Entry<String, String> entry : incomingFields.entrySet()) {
+            String field = entry.getKey();
+            String incomingType = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingType);
+
+                continue;
+            }
+
+            String existingType = res.get(field);
+
+            if (!Objects.equals(existingType, incomingType))
+                throw mergeConflict("fieldType[" + field + ']', existingType, incomingType);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Map<String, T> mergeMap(String propName, Map<String, T> existingVals, Map<String, T> incomingVals) {
+        if (existingVals == null && incomingVals == null)
+            return null;
+
+        Map<String, T> res = new HashMap<>();
+
+        if (existingVals != null)
+            res.putAll(existingVals);
+
+        if (incomingVals == null)
+            return res;
+
+        for (Map.Entry<String, T> entry : incomingVals.entrySet()) {
+            String field = entry.getKey();
+            T incomingVal = entry.getValue();
+
+            if (!res.containsKey(field)) {
+                res.put(field, incomingVal);
+
+                continue;
+            }
+
+            T existingVal = res.get(field);
+
+            if (!Objects.equals(existingVal, incomingVal))
+                throw mergeConflict(propName + '[' + field + ']', existingVal, incomingVal);
+        }
+
+        return res;
+    }
+
+    /** */
+    private <T> Set<T> mergeSet(Set<T> existing, Set<T> incoming) {
+        if (F.isEmpty(existing) && F.isEmpty(incoming))
+            return null;
+
+        Set<T> res = new LinkedHashSet<>();
+
+        if (existing != null)
+            res.addAll(existing);
+
+        if (incoming != null)
+            res.addAll(incoming);
+
+        return res;
+    }
+
+    /** */
+    private Collection<QueryIndex> mergeIndexes(
+        QueryEntity entity,
+        Collection<QueryIndex> existingIndexes,
+        Collection<QueryIndex> incomingIndexes
+    ) {
+        if (F.isEmpty(existingIndexes) && F.isEmpty(incomingIndexes))
+            return null;

Review Comment:
   This class has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180719272


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());

Review Comment:
   This test has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "ignite-pr-checker (via GitHub) via notifications" <no...@ignite.apache.org>.
ignite-pr-checker commented on PR #13583:
URL: https://github.com/apache/ignite/pull/13583#issuecomment-6552570826

   @oleg-vlsk **[Ignite PR Checker](https://ignite-pr-checker.is-a.dev/?pr=13583)** verdict · RunAll build [9460568](https://ci2.ignite.apache.org/build/9460568) · tested [15bd16d](https://github.com/apache/ignite/pull/13583/commits/15bd16de6d33ca57a1576c1b390e5058fa2022a6) · 149 suites ran, 0 reused
   
   ⚠️ **This run doesn't cover the PR fully:**
   - 49 suites have no reliable result (48× ran fewer tests than on master; execution timeout · non-zero exit code)
   - 13 suites ran fewer tests than the same suites on master
   
   Everything below is what it did manage to say.
   
   ⚠️ **Broken suites** (no reliable result):
   - ran fewer tests than on master (48 suites): SPI (URI Deploy) (ran 55 of master's 69 tests), PDS 3 (ran 42 of master's 52 tests), Cache 7 (ran 147 of master's 171 tests), PDS 4 (ran 213 of master's 239 tests), Cache 9 (ran 79 of master's 91 tests) and 43 more
   - Cache (Failover) 4: execution timeout · non-zero exit code — ran 17 of master's 48 tests
   
   🔍 **13 suites ran fewer tests than on master** (tests that never ran can't fail):
   - Cache (Failover) 5: ran 67 of master's 85 tests (**−21%**)
   - Cache 14: ran 193 of master's 233 tests (**−17%**)
   - Cache 18: ran 152 of master's 176 tests (**−14%**)
   - Calcite SQL 2: ran 1244 of master's 1406 tests (**−12%**)
   - PDS (Indexing) 2: ran 200 of master's 227 tests (**−12%**)
   - Service Grid: ran 401 of master's 451 tests (**−11%**)
   - SPI (Discovery): ran 281 of master's 317 tests (**−11%**)
   - Web Sessions: ran 54 of master's 60 tests (**−10%**)
   - Cache 8: ran 377 of master's 417 tests (**−10%**)
   - Queries 3: ran 1499 of master's 1671 tests (**−10%**)
   - Disk Page Compressions 6: ran 117 of master's 130 tests (**−10%**)
   - Cache 4: ran 599 of master's 665 tests (**−10%**)
   - PDS (Unit Tests): ran 37 of master's 41 tests (**−10%**)
   
   ❌ **1 blocker in 1 suite, all on 1 run:**
   - Snapshots 3 · `EncryptedSnapshotTest.testSnapshotRestoringFailsWithOtherMasterKey[encryption=true, onlyPrimay=false]` — 1 run · [TC](https://ci2.ignite.apache.org/buildConfiguration/IgniteTests24Java8_Snapshots3/9460523?buildTab=tests&expandedTest=build%3A%28id%3A9460523%29%2Cid%3A2000000016#testNameId3674332701100584386)
   
   <sub>**1 run**: the test ran once on this code and failed; a re-run of its suite confirms or clears it.</sub>
   
   <sub>What each label means and what to do: [verdict glossary](https://github.com/anton-vinogradov/ignite-pr-checker/blob/main/docs/features.md#verdict-glossary).</sub>
   
   ➡️ **Next:** fix the blockers, push, and run RunAll again — a `/run-all` comment here does it when PR commands are on in the checker's ⚙.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4056513832


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /** Query entities with the same value type but different key type are a conflict. */
+    @Test
+    public void testConflictingKeyTypesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+            "cacheName=%s, property=keyType, existingValue=%s, incomingValue=%s]",
+            CACHE_NAME, Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=keyType, existingValue=%s, incomingValue=%s]",
+            CACHE_NAME, Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);

Review Comment:
   Here 2 entities are merged because their key type is the same (null), so we end up having only one entity [null → Person]. Before my fix there no merging, but because the value type is the same (Person) the second entity was silently discarded. So the result is the same — only 1 entity [null → Person] . The only difference is that if you run this test on master, this assertion
   
   ```assertEquals(Person.class.getName(), entity.getValueType());```
   
   will fail, because after my fix all properties of both entities are merged, while now in master the second entity is just ignored.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "zstan (via GitHub)" <gi...@apache.org>.
zstan commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4046402088


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/index/DuplicateKeyValueClassesSelfTest.java:
##########
@@ -42,32 +48,51 @@ public class DuplicateKeyValueClassesSelfTest extends AbstractIndexingCommonTest
         grid(0).destroyCache(CACHE_NAME);
     }
 
-    /**
-     * Test duplicate key class.
-     *
-     * @throws Exception If failed.
-     */
+    /** Checks that the same key class can be used with different value classes. */
     @Test
-    public void testDuplicateKeyClass() throws Exception {
+    public void testDuplicateKeyClass() {
         CacheConfiguration ccfg = new CacheConfiguration()
             .setName(CACHE_NAME)
             .setIndexedTypes(UUID.class, Clazz1.class, UUID.class, Clazz2.class);
 
         grid(0).createCache(ccfg);
+
+        Collection<QueryEntity> entities = grid(0).context().cache().cacheConfiguration(CACHE_NAME).getQueryEntities();
+
+        assertEquals(2, entities.size());
+
+        Set<String> valTypes = new HashSet<>();
+
+        for (QueryEntity entity : entities) {
+            assertEquals(UUID.class.getName(), entity.getKeyType());
+
+            valTypes.add(entity.getValueType());
+        }
+
+        Set<String> expValTypes = new HashSet<>(Arrays.asList(Clazz1.class.getName(), Clazz2.class.getName()));
+
+        assertEquals(expValTypes, valTypes);
     }
 
     /**
-     * Test duplicate value class.
-     *
-     * @throws Exception If failed.
+     * Checks that conflicting key types configured for the same value class are rejected instead of silently
+     * discarding one of the query entity configurations.
      */
     @Test
-    public void testDuplicateValueClass() throws Exception {
+    public void testConflictingKeyTypesForSameValueClass() {
         CacheConfiguration ccfg = new CacheConfiguration()
-            .setName(CACHE_NAME)
-            .setIndexedTypes(UUID.class, Clazz1.class, String.class, Clazz1.class);
+            .setName(CACHE_NAME);
 
-        grid(0).createCache(ccfg);
+        String msg = String.format("Failed to merge query entities due to conflicting metadata " +

Review Comment:
   plz move this message into public string constant (QueryEntityMerger) and use it for comparison here.



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {

Review Comment:
   naming confusing, i see test for public API - this test - how it need\plan to work. I mean - one cache can contain numerous of different QueryEntity`s, isn`t it ? This test need to work perfectly well without your changes i suppose, if i\`m right - you need to rename it.



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {

Review Comment:
   ```suggestion
   @SuppressWarnings("ThrowableNotThrown")
   public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /** Query entities with the same value type but different key type are a conflict. */
+    @Test
+    public void testConflictingKeyTypesFail() {

Review Comment:
   may be you can remove this test at all in such a case ? : testConflictingKeyTypesForSameValueClass



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /** Query entities with the same value type but different key type are a conflict. */
+    @Test
+    public void testConflictingKeyTypesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+            "cacheName=%s, property=keyType, existingValue=%s, incomingValue=%s]",
+            CACHE_NAME, Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null

Review Comment:
   I suppose that in such case key type is implicitly derived from (.setFields(fields("id", Integer.class)).setKeyFieldName("id")) sequence. Thus i expect that near construction will fail but it didn\`t:
   
   ```
           QueryEntity first = new QueryEntity()
               .setValueType(Person.class.getName())
               .setFields(fields("id", Integer.class))
               .setKeyType(Long.class.getTypeName()) // <--
               .setKeyFieldName("id"); // keyType is null
   ```
   and looks like shit :
   
   ```
           QueryEntity first = new QueryEntity()
               .setValueType(Person.class.getName())
               .setFields(fields("id", Integer.class))
               .setKeyType(Long.class.getTypeName()) // <--
               .setKeyType(Integer.class.getTypeName()) // <-- so what type ? 
               .setKeyFieldName("id"); // keyType is null
   ```
   
   I understand that all such questions are also touch backward compatibility, but you also change public contract (bad contract of course) and if so - probably we also make clean all over here ?What do you think ?
   
   Also why it\`s a collision here, as for me there is 2 entry, first - with implicitly defined type and second with explicitly, i\`m wrong ?



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/persistence/IgnitePdsIndexingDefragmentationTest.java:
##########
@@ -195,7 +216,7 @@ private static void validateIndexes(IgniteEx node) throws Exception {
      */
     @Test
     public void testIndexingWithIntegerKey() throws Exception {
-        test(Function.identity());
+        test(Integer.class, Function.identity());

Review Comment:
   ```suggestion
           test(indexedKeyType, Function.identity());
   ```



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /** Query entities with the same value type but different key type are a conflict. */
+    @Test
+    public void testConflictingKeyTypesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+            "cacheName=%s, property=keyType, existingValue=%s, incomingValue=%s]",
+            CACHE_NAME, Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+                "cacheName=%s, property=keyType, existingValue=%s, incomingValue=%s]",
+            CACHE_NAME, Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);

Review Comment:
   I also think about : if before your fix first and second are not been merged = for access them we need 2 different sql queries and after PR all become work through one query - it\`s incompatible behavior with broken back compatibility



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4056493813


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/persistence/IgnitePdsIndexingDefragmentationTest.java:
##########
@@ -195,7 +216,7 @@ private static void validateIndexes(IgniteEx node) throws Exception {
      */
     @Test
     public void testIndexingWithIntegerKey() throws Exception {
-        test(Function.identity());
+        test(Integer.class, Function.identity());

Review Comment:
   `test()` expects `Class<T>`, so passing `Class<?>` will cause type mismatch. I changed `test()` so that `indexedKeyType` is used as the key type source directly



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4172030457


##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2484,6 +2478,29 @@ public CacheConfiguration<K, V> setIndexPath(String idxPath) {
         return S.toString(CacheConfiguration.class, this);
     }
 
+    /** */
+    private QueryEntity findQueryEntity(String valType) {
+        if (qryEntities == null)
+            return null;

Review Comment:
   Method has been rewritten.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180721588


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */

Review Comment:
   This test has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub) via notifications" <no...@ignite.apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4433827242


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/IgniteCacheAbstractQuerySelfTest.java:
##########
@@ -1186,7 +1219,7 @@ public void testTwoObjectsTextSearch() {
             new QueryEntity(String.class, ObjectValueOther.class)
         ));
 
-        IgniteCache<Object, Object> c = jcache(ignite(), conf, Object.class, Object.class);
+        IgniteCache<Object, Object> c = jcache(ignite(), conf, "Object-Object");

Review Comment:
   The existing `jcache(Ignite, CacheConfiguration, String)` overload only copies the configuration, sets the cache name, and gets or creates the cache. It does NOT modify query entities.
   
   `jcacheWithSpecifiedName(...)` additionally adds a query entity for the supplied key/value types if one is missing. Using it with `Object.class` here would add an unnecessary entity, so I would keep the existing call. Overload resolution is unambiguous with these three arguments.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub) via notifications" <no...@ignite.apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4433801817


##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfigurationInternalAccessor.java:
##########
@@ -0,0 +1,39 @@
+/*
+ * 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.configuration;
+
+import java.util.Collection;
+import org.apache.ignite.cache.QueryEntity;
+
+/** Provides internal access to {@link CacheConfiguration} implementation details. */
+public final class CacheConfigurationInternalAccessor {
+    /** */
+    private CacheConfigurationInternalAccessor() {
+        // No-op.
+    }
+
+    /**
+     * Replaces query entities without changing the query-entity configuration source.
+     *
+     * @param cfg Cache configuration.
+     * @param qryEntities Query entities.
+     */
+    public static void replaceQueryEntities(CacheConfiguration<?, ?> cfg, Collection<QueryEntity> qryEntities) {
+        cfg.replaceQueryEntities(qryEntities);
+    }
+}

Review Comment:
   Javadoc extended.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Support merging QueryEntity metadata in CacheConfiguration [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4056514592


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1202 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /** Query entities with different value types must not be merged. */
+    @Test
+    public void testDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /** Query entities with the same value type but different key type are a conflict. */
+    @Test
+    public void testConflictingKeyTypesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format("Failed to merge query entities due to conflicting metadata [" +
+            "cacheName=%s, property=keyType, existingValue=%s, incomingValue=%s]",
+            CACHE_NAME, Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null

Review Comment:
   This test basically repeats the logic of `testConflictingKeyTypesForSameValueTypeFail()`: if we have 2 entities with the same value type, they are a conflict (before the cahnges the second entity with the same value type was silently discarded). 
   
   But in this case we check that `QueryEntityMerger#merge` even though we do not explicitely set key type for the first entity. So when we start merging the key type of the first entity is null. Thus, we check that the `mergeKeyType()` helper in `QueryEntityMerger` works correctly by using `findKeyType()` and not `getKeyType()`, which would return null and do incorrect merging null + String → String.
   
   So the collision here happens not because key type was set implicitly for the first entity, but because two entities have the same valye type (`Person.class`).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180735058


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */
+    @Test
+    public void testDifferentPrecisionDefinitionsFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision1 = 50;
+        int precision2 = 100;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision1));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsPrecision[" + NAME_FIELD + ']', precision1, precision2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Scale definitions for different entity fields are merged. */
+    @Test
+    public void testScaleDefinitionsForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setFieldsScale(Collections.singletonMap("weight", 2))
+        ));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        int weightScale = entity.getFieldsScale().get("weight");
+        assertEquals(2, weightScale);
+
+        int heightScale = entity.getFieldsScale().get("height");
+        assertEquals(2, heightScale);
+    }
+
+    /** Different scale definitions for the same entity field are a conflict. */
+    @Test
+    public void testConflictingScaleDefinitionsFail() {
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        String heightField = "height";
+        int heightScale = 3;
+
+        QueryEntity configured = configuredEntity(AnnotatedPerson.class)
+            .setFieldsScale(Collections.singletonMap(heightField, heightScale));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldsScale[" + heightField + ']', 2, heightScale);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(configured)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Configured indexedTypes must not prevent configured composite index from being created. */
+    @Test
+    public void testCompositeIndexIsCreatedWithIndexedTypes() throws Exception {

Review Comment:
   No longer relevant, we decided to abandon the merge semantics.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180732090


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */

Review Comment:
   This test has been removed.



##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(keyFieldName);
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Different key field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingKeyFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "key1";
+        String key2 = "key2";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key1, Integer.class, key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFieldName(key1);
+        second.setKeyFieldName(key2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "keyFieldName", key1, key2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing value field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testValueFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Person.class, NAME_FIELD, String.class));
+
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Same value field names must be merged. */
+    @Test
+    public void testSameValueFieldNameIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String valFieldName = "value";
+
+        QueryEntity first = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(valFieldName, Integer.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(valFieldName);
+        second.setValueFieldName(valFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(valFieldName, entity.getValueFieldName());
+    }
+
+    /** Different value field names for the same query entity are a conflict. */
+    @Test
+    public void testConflictingValueFieldNamesFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String val1 = "value1";
+        String val2 = "value2";
+
+        QueryEntity first = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(val1, Person.class, val2, Person.class, NAME_FIELD, String.class));
+
+        first.setValueFieldName(val1);
+        second.setValueFieldName(val2);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "valueFieldName", val1, val2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Same field with the same type must be merged. */
+    @Test
+    public void testSameFieldWithSameTypeIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+        ccfg.setQueryEntities(Collections.singletonList(personEntity(fields(NAME_FIELD, String.class))));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, String.class.getName()),
+            entity.getFields()
+        );
+    }
+
+    /** Same field with different types is a conflict. */
+    @Test
+    public void testSameFieldWithDifferentTypesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, Integer.class));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "fieldType[" + NAME_FIELD + ']', String.class.getName(), Integer.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Different key fields for the same entity must be merged. */
+    @Test
+    public void testDifferentKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key1 = "id";
+        String key2 = "otherId";
+
+        QueryEntity first = personEntity(fields(key1, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key2, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key1));
+        second.setKeyFields(Collections.singleton(key2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(new LinkedHashSet<>(Arrays.asList(key1, key2)), entity.getKeyFields());
+    }
+
+    /** Same key fields for the same entity must be merged. */
+    @Test
+    public void testSameKeyFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Object, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String key = "id";
+
+        QueryEntity first = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(key, Integer.class, NAME_FIELD, String.class));
+
+        first.setKeyFields(Collections.singleton(key));
+        second.setKeyFields(Collections.singleton(key));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(Collections.singleton("id"), entity.getKeyFields());
+    }
+
+    /** NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(AGE_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        SqlTableColumnView ageCol = findColumn(cols, AGE_FIELD);
+
+        assertNotNull(nameCol);
+        assertNotNull(ageCol);
+
+        assertFalse(nameCol.nullable());
+        assertFalse(ageCol.nullable());
+    }
+
+    /** Same NOT NULL fields from annotation and explicit configuration must be merged. */
+    @Test
+    public void testSameNotNullFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, AnnotatedPerson> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        ccfg.setIndexedTypes(Integer.class, AnnotatedPerson.class);
+
+        ccfg.setQueryEntities(Collections.singletonList(
+            configuredEntity(AnnotatedPerson.class)
+                .setNotNullFields(Collections.singleton(NAME_FIELD))
+        ));
+
+        node.createCache(ccfg);
+
+        List<SqlTableColumnView> cols = cacheColumns(node);
+
+        SqlTableColumnView nameCol = findColumn(cols, NAME_FIELD);
+        assertNotNull(nameCol);
+
+        assertFalse(nameCol.nullable());
+    }
+
+    /** Aliases for different entity fields must be merged. */
+    @Test
+    public void testAliasesForDifferentFieldsAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String nameAlias = "name-alias";
+        String ageAlias = "age-alias";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, nameAlias));
+        second.setAliases(Collections.singletonMap(AGE_FIELD, ageAlias));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getAliases().size());
+
+        String actualNameAlias = entity.getAliases().get(NAME_FIELD);
+
+        assertNotNull(actualNameAlias);
+        assertTrue(nameAlias.equalsIgnoreCase(actualNameAlias));
+
+        String actualAgeAlias = entity.getAliases().get(AGE_FIELD);
+
+        assertNotNull(actualAgeAlias);
+        assertTrue(ageAlias.equalsIgnoreCase(actualAgeAlias));
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentAliasesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String alias1 = "alias1";
+        String alias2 = "alias2";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setAliases(Collections.singletonMap(NAME_FIELD, alias1));
+        second.setAliases(Collections.singletonMap(NAME_FIELD, alias2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "aliases[" + NAME_FIELD + ']', alias1, alias2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** Default field values for different fields must be merged. */
+    @Test
+    public void testDefaultFieldValuesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName = "John Doe";
+        int defAge = 30;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName));
+        second.setDefaultFieldValues(Collections.singletonMap(AGE_FIELD, defAge));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(2, entity.getDefaultFieldValues().size());
+
+        String actualDefName = (String)entity.getDefaultFieldValues().get(NAME_FIELD);
+        assertNotNull(actualDefName);
+
+        assertEquals(defName, actualDefName);
+
+        int actualDefAge = (int)entity.getDefaultFieldValues().get(AGE_FIELD);
+
+        assertEquals(defAge, actualDefAge);
+    }
+
+    /** Different aliases for the same field are a conflict. */
+    @Test
+    public void testDifferentDefaultValuesForSameFieldFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String defName1 = "NAME_A";
+        String defName2 = "NAME_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName1));
+        second.setDefaultFieldValues(Collections.singletonMap(NAME_FIELD, defName2));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "defaultFieldValues[" + NAME_FIELD + ']', defName1, defName2);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing precision definition in the first entity must be filled from the second entity. */
+    @Test
+    public void testPrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Equal precision definitions are merged. */
+    @Test
+    public void testSamePrecisionIsMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        int precision = 50;
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(NAME_FIELD, String.class));
+
+        first.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+        second.setFieldsPrecision(Collections.singletonMap(NAME_FIELD, precision));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(
+            Map.of(NAME_FIELD, precision),
+            entity.getFieldsPrecision()
+        );
+    }
+
+    /** Different precision definitions for the same field are a conflict. */

Review Comment:
   This test has been removed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub)" <gi...@apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4180729960


##########
modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/CacheConfigurationQueryEntityMergeTest.java:
##########
@@ -0,0 +1,1260 @@
+/*
+ * 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.processors.cache;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import javax.cache.CacheException;
+import org.apache.ignite.cache.QueryEntity;
+import org.apache.ignite.cache.QueryIndex;
+import org.apache.ignite.cache.QueryIndexType;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.configuration.IgniteConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.query.QueryEntityEx;
+import org.apache.ignite.internal.processors.query.QueryUtils;
+import org.apache.ignite.spi.systemview.view.SystemView;
+import org.apache.ignite.spi.systemview.view.sql.SqlIndexView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableColumnView;
+import org.apache.ignite.spi.systemview.view.sql.SqlTableView;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static org.apache.ignite.cluster.ClusterState.ACTIVE;
+import static org.apache.ignite.internal.processors.query.QueryEntityMerger.CONFLICT_MESSAGE_TEMPLATE;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBLS_VIEW;
+import static org.apache.ignite.internal.processors.query.schema.management.SchemaManager.SQL_TBL_COLS_VIEW;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests for merging QueryEntity metadata in CacheConfiguration. */
+@SuppressWarnings("ThrowableNotThrown")
+public class CacheConfigurationQueryEntityMergeTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CACHE_NAME = "query-entity-merge-cache";
+
+    /** */
+    private static final String COMPOSITE_IDX = "PERSON_NAME_AGE_IDX";
+
+    /** */
+    private static final String NAME_IDX = "EXPLICIT_NAME_IDX";
+
+    /** */
+    private static final String AGE_IDX = "EXPLICIT_AGE_IDX";
+
+    /** */
+    private static final String NAME_FIELD = "name";
+
+    /** */
+    private static final String AGE_FIELD = "age";
+
+    /** */
+    private boolean staticCfg;
+
+    /** {@inheritDoc} */
+    @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception {
+        IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName);
+
+        if (staticCfg)
+            cfg.setCacheConfiguration(new CacheConfiguration<>(CACHE_NAME));
+
+        return cfg;
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        stopAllGrids();
+
+        super.afterTest();
+    }
+
+    /**
+     * Query entities with the same key type but different value types are treated as different entities, are not
+     * viewed as a conflict and are not merged into one entity.
+     */
+    @Test
+    public void testQueryEntitiesWithSameKeyTypeAndDifferentValueTypesAreNotMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Object> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(AnnotatedPerson.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        Collection<QueryEntity> entities = entities(node);
+
+        assertEquals(2, entities.size());
+
+        for (Class<?> cls : List.of(Person.class, AnnotatedPerson.class))
+            assertTrue(entities.stream().anyMatch(e -> cls.getName().equals(e.getValueType())));
+    }
+
+    /**
+     * Verifies that query entities with the same value type but conflicting key types fail to merge instead of silently
+     * ignoring one of the entities.
+     */
+    @Test
+    public void testConflictingKeyTypesForSameValueTypeFail() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setKeyType(Integer.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        QueryEntity second = new QueryEntity()
+            .setKeyType(String.class.getName())
+            .setValueType(Person.class.getName())
+            .setFields(fields(NAME_FIELD, String.class));
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with conflicting effective key types cannot be merged.
+     * <p>
+     * The first entity does not define {@code keyType} explicitly. Instead, its effective key type is derived from
+     * {@code keyFieldName} and the corresponding field type. The second entity defines a different key type explicitly.
+     * <p>
+     * Although {@link QueryEntity#getKeyType()} returns {@code null} for the first entity,
+     * {@link QueryEntity#findKeyType()} resolves its key type from the field metadata. Therefore, the entities must be
+     * treated as having conflicting key types.
+     */
+    @Test
+    public void testConflictingImplicitAndExplicitKeyTypes() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setFields(fields("id", Integer.class))
+            .setKeyFieldName("id"); // keyType is null
+
+        QueryEntity second = new QueryEntity()
+            .setValueType(Person.class.getName())
+            .setKeyType(String.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE,
+            CACHE_NAME, "keyType", Integer.class.getName(), String.class.getName());
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singleton(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /**
+     * Checks that query entities with matching effective value types can be merged when one value type is defined
+     * implicitly and the other one explicitly.
+     * <p>
+     * The first entity does not define {@code valueType} explicitly. Its effective value type is derived from
+     * {@code valueFieldName} and the corresponding field type, so {@link QueryEntity#getValueType()} returns
+     * {@code null}, while {@link QueryEntity#findValueType()} resolves it to the person type.
+     * <p>
+     * The second entity defines the same value type explicitly. Since both entities have the same effective value
+     * type, they must be merged without a conflict.
+     */
+    @Test
+    public void testMatchingImplicitAndExplicitValueTypesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        QueryEntity first = new QueryEntity()
+            .setFields(fields("val", Person.class))
+            .setValueFieldName("val"); // valueType is null
+
+        QueryEntity second = new QueryEntity().setValueType(Person.class.getName());
+
+        ccfg.setQueryEntities(Collections.singleton(first));
+        ccfg.setQueryEntities(Collections.singleton(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals("val", entity.getValueFieldName());
+        assertEquals(Person.class.getName(), entity.getFields().get("val"));
+        assertEquals(Person.class.getName(), entity.getValueType());
+    }
+
+    /** A missing table name in the first entity must be filled from the second entity. */
+    @Test
+    public void testTableNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(null);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Same table names for one query entity are merged. */
+    @Test
+    public void testSameTableNamesAreMerged() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tblName = "PERSON";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tblName);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tblName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        SqlTableView tbl = cacheTable(node);
+        assertNotNull(tbl);
+
+        assertEquals(tblName, tbl.tableName());
+    }
+
+    /** Different configured table names are a conflict. */
+    @Test
+    public void testConflictingTableNamesFails() {
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String tableA = "TABLE_A";
+        String tableB = "TABLE_B";
+
+        QueryEntity first = personEntity(fields(NAME_FIELD, String.class));
+        first.setTableName(tableA);
+
+        QueryEntity second = personEntity(fields(AGE_FIELD, Integer.class));
+        second.setTableName(tableB);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+
+        String msg = String.format(CONFLICT_MESSAGE_TEMPLATE, CACHE_NAME, "tableName", tableA, tableB);
+
+        assertThrows(
+            log,
+            () -> ccfg.setQueryEntities(Collections.singletonList(second)),
+            CacheException.class,
+            msg
+        );
+    }
+
+    /** A missing key field name in the first entity must be filled from the second entity. */
+    @Test
+    public void testKeyFieldNameIsFilledFromSecondEntity() throws Exception {
+        IgniteEx node = startGrid(0);
+
+        CacheConfiguration<Integer, Person> ccfg = new CacheConfiguration<>(CACHE_NAME);
+
+        String keyFieldName = "key";
+
+        QueryEntity first = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+        QueryEntity second = personEntity(fields(keyFieldName, Integer.class, NAME_FIELD, String.class));
+
+        second.setKeyFieldName(keyFieldName);
+
+        ccfg.setQueryEntities(Collections.singletonList(first));
+        ccfg.setQueryEntities(Collections.singletonList(second));
+
+        node.createCache(ccfg);
+
+        QueryEntity entity = singleQueryEntity(node);
+        assertNotNull(entity);
+
+        assertEquals(keyFieldName, entity.getKeyFieldName());
+    }
+
+    /** Same key field names must be merged. */
+    @Test
+    public void testSameKeyFieldNameIsMerged() throws Exception {

Review Comment:
   No longer relevant, we decided to abandon the merge semantics.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub) via notifications" <no...@ignite.apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4433803352


##########
modules/spring/src/test/java/org/apache/ignite/internal/CacheConfigurationQueryEntityXmlTest.java:
##########
@@ -0,0 +1,80 @@
+/*
+ * 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;
+
+import javax.cache.CacheException;
+import org.apache.ignite.IgniteException;
+import org.apache.ignite.Ignition;
+import org.apache.ignite.internal.util.typedef.X;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+import org.springframework.beans.PropertyAccessException;
+import org.springframework.beans.PropertyBatchUpdateException;
+
+import static org.apache.ignite.configuration.CacheConfiguration.MIXED_QUERY_ENTITIES_API_ERROR_TEMPLATE;
+import static org.apache.ignite.testframework.GridTestUtils.assertThrows;
+
+/** Tests that Spring XML configuration cannot mix indexed types and explicit query entities. */
+public class CacheConfigurationQueryEntityXmlTest extends GridCommonAbstractTest {
+    /** */
+    private static final String CONFIG_DIR = "modules/spring/src/test/config/query-entities/";

Review Comment:
   Added path resolution via `resolveIgnitePath()`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


Re: [PR] IGNITE-28907 Disallow mixing query entity configuration APIs and use last-call-wins semantics [ignite]

Posted by "oleg-vlsk (via GitHub) via notifications" <no...@ignite.apache.org>.
oleg-vlsk commented on code in PR #13583:
URL: https://github.com/apache/ignite/pull/13583#discussion_r4433799825


##########
modules/core/src/main/java/org/apache/ignite/configuration/CacheConfiguration.java:
##########
@@ -2075,42 +2090,36 @@ public CacheConfiguration<K, V> setPartitionLossPolicy(PartitionLossPolicy partL
 
     /**
      * Sets query entities configuration.
+     * <p>
+     * This method must not be used together with {@link #setIndexedTypes}.
+     * <p>
+     * Repeated calls replace the query entities configured by the previous call.
+     * Passing an empty collection clears the existing query entities.
      *
      * @param qryEntities Query entities.
      * @return {@code this} for chaining.
+     * @throws CacheException If query entities have already been configured through {@link #setIndexedTypes}.
      */
     public CacheConfiguration<K, V> setQueryEntities(Collection<QueryEntity> qryEntities) {
-        if (this.qryEntities == null) {
-            this.qryEntities = new ArrayList<>(qryEntities);
-
-            return this;
-        }
+        checkQueryEntityConfigurationSource(QueryEntityConfigurationSource.QUERY_ENTITIES);
 
-        for (QueryEntity entity : qryEntities) {
-            boolean found = false;
+        this.qryEntities = new ArrayList<>(qryEntities);
 
-            for (QueryEntity existing : this.qryEntities) {
-                if (Objects.equals(entity.findValueType(), existing.findValueType())) {
-                    found = true;
-
-                    break;
-                }
-            }
-
-            if (!found)
-                this.qryEntities.add(entity);
-        }
+        qryEntityCfgSrc = QueryEntityConfigurationSource.QUERY_ENTITIES;
 
         return this;
     }

Review Comment:
   Added fallback to an empty collection for the `null` argument.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@ignite.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org