diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/hint/LeadingHint.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/hint/LeadingHint.java index 7f67311204d349..8ffb9a77559e9f 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/hint/LeadingHint.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/hint/LeadingHint.java @@ -624,7 +624,13 @@ private Long getBitmap(LogicalPlan root) { } else if (root instanceof LogicalSubQueryAlias) { return LongBitmap.set(0L, (((LogicalSubQueryAlias) root).getRelationId().asInt())); } else { - return null; + Set inputRelations = root.getInputRelations(); + if (inputRelations.size() != 1) { + return null; + } + // the leaf could be a plan which is built on one relation, e.g. the row policy filter, the data + // mask project or the aggregate which is generated for the random distribution aggregate table + return LongBitmap.set(0L, inputRelations.iterator().next().asInt()); } } } diff --git a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectJoinConstraint.java b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectJoinConstraint.java index b8b6d8adfabc37..56e6b6f72350d4 100644 --- a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectJoinConstraint.java +++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/CollectJoinConstraint.java @@ -30,8 +30,7 @@ import org.apache.doris.nereids.trees.plans.JoinType; import org.apache.doris.nereids.trees.plans.RelationId; import org.apache.doris.nereids.trees.plans.logical.LogicalJoin; -import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan; -import org.apache.doris.nereids.trees.plans.logical.LogicalProject; +import org.apache.doris.nereids.trees.plans.logical.LogicalPlan; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; @@ -54,6 +53,8 @@ public List buildRules() { LeadingHint leading = (LeadingHint) ctx.cascadesContext .getHintMap().get("Leading"); LogicalJoin join = ctx.root; + collectLeafPlan(leading, (LogicalPlan) join.left()); + collectLeafPlan(leading, (LogicalPlan) join.right()); if (join.getJoinType().isNullAwareLeftAntiJoin()) { leading.setStatus(Hint.HintStatus.UNUSED); leading.setErrorMessage("condition does not matched joinType"); @@ -90,24 +91,30 @@ public List buildRules() { collectJoinConstraintList(leading, leftHand, rightHand, join, totalFilterBitMap, nonNullableSlotBitMap); return ctx.root; - }).toRule(RuleType.COLLECT_JOIN_CONSTRAINT), - - logicalProject(logicalOlapScan()).thenApply( - ctx -> { - if (!ctx.cascadesContext.isLeadingJoin()) { - return ctx.root; - } - LeadingHint leading = (LeadingHint) ctx.cascadesContext - .getHintMap().get("Leading"); - LogicalProject project = ctx.root; - LogicalOlapScan scan = project.child(); - leading.getRelationIdToScanMap().put(scan.getRelationId(), project); - return ctx.root; - } - ).toRule(RuleType.COLLECT_JOIN_CONSTRAINT) + }).toRule(RuleType.COLLECT_JOIN_CONSTRAINT) ); } + /** + * Remember the whole plan below one side of the join, so that the leading hint can rebuild the join + * with exactly the same leaves. The plan could be the relation itself, or the plans which are built + * on the relation by the previous analysis rules, e.g. the row policy / data mask filter which is + * materialized by CheckPolicy. If only the relation is remembered, these plans are dropped silently + * once the join is rebuilt from them. + */ + private void collectLeafPlan(LeadingHint leading, LogicalPlan child) { + Set inputRelations = child.getInputRelations(); + if (inputRelations.size() != 1) { + // the child is built on multiple relations, e.g. a join, its own join node is processed separately + return; + } + RelationId relationId = inputRelations.iterator().next(); + if (relationId == null) { + return; + } + leading.getRelationIdToScanMap().put(relationId, child); + } + private void collectJoinConstraintList(LeadingHint leading, Long leftHand, Long rightHand, LogicalJoin join, Long filterTableBitMap, Long nonNullableSlotBitMap) { Long totalTables = LongBitmap.or(leftHand, rightHand); diff --git a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/LeadingHintRowPolicyTest.java b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/LeadingHintRowPolicyTest.java new file mode 100644 index 00000000000000..d5c5b95af9cfd0 --- /dev/null +++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/analysis/LeadingHintRowPolicyTest.java @@ -0,0 +1,195 @@ +// 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.doris.nereids.rules.analysis; + +import org.apache.doris.analysis.TablePattern; +import org.apache.doris.analysis.UserDesc; +import org.apache.doris.analysis.UserIdentity; +import org.apache.doris.catalog.AccessPrivilege; +import org.apache.doris.catalog.AccessPrivilegeWithCols; +import org.apache.doris.catalog.Env; +import org.apache.doris.common.FeConstants; +import org.apache.doris.common.jmockit.Deencapsulation; +import org.apache.doris.mysql.privilege.AccessControllerManager; +import org.apache.doris.mysql.privilege.DataMaskPolicy; +import org.apache.doris.nereids.trees.plans.Plan; +import org.apache.doris.nereids.trees.plans.commands.CreateUserCommand; +import org.apache.doris.nereids.trees.plans.commands.GrantTablePrivilegeCommand; +import org.apache.doris.nereids.trees.plans.commands.info.CreateUserInfo; +import org.apache.doris.nereids.trees.plans.logical.LogicalFilter; +import org.apache.doris.nereids.trees.plans.logical.LogicalJoin; +import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan; +import org.apache.doris.nereids.trees.plans.logical.LogicalProject; +import org.apache.doris.nereids.util.PlanChecker; +import org.apache.doris.utframe.TestWithFeService; + +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * The leading hint rebuilds the join from the plans which are remembered in the analysis phase, so the plans + * which are built on the table relation, e.g. the row policy filter and the data mask project, must be + * remembered together with the relation. Otherwise they are dropped silently and the user can read the rows + * which are protected by the row policy. + */ +public class LeadingHintRowPolicyTest extends TestWithFeService { + + private static final String DB_NAME = "leading_hint_row_policy"; + private static final String TABLE_1 = "leading_hint_t1"; + private static final String TABLE_2 = "leading_hint_t2"; + private static final String MASKED_TABLE = "leading_hint_masked"; + private static final String USER_NAME = "leading_hint_user"; + private static final String POLICY_NAME = "leading_hint_policy"; + + @Override + protected void runBeforeAll() throws Exception { + FeConstants.runningUnitTest = true; + createDatabase(DB_NAME); + useDatabase(DB_NAME); + createTable("create table " + TABLE_1 + " (k int, v int) distributed by hash(k) buckets 1" + + " properties(\"replication_num\" = \"1\");"); + createTable("create table " + TABLE_2 + " (k int, v int) distributed by hash(k) buckets 1" + + " properties(\"replication_num\" = \"1\");"); + createTable("create table " + MASKED_TABLE + " (k int, v int) distributed by hash(k) buckets 1" + + " properties(\"replication_num\" = \"1\");"); + + // create user and grant privilege, so that the row policy and the data mask policy can be evaluated + UserIdentity user = new UserIdentity(USER_NAME, "%"); + user.analyze(); + CreateUserCommand createUserCommand = new CreateUserCommand(new CreateUserInfo(new UserDesc(user))); + createUserCommand.getInfo().validate(); + Env.getCurrentEnv().getAuth().createUser(createUserCommand.getInfo()); + List privileges = Lists + .newArrayList(new AccessPrivilegeWithCols(AccessPrivilege.ADMIN_PRIV)); + TablePattern tablePattern = new TablePattern("*", "*", "*"); + tablePattern.analyze(); + GrantTablePrivilegeCommand grantTablePrivilegeCommand = new GrantTablePrivilegeCommand( + privileges, tablePattern, Optional.of(user), Optional.empty()); + grantTablePrivilegeCommand.validate(); + Env.getCurrentEnv().getAuth().grantTablePrivilegeCommand(grantTablePrivilegeCommand); + + // the data mask policy is provided by the external auth plugin, mock it for the masked table + AccessControllerManager spyAcm = Mockito.spy(Env.getCurrentEnv().getAccessManager()); + // Masks are asked for one column at a time, keyed by the lower-cased column name - that is the + // shape the planner asks in and reads back, so the per-column method is stubbed. + Mockito.doAnswer(invocation -> { + String tbl = invocation.getArgument(3); + String col = invocation.getArgument(4); + if (!tbl.equalsIgnoreCase(MASKED_TABLE)) { + return Optional.empty(); + } + String column = col.toLowerCase(Locale.ROOT); + return Optional.of(new DataMaskPolicy() { + @Override + public String getMaskTypeDef() { + return String.format("concat(%s, '_****_', %s)", column, column); + } + + @Override + public String getPolicyIdent() { + return String.format("custom policy: concat(%s, '_****_', %s)", column, column); + } + }); + }).when(spyAcm).evalDataMaskPolicy( + Mockito.any(UserIdentity.class), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString(), Mockito.anyString()); + Deencapsulation.setField(Env.getCurrentEnv(), "accessManager", spyAcm); + } + + @Test + public void testRowPolicyIsKeptByLeadingHint() throws Exception { + useUser(USER_NAME); + createPolicy("CREATE ROW POLICY " + POLICY_NAME + " ON " + TABLE_1 + + " AS RESTRICTIVE TO " + USER_NAME + " USING (k = 1)"); + + // the hint reverses the join order, so the rebuilt join proves that the hint is really applied + PlanChecker planChecker = PlanChecker.from(connectContext) + .analyze("SELECT /*+ leading(" + TABLE_2 + " " + TABLE_1 + ") */ " + + TABLE_1 + ".k, " + TABLE_1 + ".v, " + TABLE_2 + ".v FROM " + + TABLE_1 + " JOIN " + TABLE_2 + " ON " + TABLE_1 + ".k = " + TABLE_2 + ".k"); + Assertions.assertTrue(planChecker.getCascadesContext().getHintMap().get("Leading").isSuccess()); + Plan plan = planChecker.getPlan(); + + LogicalJoin join = findJoin(plan); + Assertions.assertNotNull(join, () -> "join is missing in plan:\n" + plan.treeString()); + Assertions.assertInstanceOf(LogicalOlapScan.class, join.left(), + () -> "unexpected join order of leading hint:\n" + plan.treeString()); + Assertions.assertEquals(TABLE_2, ((LogicalOlapScan) join.left()).getTable().getName()); + Assertions.assertInstanceOf(LogicalFilter.class, join.right(), + () -> "row policy filter is dropped by leading hint:\n" + plan.treeString()); + LogicalFilter policyFilter = (LogicalFilter) join.right(); + Assertions.assertEquals(1, policyFilter.getConjuncts().size()); + Assertions.assertTrue(policyFilter.getConjuncts().toString().contains("= 1"), + () -> "unexpected row policy filter: " + policyFilter.getConjuncts()); + Assertions.assertInstanceOf(LogicalOlapScan.class, policyFilter.child()); + Assertions.assertEquals(TABLE_1, ((LogicalOlapScan) policyFilter.child()).getTable().getName()); + + dropPolicy("DROP ROW POLICY " + POLICY_NAME + " ON " + TABLE_1); + } + + @Test + public void testRowPolicyAndDataMaskAreKeptByLeadingHint() throws Exception { + useUser(USER_NAME); + createPolicy("CREATE ROW POLICY " + POLICY_NAME + " ON " + MASKED_TABLE + + " AS RESTRICTIVE TO " + USER_NAME + " USING (k = 1)"); + + PlanChecker planChecker = PlanChecker.from(connectContext) + .analyze("SELECT /*+ leading(" + TABLE_2 + " " + MASKED_TABLE + ") */ " + + MASKED_TABLE + ".k, " + MASKED_TABLE + ".v, " + TABLE_2 + ".v FROM " + + MASKED_TABLE + " JOIN " + TABLE_2 + " ON " + MASKED_TABLE + ".k = " + TABLE_2 + ".k"); + Assertions.assertTrue(planChecker.getCascadesContext().getHintMap().get("Leading").isSuccess()); + Plan plan = planChecker.getPlan(); + + // both the data mask project and the row policy filter are kept on the leaf of the leading hint + LogicalJoin join = findJoin(plan); + Assertions.assertNotNull(join, () -> "join is missing in plan:\n" + plan.treeString()); + Assertions.assertInstanceOf(LogicalProject.class, join.right(), + () -> "data mask project is dropped by leading hint:\n" + plan.treeString()); + Plan policyLeaf = join.right().child(0); + Assertions.assertInstanceOf(LogicalFilter.class, policyLeaf, + () -> "row policy filter is dropped by leading hint:\n" + plan.treeString()); + LogicalFilter policyFilter = (LogicalFilter) policyLeaf; + Assertions.assertEquals(1, policyFilter.getConjuncts().size()); + Assertions.assertTrue(policyFilter.getConjuncts().toString().contains("= 1"), + () -> "unexpected row policy filter: " + policyFilter.getConjuncts()); + Assertions.assertInstanceOf(LogicalOlapScan.class, policyFilter.child()); + Assertions.assertEquals(MASKED_TABLE, + ((LogicalOlapScan) policyFilter.child()).getTable().getName()); + + dropPolicy("DROP ROW POLICY " + POLICY_NAME + " ON " + MASKED_TABLE); + } + + private LogicalJoin findJoin(Plan plan) { + if (plan instanceof LogicalJoin) { + return (LogicalJoin) plan; + } + for (Plan child : plan.children()) { + LogicalJoin join = findJoin(child); + if (join != null) { + return join; + } + } + return null; + } +} diff --git a/regression-test/data/nereids_p0/hint/test_leading_row_policy.out b/regression-test/data/nereids_p0/hint/test_leading_row_policy.out new file mode 100644 index 00000000000000..2d60899c86be4f --- /dev/null +++ b/regression-test/data/nereids_p0/hint/test_leading_row_policy.out @@ -0,0 +1,22 @@ +-- This file is automatically generated. You should know what you did if you want to edit this +-- !read_table -- +1 10 + +-- !join_without_hint -- +1 10 100 + +-- !join_with_leading -- +1 10 100 + +-- !join_with_leading_swapped -- +1 10 100 + +-- !left_join_with_leading -- +1 10 100 + +-- !agg_table_without_hint -- +1 40 100 + +-- !agg_table_with_leading -- +1 40 100 + diff --git a/regression-test/suites/nereids_p0/hint/test_leading_row_policy.groovy b/regression-test/suites/nereids_p0/hint/test_leading_row_policy.groovy new file mode 100644 index 00000000000000..c1705941b9b732 --- /dev/null +++ b/regression-test/suites/nereids_p0/hint/test_leading_row_policy.groovy @@ -0,0 +1,173 @@ +// 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. + +suite("test_leading_row_policy") { + String dbName = context.config.getDbNameByFile(context.file) + String user = "leading_row_policy_user" + String pwd = 'C123_567p' + def tokens = context.config.jdbcUrl.split('/') + def url = tokens[0] + "//" + tokens[2] + "/" + dbName + "?" + + sql "DROP ROW POLICY IF EXISTS leading_row_policy ON ${dbName}.leading_row_policy_t1 FOR ${user}" + sql "DROP ROW POLICY IF EXISTS leading_row_policy_agg ON ${dbName}.leading_row_policy_agg FOR ${user}" + sql "DROP TABLE IF EXISTS leading_row_policy_t1" + sql "DROP TABLE IF EXISTS leading_row_policy_t2" + sql "DROP TABLE IF EXISTS leading_row_policy_agg" + sql """ + CREATE TABLE leading_row_policy_t1 ( + `k` INT, + `v` INT + ) DUPLICATE KEY (`k`) DISTRIBUTED BY HASH (`k`) BUCKETS 1 + PROPERTIES ('replication_num' = '1') + """ + sql """ + CREATE TABLE leading_row_policy_t2 ( + `k` INT, + `v` INT + ) DUPLICATE KEY (`k`) DISTRIBUTED BY HASH (`k`) BUCKETS 1 + PROPERTIES ('replication_num' = '1') + """ + // the aggregate table with random distribution needs an aggregation above the scan to merge the rows + sql """ + CREATE TABLE leading_row_policy_agg ( + `k` INT, + `v` INT SUM + ) AGGREGATE KEY (`k`) DISTRIBUTED BY RANDOM BUCKETS 1 + PROPERTIES ('replication_num' = '1') + """ + sql "INSERT INTO leading_row_policy_t1 VALUES (1, 10), (2, 20)" + sql "INSERT INTO leading_row_policy_t2 VALUES (1, 100), (2, 200)" + sql "INSERT INTO leading_row_policy_agg VALUES (1, 10), (1, 30), (2, 20)" + + sql "DROP USER IF EXISTS ${user}" + sql "CREATE USER ${user} IDENTIFIED BY '${pwd}'" + sql "GRANT SELECT_PRIV ON internal.${dbName}.leading_row_policy_t1 TO ${user}" + sql "GRANT SELECT_PRIV ON internal.${dbName}.leading_row_policy_t2 TO ${user}" + sql "GRANT SELECT_PRIV ON internal.${dbName}.leading_row_policy_agg TO ${user}" + //cloud-mode + // a cloud user is only allowed to use the compute groups it has the usage privilege of, without it the + // connection is rejected with CURRENT_USER_NO_AUTH_TO_USE_ANY_COMPUTE_GROUP before the first query runs + if (isCloudMode()) { + def clusters = sql " SHOW CLUSTERS; " + assertTrue(!clusters.isEmpty()) + def validCluster = clusters[0][0] + sql """GRANT USAGE_PRIV ON CLUSTER `${validCluster}` TO ${user}"""; + } + sql """ + CREATE ROW POLICY leading_row_policy ON ${dbName}.leading_row_policy_t1 + AS RESTRICTIVE TO ${user} USING (k = 1) + """ + sql """ + CREATE ROW POLICY leading_row_policy_agg ON ${dbName}.leading_row_policy_agg + AS RESTRICTIVE TO ${user} USING (k = 1) + """ + sql "SYNC" + + // The tables are referenced by their real name, an alias would add a sub query alias node which already + // carries the whole plan of the table, so the cases below have to exercise the plans which are built + // directly on the relation, e.g. the row policy filter. + connect(user, "${pwd}", url) { + sql "SET enable_sql_cache = false" + // the row policy only allows to read the rows of leading_row_policy_t1 with k = 1 + order_qt_read_table "SELECT k, v FROM leading_row_policy_t1 ORDER BY k" + // the row policy also applies without any hint + order_qt_join_without_hint """ + SELECT leading_row_policy_t1.k, leading_row_policy_t1.v, leading_row_policy_t2.v + FROM leading_row_policy_t1 JOIN leading_row_policy_t2 + ON leading_row_policy_t1.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_t1.k + """ + // the leading hint has to be accepted, otherwise the cases below exercise nothing + explain { + sql """ + SELECT /*+ leading(leading_row_policy_t1 leading_row_policy_t2) */ + leading_row_policy_t1.k, leading_row_policy_t1.v, leading_row_policy_t2.v + FROM leading_row_policy_t1 JOIN leading_row_policy_t2 + ON leading_row_policy_t1.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_t1.k + """ + contains("Used: leading(leading_row_policy_t1 leading_row_policy_t2 )") + } + // a leading hint only changes the join order, it must not change the rows allowed by the row policy + order_qt_join_with_leading """ + SELECT /*+ leading(leading_row_policy_t1 leading_row_policy_t2) */ + leading_row_policy_t1.k, leading_row_policy_t1.v, leading_row_policy_t2.v + FROM leading_row_policy_t1 JOIN leading_row_policy_t2 + ON leading_row_policy_t1.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_t1.k + """ + explain { + sql """ + SELECT /*+ leading(leading_row_policy_t2 leading_row_policy_t1) */ + leading_row_policy_t1.k, leading_row_policy_t1.v, leading_row_policy_t2.v + FROM leading_row_policy_t1 JOIN leading_row_policy_t2 + ON leading_row_policy_t1.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_t1.k + """ + contains("Used: leading(leading_row_policy_t2 leading_row_policy_t1 )") + } + order_qt_join_with_leading_swapped """ + SELECT /*+ leading(leading_row_policy_t2 leading_row_policy_t1) */ + leading_row_policy_t1.k, leading_row_policy_t1.v, leading_row_policy_t2.v + FROM leading_row_policy_t1 JOIN leading_row_policy_t2 + ON leading_row_policy_t1.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_t1.k + """ + explain { + sql """ + SELECT /*+ leading(leading_row_policy_t1 leading_row_policy_t2) */ + leading_row_policy_t1.k, leading_row_policy_t1.v, leading_row_policy_t2.v + FROM leading_row_policy_t1 LEFT JOIN leading_row_policy_t2 + ON leading_row_policy_t1.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_t1.k + """ + contains("Used: leading(leading_row_policy_t1 leading_row_policy_t2 )") + } + order_qt_left_join_with_leading """ + SELECT /*+ leading(leading_row_policy_t1 leading_row_policy_t2) */ + leading_row_policy_t1.k, leading_row_policy_t1.v, leading_row_policy_t2.v + FROM leading_row_policy_t1 LEFT JOIN leading_row_policy_t2 + ON leading_row_policy_t1.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_t1.k + """ + // the random distribution aggregate table needs the aggregation which merges the rows of the table, + // both the aggregation and the row policy are built on the relation and have to be kept as well + explain { + sql """ + SELECT /*+ leading(leading_row_policy_agg leading_row_policy_t2) */ + leading_row_policy_agg.k, leading_row_policy_agg.v, leading_row_policy_t2.v + FROM leading_row_policy_agg JOIN leading_row_policy_t2 + ON leading_row_policy_agg.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_agg.k + """ + contains("Used: leading(leading_row_policy_agg leading_row_policy_t2 )") + } + order_qt_agg_table_without_hint """ + SELECT leading_row_policy_agg.k, leading_row_policy_agg.v, leading_row_policy_t2.v + FROM leading_row_policy_agg JOIN leading_row_policy_t2 + ON leading_row_policy_agg.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_agg.k + """ + order_qt_agg_table_with_leading """ + SELECT /*+ leading(leading_row_policy_agg leading_row_policy_t2) */ + leading_row_policy_agg.k, leading_row_policy_agg.v, leading_row_policy_t2.v + FROM leading_row_policy_agg JOIN leading_row_policy_t2 + ON leading_row_policy_agg.k = leading_row_policy_t2.k + ORDER BY leading_row_policy_agg.k + """ + } +}