This is an automated email from the ASF dual-hosted git repository.
yashmayya pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new 7701cfd9237 Build the lookup join key from the dimension table primary
key in MSE (#19210)
7701cfd9237 is described below
commit 7701cfd92374fd22a0ffe85f5fcc3b49a8f04669
Author: Yash Mayya <[email protected]>
AuthorDate: Tue Aug 18 00:35:14 2026 -0400
Build the lookup join key from the dimension table primary key in MSE
(#19210)
---
.../query/runtime/operator/LookupJoinOperator.java | 232 +++++++++++++++++--
.../runtime/operator/LookupJoinOperatorTest.java | 247 +++++++++++++++++++++
.../runtime/queries/ResourceBasedQueriesTest.java | 58 +++--
.../src/test/resources/queries/LookupJoin.json | 167 ++++++++++++++
4 files changed, 669 insertions(+), 35 deletions(-)
diff --git
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LookupJoinOperator.java
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LookupJoinOperator.java
index 087f8dd08e5..4debec7e08b 100644
---
a/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LookupJoinOperator.java
+++
b/pinot-query-runtime/src/main/java/org/apache/pinot/query/runtime/operator/LookupJoinOperator.java
@@ -18,14 +18,19 @@
*/
package org.apache.pinot.query.runtime.operator;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import java.util.Set;
import javax.annotation.Nullable;
import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.commons.collections4.CollectionUtils;
import org.apache.pinot.common.datatable.StatMap;
import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
import org.apache.pinot.core.data.manager.offline.DimensionTableDataManager;
import org.apache.pinot.core.query.request.ServerQueryRequest;
import org.apache.pinot.core.query.request.context.QueryContext;
@@ -53,17 +58,32 @@ import org.slf4j.LoggerFactory;
///
/// Since right table is a dimension table which is replicated across all
servers, RIGHT and FULL join are not
/// supported to avoid duplication.
+///
+/// The dimension table is a hash map keyed by the primary key values, so the
lookup key must contain one value per
+/// primary key column, in the order the dimension table schema declares them.
The join condition does not provide the
+/// key in that shape:
+///
+/// - The equi-join keys are ordered by the join condition, not by the primary
key.
+/// - A primary key column can be constrained by a constant (`dim.col = 'x'`)
instead of by an equi-join key. Calcite
+/// classifies such a condition as a non-equi condition, so it is absent
from the join keys.
+///
+/// The constructor therefore compiles a key plan that maps every primary key
column to its value source, and rejects
+/// join conditions that cannot produce a complete key. See [#compileKeyPlan].
public class LookupJoinOperator extends MultiStageOperator {
private static final Logger LOGGER =
LoggerFactory.getLogger(LookupJoinOperator.class);
private static final String EXPLAIN_NAME = "LOOKUP_JOIN";
private static final Set<JoinRelType> SUPPORTED_JOIN_TYPES =
Set.of(JoinRelType.INNER, JoinRelType.LEFT, JoinRelType.SEMI,
JoinRelType.ANTI);
+ /// Marks a key position that no join condition binds. It is an error for
one to survive [#compileKeyPlan].
+ private static final int KEY_SOURCE_UNBOUND = -1;
+ /// Marks a key position whose value is a constant held in [#_keyConstants].
+ private static final int KEY_SOURCE_CONSTANT = -2;
+
private final MultiStageOperator _leftInput;
private final int _leftColumnSize;
private final LeafOperator _rightInput;
private final JoinRelType _joinType;
- private final int[] _leftKeyIds;
private final DimensionTableDataManager _rightTable;
private final String[] _rightColumns;
private final DataSchema _resultSchema;
@@ -71,6 +91,16 @@ public class LookupJoinOperator extends MultiStageOperator {
private final List<TransformOperand> _nonEquiEvaluators;
private final StatMap<StatKey> _statMap = new StatMap<>(StatKey.class);
+ /// Number of primary key columns of the dimension table, i.e. the size of
every lookup key.
+ private final int _keySize;
+ /// Value source of each key position: a left row index, or
[#KEY_SOURCE_CONSTANT]. [#compileKeyPlan] rejects the
+ /// plan if any position is still [#KEY_SOURCE_UNBOUND], so that value never
reaches the probe path.
+ private final int[] _keySources;
+ /// Constant value of each key position whose source is
[#KEY_SOURCE_CONSTANT].
+ private final Object[] _keyConstants;
+ /// Set when a constant key value is null. A null never matches a primary
key, so every lookup misses.
+ private final boolean _neverMatches;
+
public LookupJoinOperator(OpChainExecutionContext context,
MultiStageOperator leftInput, DataSchema leftSchema,
MultiStageOperator rightInput, JoinNode node) {
super(context);
@@ -82,11 +112,6 @@ public class LookupJoinOperator extends MultiStageOperator {
Preconditions.checkState(SUPPORTED_JOIN_TYPES.contains(_joinType), "Join
type: % is not supported for lookup join",
_joinType);
- List<Integer> leftKeys = node.getLeftKeys();
- _leftKeyIds = new int[leftKeys.size()];
- for (int i = 0; i < leftKeys.size(); i++) {
- _leftKeyIds[i] = leftKeys.get(i);
- }
List<ServerQueryRequest> leafStageRequests = _rightInput.getRequests();
Preconditions.checkState(leafStageRequests.size() == 1, "Lookup join
cannot be applied to hybrid tables");
QueryContext queryContext = leafStageRequests.get(0).getQueryContext();
@@ -97,10 +122,179 @@ public class LookupJoinOperator extends
MultiStageOperator {
_resultSchema = node.getDataSchema();
_resultColumnSize = _resultSchema.size();
List<RexExpression> nonEquiConditions = node.getNonEquiConditions();
+ // SEMI and ANTI joins project the left columns only, so an evaluator
built over the join result schema cannot
+ // reference a dimension table column. Reject the combination here,
otherwise the loop below fails with an index
+ // error that says nothing about the cause.
+ Preconditions.checkState(nonEquiConditions.isEmpty() ||
_joinType.projectsRight(),
+ "Lookup join type: %s does not support non-equi join conditions, got:
%s", _joinType, nonEquiConditions);
_nonEquiEvaluators = new ArrayList<>(nonEquiConditions.size());
for (RexExpression nonEquiCondition : nonEquiConditions) {
_nonEquiEvaluators.add(TransformOperandFactory.getTransformOperand(nonEquiCondition,
_resultSchema));
}
+
+ KeyPlan keyPlan =
+ compileKeyPlan(node, rightTableName,
_rightTable.getPrimaryKeyColumns(), _rightInput.getDataSchema(),
+ _leftColumnSize);
+ _keySize = keyPlan._sources.length;
+ _keySources = keyPlan._sources;
+ _keyConstants = keyPlan._constants;
+ _neverMatches = keyPlan._neverMatches;
+ }
+
+ /// Works out where each value of the lookup key comes from.
+ ///
+ /// The key has one position per dimension table primary key column, in the
order the dimension table schema declares
+ /// them. Each position is bound in two passes:
+ ///
+ /// 1. Equi-join keys. `rightKeys[i]` names a dimension column, and that
column's position in the primary key decides
+ /// where `leftKeys[i]` lands. This is what makes the key independent of
the order of the join condition.
+ /// 2. Constants. A non-equi condition of the form `dim_column = literal`
binds a position that pass 1 left open.
+ /// A constant never replaces an equi-join key, because the equi-join key
is not kept anywhere else and dropping it
+ /// would silently widen the join. A constant that pass 1 already bound
stays in [#_nonEquiEvaluators] and runs as
+ /// a filter after the lookup, which is what the SQL semantics require.
+ ///
+ /// The method rejects a join condition that cannot produce exactly one
value per primary key column. Every rejected
+ /// case returned no rows or wrong rows before this validation existed, so
an error is the better outcome. This is
+ /// also the contract that the single-stage `lookup` transform function
enforces.
+ @VisibleForTesting
+ static KeyPlan compileKeyPlan(JoinNode node, String tableName, @Nullable
List<String> primaryKeyColumns,
+ DataSchema rightSchema, int leftColumnSize) {
+ Preconditions.checkState(CollectionUtils.isNotEmpty(primaryKeyColumns),
+ "Failed to find primary key columns for dimension table: %s",
tableName);
+ String[] rightColumns = rightSchema.getColumnNames();
+ int keySize = primaryKeyColumns.size();
+ int[] sources = new int[keySize];
+ Arrays.fill(sources, KEY_SOURCE_UNBOUND);
+ Object[] constants = new Object[keySize];
+
+ // Pass 1: bind key positions from the equi-join keys.
+ List<Integer> leftKeys = node.getLeftKeys();
+ List<Integer> rightKeys = node.getRightKeys();
+ int numEquiKeys = leftKeys.size();
+ for (int i = 0; i < numEquiKeys; i++) {
+ String rightColumn = rightColumns[rightKeys.get(i)];
+ int keyPosition = primaryKeyColumns.indexOf(rightColumn);
+ Preconditions.checkState(keyPosition >= 0,
+ "Lookup join on dimension table: %s has a join key on column: %s,
which is not a primary key column. "
+ + "Primary key columns: %s", tableName, rightColumn,
primaryKeyColumns);
+ Preconditions.checkState(sources[keyPosition] == KEY_SOURCE_UNBOUND,
+ "Lookup join on dimension table: %s has multiple join keys on
primary key column: %s", tableName,
+ rightColumn);
+ sources[keyPosition] = leftKeys.get(i);
+ }
+
+ // Pass 2: bind the remaining key positions from constant equality
conditions.
+ boolean neverMatches = false;
+ for (RexExpression nonEquiCondition : node.getNonEquiConditions()) {
+ int rightColumnId = getConstantEqualityColumnId(nonEquiCondition,
leftColumnSize, rightColumns.length);
+ if (rightColumnId < 0) {
+ continue;
+ }
+ int keyPosition = primaryKeyColumns.indexOf(rightColumns[rightColumnId]);
+ if (keyPosition < 0 || sources[keyPosition] != KEY_SOURCE_UNBOUND) {
+ continue;
+ }
+ sources[keyPosition] = KEY_SOURCE_CONSTANT;
+ RexExpression.Literal literal = getConstantLiteral(nonEquiCondition);
+ Object value = literal.getValue();
+ if (value != null) {
+ checkConstantType(literal,
rightSchema.getColumnDataType(rightColumnId), tableName,
+ rightColumns[rightColumnId]);
+ }
+ constants[keyPosition] = value;
+ neverMatches |= value == null;
+ }
+
+ List<String> unboundColumns = new ArrayList<>();
+ for (int i = 0; i < keySize; i++) {
+ if (sources[i] == KEY_SOURCE_UNBOUND) {
+ unboundColumns.add(primaryKeyColumns.get(i));
+ }
+ }
+ Preconditions.checkState(unboundColumns.isEmpty(),
+ "Lookup join on dimension table: %s cannot determine primary key
columns: %s from the join condition. "
+ + "A lookup join reads the dimension table by primary key, so the
join condition must have an equality on "
+ + "every primary key column: %s. Add the missing conditions, or
remove the lookup join hint to use a hash "
+ + "join instead.", tableName, unboundColumns, primaryKeyColumns);
+ return new KeyPlan(sources, constants, neverMatches);
+ }
+
+ /// Returns the dimension table column id of a `dim_column = literal`
condition, or -1 when the condition does not
+ /// have that shape. Only an equality against a single literal can serve as
a key value. A condition such as
+ /// `dim_column IN ('a', 'b')` reaches this method as a disjunction and
returns -1, because a hash lookup cannot read
+ /// a set of keys.
+ ///
+ /// Non-equi conditions index the joined row, so the dimension table columns
start at `leftColumnSize`.
+ private static int getConstantEqualityColumnId(RexExpression condition, int
leftColumnSize, int numRightColumns) {
+ if (!(condition instanceof RexExpression.FunctionCall)) {
+ return -1;
+ }
+ RexExpression.FunctionCall functionCall = (RexExpression.FunctionCall)
condition;
+ if (!functionCall.getFunctionName().equals(SqlKind.EQUALS.name())) {
+ return -1;
+ }
+ List<RexExpression> operands = functionCall.getFunctionOperands();
+ if (operands.size() != 2) {
+ return -1;
+ }
+ RexExpression inputRef = operands.get(0) instanceof RexExpression.InputRef
? operands.get(0) : operands.get(1);
+ RexExpression literal = operands.get(0) instanceof RexExpression.InputRef
? operands.get(1) : operands.get(0);
+ if (!(inputRef instanceof RexExpression.InputRef) || !(literal instanceof
RexExpression.Literal)) {
+ return -1;
+ }
+ int columnId = ((RexExpression.InputRef) inputRef).getIndex() -
leftColumnSize;
+ return columnId >= 0 && columnId < numRightColumns ? columnId : -1;
+ }
+
+ /// Returns the literal of a condition that [#getConstantEqualityColumnId]
accepted.
+ private static RexExpression.Literal getConstantLiteral(RexExpression
condition) {
+ List<RexExpression> operands = ((RexExpression.FunctionCall)
condition).getFunctionOperands();
+ return (RexExpression.Literal) (operands.get(0) instanceof
RexExpression.Literal ? operands.get(0)
+ : operands.get(1));
+ }
+
+ /// Makes sure that a constant can serve as a lookup key value for the given
dimension table column.
+ ///
+ /// The operator does not convert the constant. The planner coerces the
operands of a comparison, so a literal
+ /// compared against a dimension column already carries the type of that
column. This check states that assumption,
+ /// and fails with the table and column named if the planner ever stops
holding it up. A constant of another type
+ /// would miss every row, because [PrimaryKey] compares values with `equals`
and an `Integer` never equals a `Long`.
+ ///
+ /// BYTES is rejected outright. A dimension table reads its key values with
+ ///
[org.apache.pinot.segment.local.segment.readers.PinotSegmentColumnReader],
which returns a raw `byte[]` whose
+ /// `equals` and `hashCode` are identity. No constant of any representation
can match such a key, so an error is
+ /// better than an empty result. Fixing that belongs in
`DimensionTableDataManager`, which would fix the single-stage
+ /// `lookup` transform function at the same time.
+ ///
+ /// BIG_DECIMAL is allowed. `BigDecimal#equals` compares the scale, so a
constant of `1.5` does not match a stored
+ /// `1.50`, but a hash join carries the same hazard through
[org.apache.pinot.query.runtime.operator.join.LookupTable]
+ /// and this operator does not single out one arm of it.
+ private static void checkConstantType(RexExpression.Literal literal,
ColumnDataType columnDataType, String tableName,
+ String column) {
+ ColumnDataType storedType = columnDataType.getStoredType();
+ Preconditions.checkState(storedType != ColumnDataType.BYTES,
+ "Lookup join on dimension table: %s does not support a constant on
primary key column: %s of type BYTES. "
+ + "Remove the lookup join hint to use a hash join instead.",
tableName, column);
+ Preconditions.checkState(literal.getDataType().getStoredType() ==
storedType,
+ "Lookup join on dimension table: %s got a constant of stored type: %s
on primary key column: %s of stored "
+ + "type: %s", tableName, literal.getDataType().getStoredType(),
column, storedType);
+ }
+
+ /// Value sources of the lookup key, one entry per dimension table primary
key column.
+ ///
+ /// `_sources` holds a left row index, or [#KEY_SOURCE_CONSTANT] when the
value is in `_constants` at the same
+ /// position. `_neverMatches` is set when a constant is null, which no
primary key value equals.
+ @VisibleForTesting
+ static class KeyPlan {
+ final int[] _sources;
+ final Object[] _constants;
+ final boolean _neverMatches;
+
+ KeyPlan(int[] sources, Object[] constants, boolean neverMatches) {
+ _sources = sources;
+ _constants = constants;
+ _neverMatches = neverMatches;
+ }
}
@Override
@@ -170,8 +364,7 @@ public class LookupJoinOperator extends MultiStageOperator {
ArrayList<Object[]> rows = new ArrayList<>(container.size());
for (Object[] leftRow : container) {
- PrimaryKey key = getKey(leftRow);
- Object[] rightRow = _rightTable.lookupValues(key, _rightColumns);
+ Object[] rightRow = _neverMatches ? null :
_rightTable.lookupValues(getKey(leftRow), _rightColumns);
if (rightRow != null) {
List<Object> resultRow = JoinedRowView.of(leftRow, rightRow,
_resultColumnSize, _leftColumnSize);
if (_nonEquiEvaluators.isEmpty() || _nonEquiEvaluators.stream()
@@ -191,8 +384,10 @@ public class LookupJoinOperator extends MultiStageOperator
{
private List<Object[]> buildJoinedDataBlockSemi(MseBlock.Data leftBlock) {
List<Object[]> container = leftBlock.asRowHeap().getRows();
+ // A constant key value only comes from a non-equi condition, which the
constructor rejects for this join type, so
+ // there is no null constant to short-circuit on here.
List<Object[]> rows = new ArrayList<>(container.size());
- PrimaryKey key = new PrimaryKey(new Object[_leftKeyIds.length]);
+ PrimaryKey key = new PrimaryKey(new Object[_keySize]);
for (Object[] leftRow : container) {
fillKey(leftRow, key);
@@ -205,8 +400,9 @@ public class LookupJoinOperator extends MultiStageOperator {
private List<Object[]> buildJoinedDataBlockAnti(MseBlock.Data leftBlock) {
List<Object[]> container = leftBlock.asRowHeap().getRows();
+ // See the note in buildJoinedDataBlockSemi on the absence of a null
constant short-circuit.
List<Object[]> rows = new ArrayList<>(container.size());
- PrimaryKey key = new PrimaryKey(new Object[_leftKeyIds.length]);
+ PrimaryKey key = new PrimaryKey(new Object[_keySize]);
for (Object[] leftRow : container) {
fillKey(leftRow, key);
@@ -218,17 +414,19 @@ public class LookupJoinOperator extends
MultiStageOperator {
}
private PrimaryKey getKey(Object[] row) {
- Object[] values = new Object[_leftKeyIds.length];
- for (int i = 0; i < _leftKeyIds.length; i++) {
- values[i] = row[_leftKeyIds[i]];
- }
+ Object[] values = new Object[_keySize];
+ fillKeyValues(row, values);
return new PrimaryKey(values);
}
private void fillKey(Object[] row, PrimaryKey key) {
- Object[] values = key.getValues();
- for (int i = 0; i < _leftKeyIds.length; i++) {
- values[i] = row[_leftKeyIds[i]];
+ fillKeyValues(row, key.getValues());
+ }
+
+ private void fillKeyValues(Object[] row, Object[] values) {
+ for (int i = 0; i < _keySize; i++) {
+ int source = _keySources[i];
+ values[i] = source == KEY_SOURCE_CONSTANT ? _keyConstants[i] :
row[source];
}
}
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/LookupJoinOperatorTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/LookupJoinOperatorTest.java
new file mode 100644
index 00000000000..bec6a38f1bb
--- /dev/null
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/operator/LookupJoinOperatorTest.java
@@ -0,0 +1,247 @@
+/**
+ * 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.pinot.query.runtime.operator;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import javax.annotation.Nullable;
+import org.apache.calcite.rel.core.JoinRelType;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.commons.lang3.tuple.Pair;
+import org.apache.pinot.common.utils.DataSchema;
+import org.apache.pinot.common.utils.DataSchema.ColumnDataType;
+import org.apache.pinot.query.planner.logical.RexExpression;
+import org.apache.pinot.query.planner.plannode.JoinNode;
+import org.apache.pinot.query.planner.plannode.PlanNode;
+import org.apache.pinot.spi.utils.ByteArray;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Tests [LookupJoinOperator#compileKeyPlan], which decides where each value
of the dimension table lookup key comes
+/// from.
+///
+/// The dimension table is a hash map keyed by the primary key values, so a
key is only usable when it holds one value
+/// per primary key column, in the order the dimension table schema declares
them, and with the type of each column.
+/// These tests cover the cases that a query alone cannot reach, such as a
null constant and a constant of the wrong
+/// type. End-to-end coverage is in `LookupJoin.json`.
+public class LookupJoinOperatorTest {
+ private static final String TABLE_NAME = "dim_tbl_OFFLINE";
+
+ /// Fact table columns. The dimension table columns of the joined row start
after these.
+ private static final int FACT_CURRENCY = 0;
+ private static final int FACT_RATE_START_DATE = 1;
+ private static final int LEFT_COLUMN_SIZE = 2;
+
+ /// Dimension table columns, in the order that the leaf stage reports them.
The order is not the primary key order,
+ /// which is what makes the key positions worth testing.
+ private static final int DIM_CURRENCY = 0;
+ private static final int DIM_RATE = 1;
+ private static final int DIM_RATE_START_DATE = 2;
+ private static final DataSchema DIM_SCHEMA =
+ new DataSchema(new String[]{"currency", "rate", "rate_start_date"}, new
ColumnDataType[]{
+ ColumnDataType.STRING, ColumnDataType.INT, ColumnDataType.LONG
+ });
+ private static final List<String> PRIMARY_KEY_COLUMNS = List.of("currency",
"rate_start_date");
+
+ /// Key positions follow the primary key, not the order of the join
condition.
+ private static final int KEY_CURRENCY = 0;
+ private static final int KEY_RATE_START_DATE = 1;
+
+ @Test
+ public void testKeyPositionsFollowPrimaryKeyOrderNotConditionOrder() {
+ // ON dim.rate_start_date = fact.rate_start_date AND dim.currency =
fact.currency
+ LookupJoinOperator.KeyPlan keyPlan = compileKeyPlan(
+ List.of(joinKey(FACT_RATE_START_DATE, DIM_RATE_START_DATE),
joinKey(FACT_CURRENCY, DIM_CURRENCY)), List.of());
+
+ assertEquals(keyPlan._sources[KEY_CURRENCY], FACT_CURRENCY);
+ assertEquals(keyPlan._sources[KEY_RATE_START_DATE], FACT_RATE_START_DATE);
+ assertFalse(keyPlan._neverMatches);
+ }
+
+ @Test
+ public void testConstantBindsPrimaryKeyColumn() {
+ // ON dim.currency = 'gbp' AND dim.rate_start_date = fact.rate_start_date
+ LookupJoinOperator.KeyPlan keyPlan =
+ compileKeyPlan(List.of(joinKey(FACT_RATE_START_DATE,
DIM_RATE_START_DATE)),
+ List.of(dimEqualsConstant(DIM_CURRENCY, ColumnDataType.STRING,
"gbp")));
+
+ assertEquals(keyPlan._sources[KEY_RATE_START_DATE], FACT_RATE_START_DATE);
+ assertEquals(keyPlan._constants[KEY_CURRENCY], "gbp");
+ assertFalse(keyPlan._neverMatches);
+ }
+
+ @Test
+ public void testConstantDoesNotReplaceJoinKey() {
+ // ON dim.currency = fact.currency AND dim.rate_start_date =
fact.rate_start_date AND dim.currency = 'gbp'
+ // The join key is not kept anywhere else, so replacing it would silently
widen the join. The constant stays a
+ // filter that runs after the lookup.
+ LookupJoinOperator.KeyPlan keyPlan = compileKeyPlan(
+ List.of(joinKey(FACT_CURRENCY, DIM_CURRENCY),
joinKey(FACT_RATE_START_DATE, DIM_RATE_START_DATE)),
+ List.of(dimEqualsConstant(DIM_CURRENCY, ColumnDataType.STRING,
"gbp")));
+
+ assertEquals(keyPlan._sources[KEY_CURRENCY], FACT_CURRENCY);
+ assertEquals(keyPlan._sources[KEY_RATE_START_DATE], FACT_RATE_START_DATE);
+ assertNull(keyPlan._constants[KEY_CURRENCY]);
+ }
+
+ @Test
+ public void testConstantOfTheColumnTypeIsAccepted() {
+ // ON dim.currency = fact.currency AND dim.rate_start_date = 1
+ // The planner coerces the operands of a comparison, so the constant
already carries the type of the column.
+ LookupJoinOperator.KeyPlan keyPlan =
compileKeyPlan(List.of(joinKey(FACT_CURRENCY, DIM_CURRENCY)),
+ List.of(dimEqualsConstant(DIM_RATE_START_DATE, ColumnDataType.LONG,
1L)));
+
+ assertEquals(keyPlan._constants[KEY_RATE_START_DATE], 1L);
+ }
+
+ @Test
+ public void testConstantOfAnotherTypeIsRejected() {
+ // PrimaryKey compares values with equals, where an Integer never equals a
Long, so a constant of the wrong type
+ // misses every row. The planner is expected to have coerced it already.
+ IllegalStateException exception = expectThrows(IllegalStateException.class,
+ () -> compileKeyPlan(List.of(joinKey(FACT_CURRENCY, DIM_CURRENCY)),
+ List.of(dimEqualsConstant(DIM_RATE_START_DATE, ColumnDataType.INT,
1))));
+ assertTrue(exception.getMessage().contains("got a constant of stored type:
INT"), exception.getMessage());
+ }
+
+ @Test
+ public void testBigDecimalConstantIsAccepted() {
+ // BigDecimal#equals compares the scale, so 1.5 does not match a stored
1.50. A hash join carries the same hazard,
+ // so the lookup join accepts the constant rather than singling out one
arm of it.
+ LookupJoinOperator.KeyPlan keyPlan = compileKeyPlan(DECIMAL_DIM_SCHEMA,
List.of("amount"), List.of(),
+ List.of(dimEqualsConstant(0, ColumnDataType.BIG_DECIMAL, new
BigDecimal("1.5"))));
+
+ assertEquals(keyPlan._constants[0], new BigDecimal("1.5"));
+ }
+
+ @Test
+ public void testBytesConstantIsRejected() {
+ // A dimension table keys on a raw byte[], whose equals and hashCode are
identity, so no constant can match it.
+ IllegalStateException exception = expectThrows(IllegalStateException.class,
+ () -> compileKeyPlan(BYTES_DIM_SCHEMA, List.of("id"), List.of(),
+ List.of(dimEqualsConstant(0, ColumnDataType.BYTES, new
ByteArray(new byte[]{1, 2})))));
+ assertTrue(exception.getMessage().contains("of type BYTES"),
exception.getMessage());
+ }
+
+ @Test
+ public void testNullConstantMakesEveryLookupMiss() {
+ // A null never matches a primary key value, so the operator must not run
the lookup at all.
+ LookupJoinOperator.KeyPlan keyPlan =
+ compileKeyPlan(List.of(joinKey(FACT_RATE_START_DATE,
DIM_RATE_START_DATE)),
+ List.of(dimEqualsConstant(DIM_CURRENCY, ColumnDataType.STRING,
null)));
+
+ assertTrue(keyPlan._neverMatches);
+ }
+
+ @Test
+ public void testSetPredicateDoesNotBindPrimaryKeyColumn() {
+ // dim.currency IN ('gbp', 'usd') reaches the operator as a disjunction. A
hash lookup cannot read a set of keys,
+ // so the primary key column stays open and the join is rejected.
+ RexExpression inList = new
RexExpression.FunctionCall(ColumnDataType.BOOLEAN, SqlKind.OR.name(),
+ List.of(dimEqualsConstant(DIM_CURRENCY, ColumnDataType.STRING, "gbp"),
+ dimEqualsConstant(DIM_CURRENCY, ColumnDataType.STRING, "usd")));
+
+ IllegalStateException exception = expectThrows(IllegalStateException.class,
+ () -> compileKeyPlan(List.of(joinKey(FACT_RATE_START_DATE,
DIM_RATE_START_DATE)), List.of(inList)));
+ assertTrue(exception.getMessage().contains("cannot determine primary key
columns: [currency]"),
+ exception.getMessage());
+ }
+
+ @Test
+ public void testOpenPrimaryKeyColumnIsRejected() {
+ // ON dim.rate_start_date = fact.rate_start_date only. Nothing gives a
value for currency.
+ IllegalStateException exception = expectThrows(IllegalStateException.class,
+ () -> compileKeyPlan(List.of(joinKey(FACT_RATE_START_DATE,
DIM_RATE_START_DATE)), List.of()));
+ assertTrue(exception.getMessage().contains("cannot determine primary key
columns: [currency]"),
+ exception.getMessage());
+ }
+
+ @Test
+ public void testJoinKeyOnNonPrimaryKeyColumnIsRejected() {
+ // The condition on "rate" is a join key, so it is not in the non-equi
conditions and no filter applies it.
+ // Dropping it would return rows that do not match the join condition.
+ IllegalStateException exception = expectThrows(IllegalStateException.class,
+ () -> compileKeyPlan(List.of(joinKey(FACT_CURRENCY, DIM_CURRENCY),
+ joinKey(FACT_RATE_START_DATE, DIM_RATE_START_DATE),
joinKey(FACT_RATE_START_DATE, DIM_RATE)), List.of()));
+ assertTrue(exception.getMessage().contains("join key on column: rate,
which is not a primary key column"),
+ exception.getMessage());
+ }
+
+ @Test
+ public void testDuplicateJoinKeysOnSamePrimaryKeyColumnAreRejected() {
+ // Only one of the two conditions on currency can build the key, and the
other one has nowhere to run.
+ IllegalStateException exception = expectThrows(IllegalStateException.class,
+ () -> compileKeyPlan(List.of(joinKey(FACT_CURRENCY, DIM_CURRENCY),
joinKey(FACT_RATE_START_DATE, DIM_CURRENCY),
+ joinKey(FACT_RATE_START_DATE, DIM_RATE_START_DATE)), List.of()));
+ assertTrue(exception.getMessage().contains("multiple join keys on primary
key column: currency"),
+ exception.getMessage());
+ }
+
+ @Test
+ public void testMissingPrimaryKeyColumnsAreRejected() {
+ IllegalStateException exception = expectThrows(IllegalStateException.class,
+ () -> compileKeyPlan(DIM_SCHEMA, List.of(),
List.of(joinKey(FACT_RATE_START_DATE, DIM_RATE_START_DATE)),
+ List.of()));
+ assertTrue(exception.getMessage().contains("Failed to find primary key
columns"), exception.getMessage());
+ }
+
+ private static final DataSchema DECIMAL_DIM_SCHEMA =
+ new DataSchema(new String[]{"amount"}, new
ColumnDataType[]{ColumnDataType.BIG_DECIMAL});
+ private static final DataSchema BYTES_DIM_SCHEMA =
+ new DataSchema(new String[]{"id"}, new
ColumnDataType[]{ColumnDataType.BYTES});
+
+ /// A join key, as the pair of column ids that Calcite splits a `fact_column
= dim_column` condition into.
+ private static Pair<Integer, Integer> joinKey(int factColumnId, int
dimColumnId) {
+ return Pair.of(factColumnId, dimColumnId);
+ }
+
+ private static LookupJoinOperator.KeyPlan compileKeyPlan(List<Pair<Integer,
Integer>> joinKeys,
+ List<RexExpression> nonEquiConditions) {
+ return compileKeyPlan(DIM_SCHEMA, PRIMARY_KEY_COLUMNS, joinKeys,
nonEquiConditions);
+ }
+
+ private static LookupJoinOperator.KeyPlan compileKeyPlan(DataSchema
dimSchema, List<String> primaryKeyColumns,
+ List<Pair<Integer, Integer>> joinKeys, List<RexExpression>
nonEquiConditions) {
+ List<Integer> leftKeys = new ArrayList<>(joinKeys.size());
+ List<Integer> rightKeys = new ArrayList<>(joinKeys.size());
+ for (Pair<Integer, Integer> joinKey : joinKeys) {
+ leftKeys.add(joinKey.getLeft());
+ rightKeys.add(joinKey.getRight());
+ }
+ JoinNode node =
+ new JoinNode(0, dimSchema, PlanNode.NodeHint.EMPTY, List.of(),
JoinRelType.INNER, leftKeys, rightKeys,
+ nonEquiConditions, JoinNode.JoinStrategy.LOOKUP);
+ return LookupJoinOperator.compileKeyPlan(node, TABLE_NAME,
primaryKeyColumns, dimSchema, LEFT_COLUMN_SIZE);
+ }
+
+ /// Builds a `dim_column = constant` condition. Non-equi conditions index
the joined row, so the dimension table
+ /// columns start at [#LEFT_COLUMN_SIZE].
+ private static RexExpression dimEqualsConstant(int dimColumnId,
ColumnDataType dataType, @Nullable Object value) {
+ return new RexExpression.FunctionCall(ColumnDataType.BOOLEAN,
SqlKind.EQUALS.name(),
+ List.of(new RexExpression.InputRef(LEFT_COLUMN_SIZE + dimColumnId),
+ new RexExpression.Literal(dataType, value)));
+ }
+}
diff --git
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/ResourceBasedQueriesTest.java
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/ResourceBasedQueriesTest.java
index 51bc3e987eb..cfc7724ba9c 100644
---
a/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/ResourceBasedQueriesTest.java
+++
b/pinot-query-runtime/src/test/java/org/apache/pinot/query/runtime/queries/ResourceBasedQueriesTest.java
@@ -22,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.collect.Maps;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStream;
@@ -52,6 +53,7 @@ import
org.apache.pinot.query.runtime.plan.MultiStageQueryStats;
import org.apache.pinot.query.service.dispatch.QueryDispatcher;
import org.apache.pinot.query.testutils.MockInstanceDataManagerFactory;
import org.apache.pinot.query.testutils.QueryTestUtils;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentRecordReader;
import org.apache.pinot.segment.spi.ImmutableSegment;
import org.apache.pinot.spi.config.instance.InstanceType;
import org.apache.pinot.spi.config.table.TableType;
@@ -132,9 +134,9 @@ public class ResourceBasedQueriesTest extends
QueryRunnerTestBase {
List<QueryTestCase.ColumnAndType> columnAndTypes = table._schema;
List<GenericRow> genericRows = toRow(columnAndTypes, table._inputs);
if (table._replicated) {
- addSegmentReplicated(factory1, factory2, offlineTableName,
genericRows);
+ ImmutableSegment segment = addSegmentReplicated(factory1, factory2,
offlineTableName, genericRows);
if (table._isDimTable) {
- registerMockDimensionTable(offlineTableName, schema, table,
genericRows);
+ registerMockDimensionTable(offlineTableName, schema, table,
segment);
}
continue;
}
@@ -260,32 +262,52 @@ public class ResourceBasedQueriesTest extends
QueryRunnerTestBase {
}
}
- private void addSegmentReplicated(MockInstanceDataManagerFactory factory1,
MockInstanceDataManagerFactory factory2,
- String offlineTableName, List<GenericRow> rows) {
+ private ImmutableSegment addSegmentReplicated(MockInstanceDataManagerFactory
factory1,
+ MockInstanceDataManagerFactory factory2, String offlineTableName,
List<GenericRow> rows) {
ImmutableSegment segment = factory1.addSegment(offlineTableName, rows);
factory2.addSegment(offlineTableName, segment);
+ return segment;
}
/// Registers a mock DimensionTableDataManager for lookup join testing.
- /// The mock stores all rows in a HashMap keyed by primary key, supporting
lookupValues() and containsKey().
+ /// The mock stores all rows in a HashMap keyed by primary key, supporting
lookupValues(), containsKey() and
+ /// getPrimaryKeyColumns().
+ ///
+ /// The map is built from the segment with [PinotSegmentRecordReader], which
is how a real
+ /// [DimensionTableDataManager] builds its own. Reading the segment is what
keeps every value in the representation
+ /// that the column is stored in, for every data type, without this method
holding a copy of the conversion rules.
+ /// The rows of the test case cannot be used directly: they hold the values
that the JSON parser produced, where an
+ /// `Integer` stands where the query supplies a `Long`, and [PrimaryKey]
compares values with equals, so the lookup
+ /// would miss for reasons that have nothing to do with the code under test.
private void registerMockDimensionTable(String offlineTableName, Schema
schema, QueryTestCase.Table table,
- List<GenericRow> rows) {
+ ImmutableSegment segment) {
List<String> primaryKeyColumns = table._primaryKeyColumns;
if (primaryKeyColumns == null || primaryKeyColumns.isEmpty()) {
throw new IllegalStateException(
"isDimTable=true requires primaryKeyColumns to be set for table: " +
offlineTableName);
}
- // Build an in-memory lookup map: PrimaryKey -> GenericRow
- Map<PrimaryKey, GenericRow> lookupMap = new HashMap<>();
- for (GenericRow row : rows) {
- Object[] pkValues = new Object[primaryKeyColumns.size()];
- for (int i = 0; i < primaryKeyColumns.size(); i++) {
- pkValues[i] = row.getValue(primaryKeyColumns.get(i));
+ // Build an in-memory lookup map: PrimaryKey -> column name to stored value
+ List<String> columns = new ArrayList<>(schema.getColumnNames());
+ Map<PrimaryKey, Map<String, Object>> lookupMap = new HashMap<>();
+ try (PinotSegmentRecordReader recordReader = new
PinotSegmentRecordReader()) {
+ recordReader.init(segment);
+ int[] primaryKeyIndexes =
recordReader.getIndexesForColumns(primaryKeyColumns);
+ int[] columnIndexes = recordReader.getIndexesForColumns(columns);
+ int numDocs = segment.getSegmentMetadata().getTotalDocs();
+ for (int docId = 0; docId < numDocs; docId++) {
+ Object[] values = recordReader.getRecordValues(docId, columnIndexes);
+ Map<String, Object> row =
Maps.newHashMapWithExpectedSize(columns.size());
+ for (int i = 0; i < columns.size(); i++) {
+ row.put(columns.get(i), values[i]);
+ }
+ lookupMap.put(new PrimaryKey(recordReader.getRecordValues(docId,
primaryKeyIndexes)), row);
}
- lookupMap.put(new PrimaryKey(pkValues), row);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to build the mock dimension table
for: " + offlineTableName, e);
}
// Create and register a mock DimensionTableDataManager
DimensionTableDataManager mockDimManager =
Mockito.mock(DimensionTableDataManager.class);
+
Mockito.when(mockDimManager.getPrimaryKeyColumns()).thenReturn(primaryKeyColumns);
Mockito.when(mockDimManager.containsKey(ArgumentMatchers.any(PrimaryKey.class)))
.thenAnswer(invocation -> {
PrimaryKey pk = invocation.getArgument(0);
@@ -294,14 +316,14 @@ public class ResourceBasedQueriesTest extends
QueryRunnerTestBase {
Mockito.when(mockDimManager.lookupValues(ArgumentMatchers.any(PrimaryKey.class),
ArgumentMatchers.any(String[].class))).thenAnswer(invocation -> {
PrimaryKey pk = invocation.getArgument(0);
- String[] columns = invocation.getArgument(1);
- GenericRow row = lookupMap.get(pk);
+ String[] lookupColumns = invocation.getArgument(1);
+ Map<String, Object> row = lookupMap.get(pk);
if (row == null) {
return null;
}
- Object[] values = new Object[columns.length];
- for (int i = 0; i < columns.length; i++) {
- values[i] = row.getValue(columns[i]);
+ Object[] values = new Object[lookupColumns.length];
+ for (int i = 0; i < lookupColumns.length; i++) {
+ values[i] = row.get(lookupColumns[i]);
}
return values;
});
diff --git a/pinot-query-runtime/src/test/resources/queries/LookupJoin.json
b/pinot-query-runtime/src/test/resources/queries/LookupJoin.json
index 73b0f03f52e..93b0468d7d2 100644
--- a/pinot-query-runtime/src/test/resources/queries/LookupJoin.json
+++ b/pinot-query-runtime/src/test/resources/queries/LookupJoin.json
@@ -75,5 +75,172 @@
"ignoreLiteMode": true
}
]
+ },
+ "lookup_join_literal_key": {
+ "comment": "Regression test for issue 19188. One dimension primary-key
component comes from a literal in the join condition. Calcite classifies that
condition as a non-equi condition, so it is absent from the join keys. Before
the fix the lookup key held one value against a two-column primary key and the
join returned 0 rows.",
+ "tables": {
+ "fact_tbl": {
+ "schema": [
+ {"name": "rate_start_date", "type": "LONG"}
+ ],
+ "inputs": [
+ [1]
+ ]
+ },
+ "dim_tbl": {
+ "schema": [
+ {"name": "currency", "type": "STRING"},
+ {"name": "rate_start_date", "type": "LONG"},
+ {"name": "rate", "type": "INT"}
+ ],
+ "inputs": [
+ ["gbp", 1, 125],
+ ["usd", 1, 100]
+ ],
+ "replicated": true,
+ "isDimTable": true,
+ "primaryKeyColumns": ["currency", "rate_start_date"]
+ }
+ },
+ "queries": [
+ {
+ "description": "Lookup join with a literal dimension primary-key
component",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{dim_tbl}.currency, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.currency = 'gbp' AND {dim_tbl}.rate_start_date =
{fact_tbl}.rate_start_date",
+ "outputs": [
+ ["gbp", 125]
+ ],
+ "ignoreLiteMode": true
+ }
+ ]
+ },
+ "lookup_join_composite_key": {
+ "comment": "Covers lookup joins against a two-column dimension primary
key. The lookup key must hold one value per primary key column, in the order
the dimension table schema declares them. These cases check that the key is
complete, that it is ordered by the primary key instead of by the join
condition, and that a join condition which cannot produce a complete key gives
an error instead of 0 rows.",
+ "tables": {
+ "fact_tbl": {
+ "schema": [
+ {"name": "currency", "type": "STRING"},
+ {"name": "rate_start_date", "type": "LONG"},
+ {"name": "amount", "type": "INT"}
+ ],
+ "inputs": [
+ ["gbp", 1, 10],
+ ["usd", 1, 20],
+ ["gbp", 2, 30],
+ ["eur", 1, 40],
+ ["eur", 9, 50]
+ ]
+ },
+ "dim_tbl": {
+ "schema": [
+ {"name": "currency", "type": "STRING"},
+ {"name": "rate_start_date", "type": "LONG"},
+ {"name": "rate", "type": "INT"}
+ ],
+ "inputs": [
+ ["gbp", 1, 125],
+ ["usd", 1, 100],
+ ["gbp", 2, 130]
+ ],
+ "replicated": true,
+ "isDimTable": true,
+ "primaryKeyColumns": ["currency", "rate_start_date"]
+ }
+ },
+ "queries": [
+ {
+ "description": "Literal primary-key component in the ON clause",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.currency, {dim_tbl}.rate FROM {fact_tbl} JOIN
{dim_tbl} ON {dim_tbl}.currency = 'gbp' AND {dim_tbl}.rate_start_date =
{fact_tbl}.rate_start_date",
+ "outputs": [
+ [10, "gbp", 125],
+ [20, "gbp", 125],
+ [30, "gbp", 130],
+ [40, "gbp", 125]
+ ],
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "Same literal primary-key component, written in the
WHERE clause. The planner moves it into the join condition, so the operator
sees the same plan.",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.currency, {dim_tbl}.rate FROM {fact_tbl} JOIN
{dim_tbl} ON {dim_tbl}.rate_start_date = {fact_tbl}.rate_start_date WHERE
{dim_tbl}.currency = 'gbp'",
+ "outputs": [
+ [10, "gbp", 125],
+ [20, "gbp", 125],
+ [30, "gbp", 130],
+ [40, "gbp", 125]
+ ],
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "Left join with a literal primary-key component. Before
the fix every row was null-padded, which gave wrong values instead of missing
rows.",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} LEFT JOIN {dim_tbl} ON
{dim_tbl}.currency = 'gbp' AND {dim_tbl}.rate_start_date =
{fact_tbl}.rate_start_date",
+ "outputs": [
+ [10, 125],
+ [20, 125],
+ [30, 130],
+ [40, 125],
+ [50, null]
+ ],
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "Both primary-key columns equi-joined, conditions
written in primary key order",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.currency = {fact_tbl}.currency AND {dim_tbl}.rate_start_date =
{fact_tbl}.rate_start_date",
+ "outputs": [
+ [10, 125],
+ [20, 100],
+ [30, 130]
+ ],
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "Both primary-key columns equi-joined, conditions
written in reverse primary key order. Before the fix the key values were
swapped and the join returned 0 rows.",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.rate_start_date = {fact_tbl}.rate_start_date AND {dim_tbl}.currency =
{fact_tbl}.currency",
+ "outputs": [
+ [10, 125],
+ [20, 100],
+ [30, 130]
+ ],
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "A literal on a primary-key column that an equi-join
key already binds. The equi-join key builds the key and the literal runs as a
filter after the lookup. If the literal replaced the equi-join key, the usd
fact row would read the gbp dimension row and add a wrong row.",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.currency = {fact_tbl}.currency AND {dim_tbl}.rate_start_date =
{fact_tbl}.rate_start_date AND {dim_tbl}.currency = 'gbp'",
+ "outputs": [
+ [10, 125],
+ [30, 130]
+ ],
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "A constant binds the LONG primary-key column. The
planner coerces the operands of the comparison, so the constant arrives with
the type of the column, and the operator checks that before it builds the key.
A constant of another type would miss every row.",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.currency = {fact_tbl}.currency AND {dim_tbl}.rate_start_date = 1",
+ "outputs": [
+ [10, 125],
+ [20, 100],
+ [30, 125]
+ ],
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "The join condition leaves a primary-key column open.
Before the fix the join returned 0 rows.",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.rate_start_date = {fact_tbl}.rate_start_date",
+ "outputs": [],
+ "expectedException": ".*cannot determine primary key columns:
\\[currency\\].*",
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "A set predicate cannot give a single key value, so the
primary-key column stays open",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.currency IN ('gbp', 'usd') AND {dim_tbl}.rate_start_date =
{fact_tbl}.rate_start_date",
+ "outputs": [],
+ "expectedException": ".*cannot determine primary key columns:
\\[currency\\].*",
+ "ignoreLiteMode": true
+ },
+ {
+ "description": "A join key on a dimension column that is not part of
the primary key. Before the fix this condition was dropped and the join
returned rows that do not match it.",
+ "sql": "SELECT /*+ joinOptions(join_strategy='lookup') */
{fact_tbl}.amount, {dim_tbl}.rate FROM {fact_tbl} JOIN {dim_tbl} ON
{dim_tbl}.currency = {fact_tbl}.currency AND {dim_tbl}.rate_start_date =
{fact_tbl}.rate_start_date AND {dim_tbl}.rate = {fact_tbl}.amount",
+ "outputs": [],
+ "expectedException": ".*join key on column: rate, which is not a
primary key column.*",
+ "ignoreLiteMode": true
+ }
+ ]
}
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]