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 08f4ab17e67 Make IDSET and IN_ID_SET work in the multi-stage engine
(#19757)
08f4ab17e67 is described below
commit 08f4ab17e67d6382c848221864739799f7ad55f3
Author: Yash Mayya <[email protected]>
AuthorDate: Tue Oct 6 21:14:51 2026 -0700
Make IDSET and IN_ID_SET work in the multi-stage engine (#19757)
---
.../common/function/TransformFunctionType.java | 5 +-
.../pinot/core/function/scalar/IdSetFunctions.java | 169 ++++++++++
.../function/InIdSetTransformFunction.java | 14 +-
.../core/query/utils/idset/BloomFilterIdSet.java | 29 +-
.../pinot/core/query/utils/idset/IdSets.java | 53 ++-
.../utils/idset/Roaring64NavigableMapIdSet.java | 13 +-
.../pinot/core/function/FunctionRegistryTest.java | 4 +-
.../core/function/scalar/IdSetFunctionsTest.java | 219 +++++++++++++
.../pinot/core/query/utils/idset/IdSetTest.java | 76 +++++
.../org/apache/pinot/queries/IdSetQueriesTest.java | 157 ++++++++-
.../tests/custom/IdSetIntegrationTest.java | 219 +++++++++++++
.../resources/udf-test-results/all-functions.yaml | 2 +-
.../src/test/resources/queries/JoinPlans.json | 14 +
.../resources/queries/PhysicalOptimizerPlans.json | 15 +
.../src/test/resources/queries/IdSet.json | 360 +++++++++++++++++++++
15 files changed, 1318 insertions(+), 31 deletions(-)
diff --git
a/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
b/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
index 23c972d2684..793249106f4 100644
---
a/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
+++
b/pinot-common/src/main/java/org/apache/pinot/common/function/TransformFunctionType.java
@@ -176,7 +176,10 @@ public enum TransformFunctionType {
MAP_VALUE("mapValue",
ReturnTypes.cascade(opBinding -> positionalComponentType(opBinding, 2),
SqlTypeTransforms.FORCE_NULLABLE),
OperandTypes.family(List.of(SqlTypeFamily.ARRAY, SqlTypeFamily.ANY,
SqlTypeFamily.ARRAY))),
- IN_ID_SET("inIdSet", ReturnTypes.BOOLEAN,
OperandTypes.family(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER)),
+ // Nullable: both the transform and the scalar function return NULL for a
NULL value, unless the IdSet is empty. The
+ // multi-stage planner also uses this operator for the inIdSet scalar
function. This operator is deterministic and
+ // the scalar function's is not, so this one lets the planner push IN_ID_SET
below joins (see IdSetFunctions).
+ IN_ID_SET("inIdSet", ReturnTypes.BOOLEAN_NULLABLE,
OperandTypes.family(SqlTypeFamily.ANY, SqlTypeFamily.CHARACTER)),
LOOKUP("lookUp"),
GROOVY("groovy"),
SCALAR("scalar"),
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/function/scalar/IdSetFunctions.java
b/pinot-core/src/main/java/org/apache/pinot/core/function/scalar/IdSetFunctions.java
new file mode 100644
index 00000000000..e6df10a7977
--- /dev/null
+++
b/pinot-core/src/main/java/org/apache/pinot/core/function/scalar/IdSetFunctions.java
@@ -0,0 +1,169 @@
+/**
+ * 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.core.function.scalar;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.util.UUID;
+import javax.annotation.Nullable;
+import org.apache.pinot.core.query.utils.idset.IdSet;
+import org.apache.pinot.core.query.utils.idset.IdSets;
+import org.apache.pinot.spi.annotations.ScalarFunction;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.utils.ByteArray;
+import org.apache.pinot.spi.utils.UuidUtils;
+
+
+/// Scalar form of the `IN_ID_SET` transform function, for queries that cannot
run the transform function:
+/// - In the multi-stage engine, wherever the predicate is not pushed into a
leaf stage, e.g. a filter over a join,
+/// `HAVING` or a join condition. Leaf stages keep using the
`InIdSetTransformFunction`, because transform functions
+/// take precedence over scalar functions.
+/// - In the single-stage engine, after aggregation, e.g. in `HAVING`.
Aggregations such as `MAX` return DOUBLE there,
+/// so cast them to the type the IdSet was built from.
+///
+/// The method is not static, so each call site gets its own instance (see
`FunctionInvoker`), which caches the last
+/// deserialized IdSet:
+/// - A literal IdSet reaches every row as the same String instance, so it is
deserialized once per call site.
+/// - An IdSet read from rows (e.g. a joined `IDSET` subquery result) arrives
as a new but equal String instance with
+/// every data block, which costs one String comparison per block.
+/// - A call site whose IdSet changes from row to row (e.g. a correlated
`IDSET` subquery, with one IdSet per group)
+/// deserializes the IdSet on every change, so it only suits small IdSets.
+///
+/// Not thread-safe: an instance serves one call site, which one thread
evaluates at a time.
+public class IdSetFunctions {
+ private String _serializedIdSet;
+ private IdSet _idSet;
+ // The IdSet and the value class whose combination passed the last value
type validation
+ private IdSet _validatedIdSet;
+ private Class<?> _validatedValueClass;
+
+ /// Returns whether the IdSet contains the value. As with an `IN` subquery,
the result is `false` for an empty or
+ /// `null` IdSet, even when the value is `null`, and `null` for a `null`
value otherwise. `IDSET` returns a `null`
+ /// IdSet over zero rows when null handling is enabled.
+ ///
+ /// Values arrive as their external Java types. BOOLEAN, TIMESTAMP and UUID
values are looked up through their
+ /// stored INT, LONG and BYTES values, as the transform function does.
+ ///
+ /// `isDeterministic = false` keeps all-literal calls out of compile-time
evaluation, which passes literals as Java
+ /// types the lookup cannot map to a value type (e.g. BigDecimal for an INT
literal). The runtime evaluates them
+ /// instead, as before this function existed. The flag also marks the
function VOLATILE, so ingestion transforms
+ /// cannot use it.
+ ///
+ /// The flag does not stop the multi-stage planner from pushing `IN_ID_SET`
below a join into a leaf stage, because
+ /// the planner uses the operator of the `IN_ID_SET` transform function,
which is deterministic. The operator table
+ /// registers transform functions before scalar functions, and skips this
function because its name is taken. If
+ /// the transform function's operator is removed, this function's volatile
operator takes its place, and
+ /// `PinotRuleUtils.isRelocatable` then keeps `IN_ID_SET` above joins. Tests
in `JoinPlans.json` and
+ /// `PhysicalOptimizerPlans.json` pin the push-down.
+ @Nullable
+ @ScalarFunction(nullableParameters = true, isDeterministic = false)
+ public Boolean inIdSet(@Nullable Object value, @Nullable String
serializedIdSet) {
+ if (serializedIdSet == null) {
+ return false;
+ }
+ IdSet idSet = getIdSet(serializedIdSet);
+ if (value == null) {
+ return idSet.getType() == IdSet.Type.EMPTY ? false : null;
+ }
+ if (idSet != _validatedIdSet || value.getClass() != _validatedValueClass) {
+ IdSets.validateValueType(idSet, getStoredType(value));
+ _validatedIdSet = idSet;
+ _validatedValueClass = value.getClass();
+ }
+ if (value instanceof Integer) {
+ return idSet.contains(((Integer) value).intValue());
+ }
+ if (value instanceof Long) {
+ return idSet.contains(((Long) value).longValue());
+ }
+ if (value instanceof Float) {
+ return idSet.contains(((Float) value).floatValue());
+ }
+ if (value instanceof Double) {
+ return idSet.contains(((Double) value).doubleValue());
+ }
+ if (value instanceof String) {
+ return idSet.contains((String) value);
+ }
+ if (value instanceof byte[]) {
+ return idSet.contains((byte[]) value);
+ }
+ if (value instanceof Boolean) {
+ return idSet.contains((Boolean) value ? 1 : 0);
+ }
+ if (value instanceof Timestamp) {
+ return idSet.contains(((Timestamp) value).getTime());
+ }
+ if (value instanceof UUID) {
+ return idSet.contains(UuidUtils.toBytes((UUID) value));
+ }
+ if (value instanceof ByteArray) {
+ return idSet.contains(((ByteArray) value).getBytes());
+ }
+ throw new IllegalStateException("Unsupported value class: " +
value.getClass().getName());
+ }
+
+ @VisibleForTesting
+ IdSet getIdSet(String serializedIdSet) {
+ if (serializedIdSet != _serializedIdSet) {
+ if (!serializedIdSet.equals(_serializedIdSet)) {
+ _idSet = deserialize(serializedIdSet);
+ }
+ // Keep the latest instance: the following rows of a data block share
it, so they match by reference
+ _serializedIdSet = serializedIdSet;
+ }
+ return _idSet;
+ }
+
+ private static IdSet deserialize(String serializedIdSet) {
+ try {
+ return IdSets.fromBase64String(serializedIdSet);
+ } catch (IOException | RuntimeException e) {
+ throw new IllegalArgumentException("Caught exception while deserializing
IdSet: " + e, e);
+ }
+ }
+
+ private static DataType getStoredType(Object value) {
+ if (value instanceof Integer || value instanceof Boolean) {
+ return DataType.INT;
+ }
+ if (value instanceof Long || value instanceof Timestamp) {
+ return DataType.LONG;
+ }
+ if (value instanceof Float) {
+ return DataType.FLOAT;
+ }
+ if (value instanceof Double) {
+ return DataType.DOUBLE;
+ }
+ if (value instanceof String) {
+ return DataType.STRING;
+ }
+ if (value instanceof byte[] || value instanceof UUID || value instanceof
ByteArray) {
+ return DataType.BYTES;
+ }
+ if (value instanceof BigDecimal) {
+ return DataType.BIG_DECIMAL;
+ }
+ throw new IllegalArgumentException(
+ "Cannot look up values of class " + value.getClass().getName() + " in
an IdSet");
+ }
+}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
index a305ebecb39..ac7594f399a 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/InIdSetTransformFunction.java
@@ -22,6 +22,7 @@ import com.google.common.base.Preconditions;
import java.io.IOException;
import java.util.List;
import java.util.Map;
+import javax.annotation.Nullable;
import org.apache.pinot.common.function.TransformFunctionType;
import org.apache.pinot.core.operator.ColumnContext;
import org.apache.pinot.core.operator.blocks.ValueBlock;
@@ -29,6 +30,7 @@ import
org.apache.pinot.core.operator.transform.TransformResultMetadata;
import org.apache.pinot.core.query.utils.idset.IdSet;
import org.apache.pinot.core.query.utils.idset.IdSets;
import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.roaringbitmap.RoaringBitmap;
/// The IN_ID_SET transform function takes 2 arguments:
@@ -61,9 +63,10 @@ public class InIdSetTransformFunction extends
BaseTransformFunction {
_transformFunction = arguments.get(0);
try {
_idSet = IdSets.fromBase64String(((LiteralTransformFunction)
arguments.get(1)).getStringLiteral());
- } catch (IOException e) {
- throw new IllegalArgumentException("Caught exception while deserializing
IdSet", e);
+ } catch (IOException | RuntimeException e) {
+ throw new IllegalArgumentException("Caught exception while deserializing
IdSet: " + e, e);
}
+ IdSets.validateValueType(_idSet,
_transformFunction.getResultMetadata().getDataType().getStoredType());
}
@Override
@@ -71,6 +74,13 @@ public class InIdSetTransformFunction extends
BaseTransformFunction {
return BOOLEAN_SV_NO_DICTIONARY_METADATA;
}
+ /// As with an `IN` subquery, a NULL value is in no empty IdSet, so the
result is never NULL for an empty IdSet.
+ @Nullable
+ @Override
+ public RoaringBitmap getNullBitmap(ValueBlock valueBlock) {
+ return _idSet.getType() == IdSet.Type.EMPTY ? null :
super.getNullBitmap(valueBlock);
+ }
+
@Override
public int[] transformToIntValuesSV(ValueBlock valueBlock) {
int length = valueBlock.getNumDocs();
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/BloomFilterIdSet.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/BloomFilterIdSet.java
index 3965c84be1b..a0684852851 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/BloomFilterIdSet.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/BloomFilterIdSet.java
@@ -18,14 +18,14 @@
*/
package org.apache.pinot.core.query.utils.idset;
-import com.google.common.base.Preconditions;
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnel;
import com.google.common.hash.Funnels;
-import java.io.ByteArrayInputStream;
+import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
+import java.util.List;
import org.apache.pinot.spi.data.FieldSpec.DataType;
@@ -36,12 +36,16 @@ public class BloomFilterIdSet implements IdSet {
private enum FunnelType {
// DO NOT change the ids as the ser/de relies on them
- INT((byte) 0), LONG((byte) 1), STRING((byte) 2), BYTES((byte) 3);
+ INT((byte) 0, DataType.INT, DataType.FLOAT), LONG((byte) 1, DataType.LONG,
DataType.DOUBLE),
+ STRING((byte) 2, DataType.STRING), BYTES((byte) 3, DataType.BYTES);
private final byte _id;
+ // Stored types whose values hash through this funnel: FLOAT and DOUBLE
values hash as their raw bits
+ private final List<DataType> _valueTypes;
- FunnelType(byte id) {
+ FunnelType(byte id, DataType... valueTypes) {
_id = id;
+ _valueTypes = List.of(valueTypes);
}
public byte getId() {
@@ -93,6 +97,12 @@ public class BloomFilterIdSet implements IdSet {
return _bloomFilter;
}
+ /// Returns the stored types whose values can be looked up in this IdSet.
The serialized form records only the
+ /// funnel, so an IdSet built from INT values also accepts FLOAT values (and
LONG also accepts DOUBLE).
+ List<DataType> getValueTypes() {
+ return _funnelType._valueTypes;
+ }
+
@Override
public Type getType() {
return Type.BLOOM_FILTER;
@@ -174,13 +184,11 @@ public class BloomFilterIdSet implements IdSet {
return byteArrayOutputStream.toByteArray();
}
- /// Deserializes the BloomFilterIdSet from a ByteBuffer.
+ /// Deserializes the BloomFilterIdSet from a ByteBuffer, which may be
read-only or direct.
///
/// NOTE: The ByteBuffer does not include the IdSet.Type byte.
static BloomFilterIdSet fromByteBuffer(ByteBuffer byteBuffer)
throws IOException {
- Preconditions.checkArgument(byteBuffer.hasArray(),
- "Cannot deserialize BloomFilter from ByteBuffer not backed by an
accessible byte array");
// Count the IdSet.Type byte
int serializedSizeInBytes = 1 + byteBuffer.remaining();
byte funnelTypeId = byteBuffer.get();
@@ -204,12 +212,11 @@ public class BloomFilterIdSet implements IdSet {
funnel = Funnels.byteArrayFunnel();
break;
default:
- throw new IllegalStateException();
+ throw new IllegalStateException("Unsupported BloomFilter funnel type
id: " + funnelTypeId);
}
// NOTE: No need to close the stream.
- BloomFilter bloomFilter = BloomFilter.readFrom(
- new ByteArrayInputStream(byteBuffer.array(), byteBuffer.arrayOffset()
+ byteBuffer.position(),
- byteBuffer.remaining()), funnel);
+ // Guava reads the filter 8 bytes at a time, which the Pinot buffer stream
copies byte by byte, so buffer the reads.
+ BloomFilter bloomFilter = BloomFilter.readFrom(new
BufferedInputStream(IdSets.toInputStream(byteBuffer)), funnel);
return new BloomFilterIdSet(funnelType, bloomFilter,
serializedSizeInBytes);
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/IdSets.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/IdSets.java
index 721c4787473..4b440c0ee76 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/IdSets.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/IdSets.java
@@ -21,6 +21,11 @@ package org.apache.pinot.core.query.utils.idset;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Base64;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.pinot.segment.spi.memory.DataBufferPinotInputStream;
+import org.apache.pinot.segment.spi.memory.PinotByteBuffer;
+import org.apache.pinot.segment.spi.memory.PinotInputStream;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.roaringbitmap.PeekableIntIterator;
import org.roaringbitmap.longlong.LongIterator;
@@ -38,6 +43,10 @@ public class IdSets {
public static final int DEFAULT_EXPECTED_INSERTIONS = 5_000_000;
public static final double DEFAULT_FPP = 0.03;
+ // Stored types whose values can be looked up in an IdSet
+ private static final List<DataType> LOOKUP_VALUE_TYPES =
+ List.of(DataType.INT, DataType.LONG, DataType.FLOAT, DataType.DOUBLE,
DataType.STRING, DataType.BYTES);
+
/// Creates an IdSet.
///
/// @param dataType Data type of the ids
@@ -78,6 +87,40 @@ public class IdSets {
return EmptyIdSet.INSTANCE;
}
+ /// Validates that values of the given stored type can be looked up in the
given IdSet. Rejects e.g. LONG values
+ /// in an IdSet built from INT values, where every lookup would otherwise
throw or hash to the wrong bits. An empty
+ /// IdSet accepts every lookup type, as it records no value type.
+ ///
+ /// @throws IllegalArgumentException if values of the stored type cannot be
looked up in the IdSet
+ public static void validateValueType(IdSet idSet, DataType storedType) {
+ if (!LOOKUP_VALUE_TYPES.contains(storedType)) {
+ throw new IllegalArgumentException(
+ String.format("Cannot look up %s values in an IdSet, supported value
types: %s", storedType,
+ LOOKUP_VALUE_TYPES));
+ }
+ List<DataType> valueTypes;
+ switch (idSet.getType()) {
+ case EMPTY:
+ return;
+ case ROARING_BITMAP:
+ valueTypes = List.of(DataType.INT);
+ break;
+ case ROARING_64_NAVIGABLE_MAP:
+ valueTypes = List.of(DataType.LONG);
+ break;
+ case BLOOM_FILTER:
+ valueTypes = ((BloomFilterIdSet) idSet).getValueTypes();
+ break;
+ default:
+ throw new IllegalStateException("Unsupported IdSet type: " +
idSet.getType());
+ }
+ if (!valueTypes.contains(storedType)) {
+ throw new IllegalArgumentException(
+ String.format("Cannot look up %s values in an IdSet built from %s
values", storedType,
+
valueTypes.stream().map(DataType::name).collect(Collectors.joining(" or "))));
+ }
+ }
+
/// Merges 2 IdSets, converts to BloomFilterIdSet if the size exceeds the
size threshold.
///
/// @param idSet1 First IdSet to merge
@@ -187,7 +230,7 @@ public class IdSets {
return fromByteBuffer(ByteBuffer.wrap(bytes));
}
- /// Deserializes the IdSet from a ByteBuffer.
+ /// Deserializes the IdSet from a ByteBuffer, which may be read-only or
direct (e.g. a multi-stage mailbox payload).
public static IdSet fromByteBuffer(ByteBuffer byteBuffer)
throws IOException {
byte typeId = byteBuffer.get();
@@ -201,7 +244,7 @@ public class IdSets {
case 3:
return BloomFilterIdSet.fromByteBuffer(byteBuffer);
default:
- throw new IllegalStateException();
+ throw new IllegalStateException("Unsupported IdSet type id: " +
typeId);
}
}
@@ -212,4 +255,10 @@ public class IdSets {
throws IOException {
return fromBytes(Base64.getDecoder().decode(base64String));
}
+
+ /// Returns a [PinotInputStream] over the remaining bytes of the given
buffer, without moving its position. It reads
+ /// heap, direct and read-only buffers the same way, and its `DataInput`
methods read big-endian values.
+ static PinotInputStream toInputStream(ByteBuffer byteBuffer) {
+ return new DataBufferPinotInputStream(PinotByteBuffer.slice(byteBuffer));
+ }
}
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/Roaring64NavigableMapIdSet.java
b/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/Roaring64NavigableMapIdSet.java
index fc913afae3a..9b0522bae35 100644
---
a/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/Roaring64NavigableMapIdSet.java
+++
b/pinot-core/src/main/java/org/apache/pinot/core/query/utils/idset/Roaring64NavigableMapIdSet.java
@@ -18,10 +18,7 @@
*/
package org.apache.pinot.core.query.utils.idset;
-import com.google.common.base.Preconditions;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
-import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
@@ -77,18 +74,14 @@ public class Roaring64NavigableMapIdSet implements IdSet {
return byteArrayOutputStream.toByteArray();
}
- /// Deserializes the Roaring64NavigableMapIdSet from a ByteBuffer.
+ /// Deserializes the Roaring64NavigableMapIdSet from a ByteBuffer, which may
be read-only or direct.
///
/// NOTE: The ByteBuffer does not include the IdSet.Type byte.
static Roaring64NavigableMapIdSet fromByteBuffer(ByteBuffer byteBuffer)
throws IOException {
- Preconditions.checkArgument(byteBuffer.hasArray(),
- "Cannot deserialize Roaring64NavigableMap from ByteBuffer not backed
by an accessible byte array");
Roaring64NavigableMap roaring64NavigableMap = new Roaring64NavigableMap();
- // NOTE: No need to close these streams.
- roaring64NavigableMap.deserialize(new DataInputStream(
- new ByteArrayInputStream(byteBuffer.array(), byteBuffer.arrayOffset()
+ byteBuffer.position(),
- byteBuffer.remaining())));
+ // NOTE: No need to close the stream.
+ roaring64NavigableMap.deserialize(IdSets.toInputStream(byteBuffer));
return new Roaring64NavigableMapIdSet(roaring64NavigableMap);
}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
index d1a0c076d46..0382d5bca0e 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/function/FunctionRegistryTest.java
@@ -51,8 +51,8 @@ public class FunctionRegistryTest {
TransformFunctionType.JSON_EXTRACT_KEY,
TransformFunctionType.TIME_CONVERT,
TransformFunctionType.DATE_TIME_CONVERT_WINDOW_HOP,
TransformFunctionType.ARRAY_LENGTH,
TransformFunctionType.ARRAY_AVERAGE, TransformFunctionType.ARRAY_MIN,
TransformFunctionType.ARRAY_MAX,
- TransformFunctionType.ARRAY_SUM, TransformFunctionType.VALUE_IN,
TransformFunctionType.IN_ID_SET,
- TransformFunctionType.GROOVY, TransformFunctionType.CLP_DECODE,
TransformFunctionType.CLP_ENCODED_VARS_MATCH,
+ TransformFunctionType.ARRAY_SUM, TransformFunctionType.VALUE_IN,
TransformFunctionType.GROOVY,
+ TransformFunctionType.CLP_DECODE,
TransformFunctionType.CLP_ENCODED_VARS_MATCH,
TransformFunctionType.ST_POLYGON, TransformFunctionType.ST_AREA,
TransformFunctionType.ITEM,
TransformFunctionType.TIME_SERIES_BUCKET);
private static final EnumSet<FilterKind> IGNORED_FILTER_KINDS = EnumSet.of(
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/function/scalar/IdSetFunctionsTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/function/scalar/IdSetFunctionsTest.java
new file mode 100644
index 00000000000..1cbfb1ec635
--- /dev/null
+++
b/pinot-core/src/test/java/org/apache/pinot/core/function/scalar/IdSetFunctionsTest.java
@@ -0,0 +1,219 @@
+/**
+ * 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.core.function.scalar;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.UUID;
+import org.apache.pinot.common.function.FunctionInfo;
+import org.apache.pinot.common.function.FunctionInvoker;
+import org.apache.pinot.common.function.FunctionRegistry;
+import org.apache.pinot.core.query.utils.idset.IdSet;
+import org.apache.pinot.core.query.utils.idset.IdSets;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.utils.ByteArray;
+import org.apache.pinot.spi.utils.UuidUtils;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNotSame;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
+
+
+/// Tests the `inIdSet` scalar function on its own; `IdSet.json` in
pinot-query-runtime runs it in queries.
+public class IdSetFunctionsTest {
+
+ @Test
+ public void testLooksUpEveryValueType()
+ throws IOException {
+ IdSet intIdSet = IdSets.create(DataType.INT);
+ intIdSet.add(1);
+ assertInIdSet(intIdSet, 1, 2);
+ // BOOLEAN values are looked up as their stored INT values
+ assertInIdSet(intIdSet, true, false);
+
+ IdSet longIdSet = IdSets.create(DataType.LONG);
+ longIdSet.add(10_000_000_000L);
+ assertInIdSet(longIdSet, 10_000_000_000L, 1L);
+ // TIMESTAMP values are looked up as their stored LONG values
+ assertInIdSet(longIdSet, new Timestamp(10_000_000_000L), new
Timestamp(1L));
+
+ IdSet floatIdSet = createBloomFilterIdSet(DataType.FLOAT);
+ floatIdSet.add(1.5f);
+ assertInIdSet(floatIdSet, 1.5f, 2.5f);
+
+ IdSet doubleIdSet = createBloomFilterIdSet(DataType.DOUBLE);
+ doubleIdSet.add(1.25);
+ assertInIdSet(doubleIdSet, 1.25, 2.25);
+
+ IdSet stringIdSet = createBloomFilterIdSet(DataType.STRING);
+ stringIdSet.add("foo");
+ assertInIdSet(stringIdSet, "foo", "bar");
+
+ IdSet bytesIdSet = createBloomFilterIdSet(DataType.BYTES);
+ bytesIdSet.add(new byte[]{1, 2});
+ assertInIdSet(bytesIdSet, new byte[]{1, 2}, new byte[]{3});
+ // Single-stage post-aggregation passes BYTES group keys as ByteArray
+ assertInIdSet(bytesIdSet, new ByteArray(new byte[]{1, 2}), new
ByteArray(new byte[]{3}));
+
+ // UUID values are looked up as their stored BYTES values
+ UUID uuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000");
+ IdSet uuidIdSet = createBloomFilterIdSet(DataType.BYTES);
+ uuidIdSet.add(UuidUtils.toBytes(uuid));
+ assertInIdSet(uuidIdSet, uuid,
UUID.fromString("123e4567-e89b-12d3-a456-426614174001"));
+ }
+
+ private static IdSet createBloomFilterIdSet(DataType dataType) {
+ // Sized so that a single id cannot produce a false positive
+ return IdSets.create(dataType, 0, 100, 0.0001);
+ }
+
+ private static void assertInIdSet(IdSet idSet, Object member, Object
nonMember)
+ throws IOException {
+ IdSetFunctions idSetFunctions = new IdSetFunctions();
+ String serializedIdSet = idSet.toBase64String();
+ assertEquals(idSetFunctions.inIdSet(member, serializedIdSet),
Boolean.TRUE);
+ assertEquals(idSetFunctions.inIdSet(nonMember, serializedIdSet),
Boolean.FALSE);
+ }
+
+ @Test
+ public void testNullArguments()
+ throws IOException {
+ IdSet intIdSet = IdSets.create(DataType.INT);
+ intIdSet.add(1);
+ IdSetFunctions idSetFunctions = new IdSetFunctions();
+ // Whether NULL is in a non-empty IdSet is unknown, as for an IN subquery
+ assertNull(idSetFunctions.inIdSet(null, intIdSet.toBase64String()));
+ // A null IdSet, which IDSET returns over zero rows with null handling
enabled, holds no ids, so no value is in it,
+ // not even NULL
+ assertEquals(idSetFunctions.inIdSet(1, null), Boolean.FALSE);
+ assertEquals(idSetFunctions.inIdSet(null, null), Boolean.FALSE);
+ // The same holds for an empty IdSet, which IDSET returns over zero rows
with null handling disabled
+ assertEquals(idSetFunctions.inIdSet(null,
IdSets.emptyIdSet().toBase64String()), Boolean.FALSE);
+ }
+
+ @Test
+ public void testEmptyIdSetAcceptsEveryValueType()
+ throws IOException {
+ String serializedEmptyIdSet = IdSets.emptyIdSet().toBase64String();
+ IdSetFunctions idSetFunctions = new IdSetFunctions();
+ for (Object value : List.of(1, 1L, 1.5f, 1.25, "foo", new byte[]{1}, true,
new Timestamp(1L))) {
+ assertEquals(idSetFunctions.inIdSet(value, serializedEmptyIdSet),
Boolean.FALSE);
+ }
+ }
+
+ @Test
+ public void testRejectsUnsupportedValueTypes()
+ throws IOException {
+ IdSet intIdSet = IdSets.create(DataType.INT);
+ intIdSet.add(1);
+ String serializedIntIdSet = intIdSet.toBase64String();
+ IdSetFunctions idSetFunctions = new IdSetFunctions();
+ IllegalArgumentException exception =
+ expectThrows(IllegalArgumentException.class, () ->
idSetFunctions.inIdSet(1L, serializedIntIdSet));
+ assertEquals(exception.getMessage(), "Cannot look up LONG values in an
IdSet built from INT values");
+ exception = expectThrows(IllegalArgumentException.class,
+ () -> idSetFunctions.inIdSet(new BigDecimal("1"), serializedIntIdSet));
+ assertTrue(exception.getMessage().startsWith("Cannot look up BIG_DECIMAL
values in an IdSet"),
+ exception.getMessage());
+ exception =
+ expectThrows(IllegalArgumentException.class, () ->
idSetFunctions.inIdSet(new int[]{1}, serializedIntIdSet));
+ assertTrue(exception.getMessage().startsWith("Cannot look up values of
class"), exception.getMessage());
+
+ // Each value class is validated against the IdSet it is looked up in,
also after another class passed
+ assertEquals(idSetFunctions.inIdSet(1, serializedIntIdSet), Boolean.TRUE);
+ expectThrows(IllegalArgumentException.class, () ->
idSetFunctions.inIdSet(1L, serializedIntIdSet));
+ IdSet longIdSet = IdSets.create(DataType.LONG);
+ longIdSet.add(1L);
+ assertEquals(idSetFunctions.inIdSet(1L, longIdSet.toBase64String()),
Boolean.TRUE);
+ expectThrows(IllegalArgumentException.class, () ->
idSetFunctions.inIdSet(1, longIdSet.toBase64String()));
+ }
+
+ @Test
+ public void testFollowsTheIdSetOfEachCall()
+ throws IOException {
+ IdSet idSet1 = IdSets.create(DataType.INT);
+ idSet1.add(1);
+ IdSet idSet2 = IdSets.create(DataType.INT);
+ idSet2.add(2);
+ String serializedIdSet1 = idSet1.toBase64String();
+ String serializedIdSet2 = idSet2.toBase64String();
+ // One instance serves one call site, whose IdSet can change from row to
row when it is read from a column
+ IdSetFunctions idSetFunctions = new IdSetFunctions();
+ assertEquals(idSetFunctions.inIdSet(1, serializedIdSet1), Boolean.TRUE);
+ assertEquals(idSetFunctions.inIdSet(2, serializedIdSet1), Boolean.FALSE);
+ assertEquals(idSetFunctions.inIdSet(1, serializedIdSet2), Boolean.FALSE);
+ assertEquals(idSetFunctions.inIdSet(2, serializedIdSet2), Boolean.TRUE);
+ // An equal but distinct String instance holds the same IdSet
+ assertEquals(idSetFunctions.inIdSet(2, new String(serializedIdSet2)),
Boolean.TRUE);
+ assertEquals(idSetFunctions.inIdSet(1, new String(serializedIdSet1)),
Boolean.TRUE);
+ }
+
+ @Test
+ public void testReusesTheDeserializedIdSet()
+ throws IOException {
+ IdSet idSet1 = IdSets.create(DataType.INT);
+ idSet1.add(1);
+ String serializedIdSet1 = idSet1.toBase64String();
+ IdSetFunctions idSetFunctions = new IdSetFunctions();
+ IdSet deserializedIdSet = idSetFunctions.getIdSet(serializedIdSet1);
+ assertSame(idSetFunctions.getIdSet(serializedIdSet1), deserializedIdSet);
+ // Rows of a new data block carry a new but equal String instance
+ assertSame(idSetFunctions.getIdSet(new String(serializedIdSet1)),
deserializedIdSet);
+ // A different IdSet is deserialized
+ IdSet idSet2 = IdSets.create(DataType.INT);
+ idSet2.add(2);
+ assertNotSame(idSetFunctions.getIdSet(idSet2.toBase64String()),
deserializedIdSet);
+ }
+
+ @Test
+ public void testRejectsMalformedIdSet() {
+ IdSetFunctions idSetFunctions = new IdSetFunctions();
+ // Not Base64
+ IllegalArgumentException exception =
+ expectThrows(IllegalArgumentException.class, () ->
idSetFunctions.inIdSet(1, "not an IdSet"));
+ assertTrue(exception.getMessage().startsWith("Caught exception while
deserializing IdSet"), exception.getMessage());
+ // Valid Base64 for an unknown IdSet type id
+ exception = expectThrows(IllegalArgumentException.class, () ->
idSetFunctions.inIdSet(1, "CQ=="));
+ assertTrue(exception.getMessage().contains("Unsupported IdSet type id:
9"), exception.getMessage());
+ }
+
+ @Test
+ public void testRegisteredAsScalarFunction()
+ throws IOException {
+ FunctionInfo functionInfo = FunctionRegistry.lookupFunctionInfo("inidset",
2);
+ assertNotNull(functionInfo);
+ assertEquals(functionInfo.getMethod().getDeclaringClass(),
IdSetFunctions.class);
+ // Kept out of compile-time evaluation, which passes literals as types the
lookup cannot use
+ assertFalse(functionInfo.isDeterministic());
+ // The parameters are nullable, so the invoker passes null arguments
through instead of returning null
+ FunctionInvoker functionInvoker = new FunctionInvoker(functionInfo);
+ assertEquals(functionInvoker.invoke(new Object[]{1, null}), Boolean.FALSE);
+ IdSet intIdSet = IdSets.create(DataType.INT);
+ intIdSet.add(1);
+ assertNull(functionInvoker.invoke(new Object[]{null,
intIdSet.toBase64String()}));
+ }
+}
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/query/utils/idset/IdSetTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/query/utils/idset/IdSetTest.java
index b75c5c4b741..179a5310b23 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/query/utils/idset/IdSetTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/query/utils/idset/IdSetTest.java
@@ -21,6 +21,9 @@ package org.apache.pinot.core.query.utils.idset;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
import java.util.Random;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.testng.annotations.BeforeClass;
@@ -29,6 +32,7 @@ import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertSame;
import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
public class IdSetTest {
@@ -129,6 +133,78 @@ public class IdSetTest {
assertEquals(IdSets.fromBase64String(_longBloomFilterIdSet.toBase64String()),
_longBloomFilterIdSet);
}
+ @Test
+ public void testDeserializeFromReadOnlyAndDirectBuffers()
+ throws IOException {
+ IdSet stringBloomFilterIdSet = IdSets.create(DataType.STRING, 0,
NUM_VALUES, IdSets.DEFAULT_FPP);
+ IdSet bytesBloomFilterIdSet = IdSets.create(DataType.BYTES, 0, NUM_VALUES,
IdSets.DEFAULT_FPP);
+ for (int intId : _intSet) {
+ String stringId = Integer.toString(intId);
+ stringBloomFilterIdSet.add(stringId);
+ bytesBloomFilterIdSet.add(stringId.getBytes(StandardCharsets.UTF_8));
+ }
+ List<IdSet> idSets =
+ List.of(IdSets.emptyIdSet(), _intIdSet, _longIdSet,
_intBloomFilterIdSet, _longBloomFilterIdSet,
+ stringBloomFilterIdSet, bytesBloomFilterIdSet);
+ for (IdSet idSet : idSets) {
+ byte[] bytes = idSet.toBytes();
+ // The multi-stage engine reads intermediate results from read-only
buffers, which expose no backing array
+ ByteBuffer directBuffer = ByteBuffer.allocateDirect(bytes.length);
+ directBuffer.put(bytes).flip();
+ // Serialized bytes that start inside a larger array, as a positioned
buffer and as a slice
+ byte[] paddedBytes = new byte[bytes.length + 3];
+ System.arraycopy(bytes, 0, paddedBytes, 3, bytes.length);
+ List<ByteBuffer> byteBuffers =
+ List.of(ByteBuffer.wrap(bytes),
ByteBuffer.wrap(bytes).asReadOnlyBuffer(), directBuffer,
+ ByteBuffer.wrap(paddedBytes, 3, bytes.length),
ByteBuffer.wrap(paddedBytes, 3, bytes.length).slice(),
+ ByteBuffer.wrap(paddedBytes, 3,
bytes.length).asReadOnlyBuffer());
+ for (ByteBuffer byteBuffer : byteBuffers) {
+ assertEquals(IdSets.fromByteBuffer(byteBuffer), idSet, idSet.getType()
+ " from " + byteBuffer);
+ }
+ }
+ }
+
+ @Test
+ public void testValidateValueType()
+ throws IOException {
+ assertValueTypes(IdSets.emptyIdSet(), DataType.INT, DataType.LONG,
DataType.FLOAT, DataType.DOUBLE,
+ DataType.STRING, DataType.BYTES);
+ assertValueTypes(_intIdSet, DataType.INT);
+ assertValueTypes(_longIdSet, DataType.LONG);
+ // A Bloom filter funnel serves 2 stored types: FLOAT values hash as int
bits, DOUBLE values as long bits
+ assertValueTypes(_intBloomFilterIdSet, DataType.INT, DataType.FLOAT);
+ assertValueTypes(IdSets.create(DataType.FLOAT, 0, NUM_VALUES,
IdSets.DEFAULT_FPP), DataType.INT, DataType.FLOAT);
+ assertValueTypes(_longBloomFilterIdSet, DataType.LONG, DataType.DOUBLE);
+ assertValueTypes(IdSets.create(DataType.DOUBLE, 0, NUM_VALUES,
IdSets.DEFAULT_FPP), DataType.LONG,
+ DataType.DOUBLE);
+ assertValueTypes(IdSets.create(DataType.STRING, 0, NUM_VALUES,
IdSets.DEFAULT_FPP), DataType.STRING);
+ assertValueTypes(IdSets.create(DataType.BYTES, 0, NUM_VALUES,
IdSets.DEFAULT_FPP), DataType.BYTES);
+ // The funnel survives serialization
+ assertValueTypes(IdSets.fromBytes(_intBloomFilterIdSet.toBytes()),
DataType.INT, DataType.FLOAT);
+ assertValueTypes(IdSets.fromBytes(_longBloomFilterIdSet.toBytes()),
DataType.LONG, DataType.DOUBLE);
+
+ IllegalArgumentException exception =
expectThrows(IllegalArgumentException.class,
+ () -> IdSets.validateValueType(_intBloomFilterIdSet, DataType.STRING));
+ assertEquals(exception.getMessage(), "Cannot look up STRING values in an
IdSet built from INT or FLOAT values");
+ // Even an empty IdSet rejects value types that no IdSet can hold
+ exception = expectThrows(IllegalArgumentException.class,
+ () -> IdSets.validateValueType(IdSets.emptyIdSet(),
DataType.BIG_DECIMAL));
+ assertTrue(exception.getMessage().startsWith("Cannot look up BIG_DECIMAL
values in an IdSet"),
+ exception.getMessage());
+ }
+
+ private static void assertValueTypes(IdSet idSet, DataType... acceptedTypes)
{
+ List<DataType> acceptedTypeList = List.of(acceptedTypes);
+ for (DataType storedType : List.of(DataType.INT, DataType.LONG,
DataType.FLOAT, DataType.DOUBLE, DataType.STRING,
+ DataType.BYTES, DataType.BIG_DECIMAL)) {
+ if (acceptedTypeList.contains(storedType)) {
+ IdSets.validateValueType(idSet, storedType);
+ } else {
+ expectThrows(IllegalArgumentException.class, () ->
IdSets.validateValueType(idSet, storedType));
+ }
+ }
+ }
+
@Test
public void testMerge()
throws IOException {
diff --git
a/pinot-core/src/test/java/org/apache/pinot/queries/IdSetQueriesTest.java
b/pinot-core/src/test/java/org/apache/pinot/queries/IdSetQueriesTest.java
index 4fc6a1c92da..5fd0b32b373 100644
--- a/pinot-core/src/test/java/org/apache/pinot/queries/IdSetQueriesTest.java
+++ b/pinot-core/src/test/java/org/apache/pinot/queries/IdSetQueriesTest.java
@@ -50,6 +50,7 @@ import org.apache.pinot.spi.config.table.TableType;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.exception.BadQueryRequestException;
import org.apache.pinot.spi.utils.ReadMode;
import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
import org.testng.annotations.AfterClass;
@@ -60,6 +61,7 @@ import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
/// Queries test for ID_SET queries.
@@ -83,17 +85,22 @@ public class IdSetQueriesTest extends BaseQueriesTest {
private static final String FLOAT_MV_COLUMN = "floatMVColumn";
private static final String DOUBLE_MV_COLUMN = "doubleMVColumn";
private static final String STRING_MV_COLUMN = "stringMVColumn";
+ // Holds the INT value, except for every 4th record, which holds NULL
+ private static final String NULLABLE_INT_COLUMN = "nullableIntColumn";
+ private static final int NUM_NULL_RECORDS = NUM_RECORDS / 4;
private static final Schema SCHEMA = new
Schema.SchemaBuilder().addSingleValueDimension(INT_COLUMN, DataType.INT)
.addSingleValueDimension(LONG_COLUMN,
DataType.LONG).addSingleValueDimension(FLOAT_COLUMN, DataType.FLOAT)
.addSingleValueDimension(DOUBLE_COLUMN,
DataType.DOUBLE).addSingleValueDimension(STRING_COLUMN, DataType.STRING)
.addSingleValueDimension(BYTES_COLUMN,
DataType.BYTES).addMultiValueDimension(INT_MV_COLUMN, DataType.INT)
.addMultiValueDimension(LONG_MV_COLUMN,
DataType.LONG).addMultiValueDimension(FLOAT_MV_COLUMN, DataType.FLOAT)
.addMultiValueDimension(DOUBLE_MV_COLUMN, DataType.DOUBLE)
- .addMultiValueDimension(STRING_MV_COLUMN, DataType.STRING).build();
+ .addMultiValueDimension(STRING_MV_COLUMN, DataType.STRING)
+ .addSingleValueDimension(NULLABLE_INT_COLUMN, DataType.INT).build();
private static final TableConfig TABLE_CONFIG =
- new
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).build();
+ new
TableConfigBuilder(TableType.OFFLINE).setTableName(RAW_TABLE_NAME).setNullHandlingEnabled(true).build();
private final int[] _values = new int[NUM_RECORDS];
+ private final List<GenericRow> _records = new ArrayList<>(NUM_RECORDS);
private IndexSegment _indexSegment;
private List<IndexSegment> _indexSegments;
@@ -139,8 +146,10 @@ public class IdSetQueriesTest extends BaseQueriesTest {
record.putValue(FLOAT_MV_COLUMN, new Float[]{floatValue, floatValue +
MAX_VALUE});
record.putValue(DOUBLE_MV_COLUMN, new Double[]{doubleValue, doubleValue
+ MAX_VALUE});
record.putValue(STRING_MV_COLUMN, new String[]{stringValue, stringValue
+ MAX_VALUE});
+ record.putValue(NULLABLE_INT_COLUMN, i % 4 == 0 ? null : intValue);
records.add(record);
}
+ _records.addAll(records);
SegmentGeneratorConfig segmentGeneratorConfig = new
SegmentGeneratorConfig(TABLE_CONFIG, SCHEMA);
segmentGeneratorConfig.setTableName(RAW_TABLE_NAME);
@@ -563,6 +572,150 @@ public class IdSetQueriesTest extends BaseQueriesTest {
}
}
+ @Test
+ public void testInIdSetOnEveryValueType()
+ throws IOException {
+ testInIdSet(INT_COLUMN, IdSets.create(DataType.INT));
+ testInIdSet(LONG_COLUMN, IdSets.create(DataType.LONG));
+ // Bloom filters sized for the records, to keep the literals small
+ testInIdSet(FLOAT_COLUMN, createBloomFilterIdSet(DataType.FLOAT));
+ testInIdSet(DOUBLE_COLUMN, createBloomFilterIdSet(DataType.DOUBLE));
+ testInIdSet(STRING_COLUMN, createBloomFilterIdSet(DataType.STRING));
+ testInIdSet(BYTES_COLUMN, createBloomFilterIdSet(DataType.BYTES));
+ }
+
+ private static IdSet createBloomFilterIdSet(DataType dataType) {
+ return IdSets.create(dataType, IdSets.DEFAULT_SIZE_THRESHOLD_IN_BYTES,
NUM_RECORDS, IdSets.DEFAULT_FPP);
+ }
+
+ /// Fills the IdSet with the column values of the first half of the records,
then checks that IN_ID_SET matches
+ /// the records the IdSet contains, including any Bloom filter false
positives.
+ private void testInIdSet(String column, IdSet idSet)
+ throws IOException {
+ for (int i = 0; i < NUM_RECORDS / 2; i++) {
+ Object value = _records.get(i).getValue(column);
+ if (value instanceof Integer) {
+ idSet.add((int) value);
+ } else if (value instanceof Long) {
+ idSet.add((long) value);
+ } else if (value instanceof Float) {
+ idSet.add((float) value);
+ } else if (value instanceof Double) {
+ idSet.add((double) value);
+ } else if (value instanceof String) {
+ idSet.add((String) value);
+ } else {
+ idSet.add((byte[]) value);
+ }
+ }
+ long expectedNumMatchingRecords = 0;
+ for (GenericRow record : _records) {
+ Object value = record.getValue(column);
+ boolean contains;
+ if (value instanceof Integer) {
+ contains = idSet.contains((int) value);
+ } else if (value instanceof Long) {
+ contains = idSet.contains((long) value);
+ } else if (value instanceof Float) {
+ contains = idSet.contains((float) value);
+ } else if (value instanceof Double) {
+ contains = idSet.contains((double) value);
+ } else if (value instanceof String) {
+ contains = idSet.contains((String) value);
+ } else {
+ contains = idSet.contains((byte[]) value);
+ }
+ if (contains) {
+ expectedNumMatchingRecords++;
+ }
+ }
+ String query =
+ "SELECT COUNT(*) FROM testTable WHERE IN_ID_SET(" + column + ", '" +
idSet.toBase64String() + "') = 1";
+ AggregationOperator aggregationOperator = getOperator(query);
+ List<Object> aggregationResult =
aggregationOperator.nextBlock().getResults();
+ assertNotNull(aggregationResult);
+ assertEquals((long) aggregationResult.get(0), expectedNumMatchingRecords,
column);
+ }
+
+ @Test
+ public void testInIdSetInHaving()
+ throws IOException {
+ // IdSets of the int and long values of the first half of the records
+ IdSet intIdSet = IdSets.create(DataType.INT);
+ IdSet longIdSet = IdSets.create(DataType.LONG);
+ for (int i = 0; i < NUM_RECORDS / 2; i++) {
+ intIdSet.add(_values[i]);
+ longIdSet.add(_values[i] + (long) Integer.MAX_VALUE);
+ }
+ long expectedNumGroups =
Arrays.stream(_values).distinct().filter(intIdSet::contains).count();
+
+ // HAVING on a group key
+ BrokerResponseNative brokerResponse = getBrokerResponse(
+ "SELECT intColumn, COUNT(*) FROM testTable GROUP BY intColumn HAVING
IN_ID_SET(intColumn, '"
+ + intIdSet.toBase64String() + "') = 1 LIMIT " + NUM_RECORDS);
+ assertTrue(brokerResponse.getExceptions().isEmpty(),
brokerResponse.getExceptions().toString());
+ assertEquals(brokerResponse.getResultTable().getRows().size(),
expectedNumGroups);
+
+ // HAVING on an aggregation, which returns DOUBLE here, so it is cast to
the type of the IdSet. Every group holds a
+ // single int value, so its maximum long value is in the IdSet when its
int value is.
+ brokerResponse = getBrokerResponse(
+ "SELECT stringColumn, MAX(longColumn) FROM testTable GROUP BY
stringColumn HAVING IN_ID_SET(CAST(MAX("
+ + "longColumn) AS LONG), '" + longIdSet.toBase64String() + "') = 1
LIMIT " + NUM_RECORDS);
+ assertTrue(brokerResponse.getExceptions().isEmpty(),
brokerResponse.getExceptions().toString());
+ assertEquals(brokerResponse.getResultTable().getRows().size(),
expectedNumGroups);
+
+ // Without the cast, this RoaringBitmap-backed LONG IdSet rejects the
DOUBLE values. A LONG IdSet large enough to be
+ // a Bloom filter would accept them and hash their raw bits, missing every
group, because LONG and DOUBLE values
+ // share a funnel.
+ brokerResponse = getBrokerResponse(
+ "SELECT stringColumn, MAX(longColumn) FROM testTable GROUP BY
stringColumn HAVING IN_ID_SET(MAX(longColumn), '"
+ + longIdSet.toBase64String() + "') = 1 LIMIT " + NUM_RECORDS);
+ assertTrue(brokerResponse.getExceptions().toString()
+ .contains("Cannot look up DOUBLE values in an IdSet built from
LONG values"),
+ brokerResponse.getExceptions().toString());
+ }
+
+ @Test
+ public void testInIdSetRejectsMismatchedValueType()
+ throws IOException {
+ IdSet idSet = IdSets.create(DataType.INT);
+ idSet.add(_values[0]);
+ String query = "SELECT COUNT(*) FROM testTable WHERE IN_ID_SET(longColumn,
'" + idSet.toBase64String() + "') = 1";
+ BadQueryRequestException exception =
expectThrows(BadQueryRequestException.class, () -> getOperator(query));
+ assertTrue(exception.getMessage().contains("Cannot look up LONG values in
an IdSet built from INT values"),
+ exception.getMessage());
+ }
+
+ /// As with an `IN` subquery, no value is in an empty IdSet, not even NULL.
With null handling enabled, `IN_ID_SET` is
+ /// FALSE for a NULL value when the IdSet is empty, and NULL when it is not.
+ @Test
+ public void testInIdSetNullHandling()
+ throws IOException {
+ IdSet idSet = IdSets.create(DataType.INT);
+ for (int value : _values) {
+ idSet.add(value);
+ }
+ // 2 instances with 2 segments each
+ int numRecords = 4 * NUM_RECORDS;
+ int numNullRecords = 4 * NUM_NULL_RECORDS;
+
+ String emptyIdSet = IdSets.emptyIdSet().toBase64String();
+ assertCount(NULLABLE_INT_COLUMN, emptyIdSet, 1, 0);
+ assertCount(NULLABLE_INT_COLUMN, emptyIdSet, 0, numRecords);
+ // Whether a NULL value is in a non-empty IdSet is unknown, so neither
filter keeps the NULL records
+ assertCount(NULLABLE_INT_COLUMN, idSet.toBase64String(), 1, numRecords -
numNullRecords);
+ assertCount(NULLABLE_INT_COLUMN, idSet.toBase64String(), 0, 0);
+ }
+
+ private void assertCount(String column, String serializedIdSet, int inIdSet,
long expectedCount) {
+ BrokerResponseNative brokerResponse = getBrokerResponse(
+ "SET enableNullHandling = true; SELECT COUNT(*) FROM testTable WHERE
IN_ID_SET(" + column + ", '"
+ + serializedIdSet + "') = " + inIdSet);
+ assertTrue(brokerResponse.getExceptions().isEmpty(),
brokerResponse.getExceptions().toString());
+ assertEquals(brokerResponse.getResultTable().getRows().get(0)[0],
expectedCount,
+ "IN_ID_SET = " + inIdSet + " with IdSet " + serializedIdSet);
+ }
+
@AfterClass
public void tearDown()
throws IOException {
diff --git
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
new file mode 100644
index 00000000000..1c4b2e43af3
--- /dev/null
+++
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/IdSetIntegrationTest.java
@@ -0,0 +1,219 @@
+/**
+ * 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.integration.tests.custom;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.File;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.avro.file.DataFileWriter;
+import org.apache.avro.generic.GenericData;
+import org.apache.pinot.core.query.utils.idset.IdSet;
+import org.apache.pinot.core.query.utils.idset.IdSets;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// IDSET and IN_ID_SET in both query engines, on a cluster with 2 servers. A
set built by either engine must filter
+/// the same rows in the other one.
+@Test(suiteName = "CustomClusterIntegrationTest")
+public class IdSetIntegrationTest extends
CustomDataQueryClusterIntegrationTest {
+ private static final String TABLE_NAME = "IdSetIntegrationTest";
+ private static final String ID = "id";
+ private static final String INT_COL = "intCol";
+ private static final String LONG_COL = "longCol";
+ private static final String STR_COL = "strCol";
+ private static final String BYTES_COL = "bytesCol";
+ private static final int NUM_ROWS = 1000;
+ // Row i holds value i % NUM_VALUES, so each value appears in NUM_ROWS /
NUM_VALUES rows, and the sets built from the
+ // rows with id < NUM_SET_VALUES hold values 0 to NUM_SET_VALUES - 1
+ private static final int NUM_VALUES = 200;
+ private static final int NUM_SET_VALUES = 60;
+ private static final String SET_FILTER = ID + " < " + NUM_SET_VALUES;
+ // STRING and BYTES sets are Bloom filters; these params keep their literals
short
+ private static final String BLOOM_FILTER_PARAMS =
"expectedInsertions=1000;fpp=0.0001";
+
+ @Override
+ protected long getCountStarResult() {
+ return NUM_ROWS;
+ }
+
+ @Test
+ public void testIdSetIsTheSameInBothEngines()
+ throws Exception {
+ for (String column : List.of(INT_COL, LONG_COL, STR_COL, BYTES_COL)) {
+ String query = "SELECT " + idSetExpression(column) + " FROM " +
getTableName() + " WHERE " + SET_FILTER;
+ setUseMultiStageQueryEngine(false);
+ String singleStageIdSet = getSingleValue(postQuery(query)).asText();
+ setUseMultiStageQueryEngine(true);
+ String multiStageIdSet = getSingleValue(postQuery(query)).asText();
+ assertEquals(multiStageIdSet, singleStageIdSet, column);
+ }
+ }
+
+ @Test(dataProvider = "useBothQueryEngines")
+ public void testFilterBySetFromEitherEngine(boolean
buildSetWithMultiStageEngine)
+ throws Exception {
+ for (String column : List.of(INT_COL, LONG_COL, STR_COL, BYTES_COL)) {
+ setUseMultiStageQueryEngine(buildSetWithMultiStageEngine);
+ String idSet = getSingleValue(
+ postQuery("SELECT " + idSetExpression(column) + " FROM " +
getTableName() + " WHERE " + SET_FILTER)).asText();
+ String inQuery =
+ "SELECT COUNT(*) FROM " + getTableName() + " WHERE IN_ID_SET(" +
column + ", '" + idSet + "') = 1";
+ String notInQuery =
+ "SELECT COUNT(*) FROM " + getTableName() + " WHERE IN_ID_SET(" +
column + ", '" + idSet + "') = 0";
+ setUseMultiStageQueryEngine(false);
+ long singleStageCount = getSingleValue(postQuery(inQuery)).asLong();
+ assertEquals(getSingleValue(postQuery(notInQuery)).asLong(), NUM_ROWS -
singleStageCount, column);
+ setUseMultiStageQueryEngine(true);
+ long multiStageCount = getSingleValue(postQuery(inQuery)).asLong();
+ assertEquals(getSingleValue(postQuery(notInQuery)).asLong(), NUM_ROWS -
multiStageCount, column);
+ assertEquals(multiStageCount, singleStageCount, column);
+ assertEquals(multiStageCount, getExpectedCount(column,
IdSets.fromBase64String(idSet)), column);
+ }
+ }
+
+ @Test
+ public void testScalarSubquery()
+ throws Exception {
+ // The set argument is a scalar subquery, so IN_ID_SET runs as a scalar
function above the join that supplies it
+ setUseMultiStageQueryEngine(true);
+ for (String column : List.of(INT_COL, LONG_COL, STR_COL, BYTES_COL)) {
+ String idSetQuery = "SELECT " + idSetExpression(column) + " FROM " +
getTableName() + " WHERE " + SET_FILTER;
+ String query =
+ "SELECT COUNT(*) FROM " + getTableName() + " WHERE IN_ID_SET(" +
column + ", (" + idSetQuery + ")) = 1";
+ IdSet idSet =
IdSets.fromBase64String(getSingleValue(postQuery(idSetQuery)).asText());
+ assertEquals(getSingleValue(postQuery(query)).asLong(),
getExpectedCount(column, idSet), column);
+ }
+ }
+
+ @Test(dataProvider = "useBothQueryEngines")
+ public void testMismatchedValueType(boolean useMultiStageQueryEngine)
+ throws Exception {
+ setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+ IdSet intIdSet = IdSets.create(DataType.INT);
+ intIdSet.add(1);
+ JsonNode response = postQuery(
+ "SELECT COUNT(*) FROM " + getTableName() + " WHERE IN_ID_SET(" +
LONG_COL + ", '" + intIdSet.toBase64String()
+ + "') = 1");
+ String exceptions = response.get("exceptions").toString();
+ assertTrue(exceptions.contains("Cannot look up LONG values in an IdSet
built from INT values"), exceptions);
+ }
+
+ private static String idSetExpression(String column) {
+ if (column.equals(STR_COL) || column.equals(BYTES_COL)) {
+ return "IDSET(" + column + ", '" + BLOOM_FILTER_PARAMS + "')";
+ }
+ return "IDSET(" + column + ")";
+ }
+
+ /// Returns the number of rows whose value of the column the IdSet contains,
which includes any Bloom filter false
+ /// positives. INT and LONG sets are exact, so they hold exactly the values
of the rows with id < NUM_SET_VALUES.
+ private static long getExpectedCount(String column, IdSet idSet) {
+ long numValuesInSet = 0;
+ for (int value = 0; value < NUM_VALUES; value++) {
+ boolean contains;
+ switch (column) {
+ case INT_COL:
+ contains = idSet.contains(value);
+ break;
+ case LONG_COL:
+ contains = idSet.contains(10_000_000_000L + value);
+ break;
+ case STR_COL:
+ contains = idSet.contains(stringValue(value));
+ break;
+ default:
+ contains =
idSet.contains(stringValue(value).getBytes(StandardCharsets.UTF_8));
+ break;
+ }
+ if (contains) {
+ numValuesInSet++;
+ } else if (value < NUM_SET_VALUES) {
+ throw new AssertionError(column + " set misses value " + value);
+ }
+ }
+ if (column.equals(INT_COL) || column.equals(LONG_COL)) {
+ assertEquals(numValuesInSet, NUM_SET_VALUES, column);
+ }
+ return numValuesInSet * (NUM_ROWS / NUM_VALUES);
+ }
+
+ private static String stringValue(int value) {
+ return "value_" + value;
+ }
+
+ private static JsonNode getSingleValue(JsonNode response) {
+ assertTrue(response.get("exceptions").isEmpty(),
response.get("exceptions").toString());
+ return response.get("resultTable").get("rows").get(0).get(0);
+ }
+
+ @Override
+ public String getTableName() {
+ return TABLE_NAME;
+ }
+
+ @Override
+ public Schema createSchema() {
+ return new Schema.SchemaBuilder().setSchemaName(getTableName())
+ .addSingleValueDimension(ID, DataType.INT)
+ .addSingleValueDimension(INT_COL, DataType.INT)
+ .addSingleValueDimension(LONG_COL, DataType.LONG)
+ .addSingleValueDimension(STR_COL, DataType.STRING)
+ .addSingleValueDimension(BYTES_COL, DataType.BYTES)
+ .build();
+ }
+
+ @Override
+ public List<File> createAvroFiles()
+ throws Exception {
+ org.apache.avro.Schema avroSchema =
org.apache.avro.Schema.createRecord("myRecord", null, null, false);
+ avroSchema.setFields(List.of(
+ new org.apache.avro.Schema.Field(ID,
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.INT), null,
+ null),
+ new org.apache.avro.Schema.Field(INT_COL,
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.INT), null,
+ null),
+ new org.apache.avro.Schema.Field(LONG_COL,
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.LONG),
+ null, null),
+ new org.apache.avro.Schema.Field(STR_COL,
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.STRING),
+ null, null),
+ new org.apache.avro.Schema.Field(BYTES_COL,
org.apache.avro.Schema.create(org.apache.avro.Schema.Type.BYTES),
+ null, null)));
+ try (AvroFilesAndWriters avroFilesAndWriters =
createAvroFilesAndWriters(avroSchema)) {
+ List<DataFileWriter<GenericData.Record>> writers =
avroFilesAndWriters.getWriters();
+ for (int i = 0; i < NUM_ROWS; i++) {
+ int value = i % NUM_VALUES;
+ String stringValue = stringValue(value);
+ GenericData.Record record = new GenericData.Record(avroSchema);
+ record.put(ID, i);
+ record.put(INT_COL, value);
+ record.put(LONG_COL, 10_000_000_000L + value);
+ record.put(STR_COL, stringValue);
+ record.put(BYTES_COL,
ByteBuffer.wrap(stringValue.getBytes(StandardCharsets.UTF_8)));
+ writers.get(i % getNumAvroFiles()).append(record);
+ }
+ return avroFilesAndWriters.getAvroFiles();
+ }
+ }
+}
diff --git
a/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
b/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
index 5c44ac2f86b..5fb9cfcb4b6 100644
---
a/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
+++
b/pinot-integration-tests/src/test/resources/udf-test-results/all-functions.yaml
@@ -866,7 +866,7 @@ in:
transform:
"org.apache.pinot.core.operator.transform.function.InTransformFunction"
udf: null
inidset:
- scalar: null
+ scalar:
"ArgumentCountBasedScalarFunction{org.apache.pinot.core.function.scalar.IdSetFunctions.inIdSet}"
transform:
"org.apache.pinot.core.operator.transform.function.InIdSetTransformFunction"
udf: null
innerproduct:
diff --git a/pinot-query-planner/src/test/resources/queries/JoinPlans.json
b/pinot-query-planner/src/test/resources/queries/JoinPlans.json
index fa6b9c0c892..b68b0d4a0a5 100644
--- a/pinot-query-planner/src/test/resources/queries/JoinPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/JoinPlans.json
@@ -64,6 +64,20 @@
"\n"
]
},
+ {
+ "description": "Inner join with an IN_ID_SET filter on one table,
which is pushed into the leaf stage",
+ "sql": "EXPLAIN PLAN FOR SELECT * FROM a JOIN b ON a.col1 = b.col2
WHERE IN_ID_SET(a.col3, 'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "output": [
+ "Execution Plan",
+ "\nLogicalJoin(condition=[=($0, $10)], joinType=[inner])",
+ "\n PinotLogicalExchange(distribution=[hash[0]])",
+ "\n LogicalFilter(condition=[INIDSET($2,
_UTF-8'ATowAAABAAAAAAACABAAAAACAAQABgA=')])",
+ "\n PinotLogicalTableScan(table=[[default, a]])",
+ "\n PinotLogicalExchange(distribution=[hash[1]])",
+ "\n PinotLogicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
{
"description": "SELECT * inner join with filter",
"sql": "EXPLAIN PLAN FOR SELECT * FROM a JOIN b ON a.col1 = b.col2
WHERE a.col3 >= 0 AND a.col3 > b.col3",
diff --git
a/pinot-query-planner/src/test/resources/queries/PhysicalOptimizerPlans.json
b/pinot-query-planner/src/test/resources/queries/PhysicalOptimizerPlans.json
index 771374c9fc5..92de24ed38e 100644
--- a/pinot-query-planner/src/test/resources/queries/PhysicalOptimizerPlans.json
+++ b/pinot-query-planner/src/test/resources/queries/PhysicalOptimizerPlans.json
@@ -263,6 +263,21 @@
},
"physical_opt_join_planning_tests": {
"queries": [
+ {
+ "description": "Inner join with an IN_ID_SET filter on one table,
which is pushed into the leaf stage",
+ "sql": "SET usePhysicalOptimizer=true; EXPLAIN PLAN FOR SELECT * FROM
a JOIN b ON a.col1 = b.col2 WHERE IN_ID_SET(a.col3,
'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "output": [
+ "Execution Plan",
+ "\nPhysicalExchange(exchangeStrategy=[SINGLETON_EXCHANGE])",
+ "\n PhysicalJoin(condition=[=($0, $10)], joinType=[inner])",
+ "\n PhysicalExchange(exchangeStrategy=[PARTITIONING_EXCHANGE],
distKeys=[[0]])",
+ "\n PhysicalFilter(condition=[INIDSET($2,
_UTF-8'ATowAAABAAAAAAACABAAAAACAAQABgA=')])",
+ "\n PhysicalTableScan(table=[[default, a]])",
+ "\n PhysicalExchange(exchangeStrategy=[PARTITIONING_EXCHANGE],
distKeys=[[1]])",
+ "\n PhysicalTableScan(table=[[default, b]])",
+ "\n"
+ ]
+ },
{
"description": "Inner join with order by",
"sql": "SET usePhysicalOptimizer=true; EXPLAIN PLAN FOR SELECT a.col1,
a.ts, b.col3 FROM a JOIN b ON a.col1 = b.col2 ORDER BY a.col1",
diff --git a/pinot-query-runtime/src/test/resources/queries/IdSet.json
b/pinot-query-runtime/src/test/resources/queries/IdSet.json
new file mode 100644
index 00000000000..65b462c35bf
--- /dev/null
+++ b/pinot-query-runtime/src/test/resources/queries/IdSet.json
@@ -0,0 +1,360 @@
+{
+ "idset_filters": {
+ "comments": "IDSET builds the set in the multi-stage engine and IN_ID_SET
consumes it, compared with the equivalent IN subquery in H2. A scalar subquery
as the IdSet argument is joined in, so IN_ID_SET runs as a scalar function
above the join; a literal IdSet on base table columns runs in the leaf stage.
The STRING, BYTES, FLOAT and DOUBLE sets are Bloom filters, sized so that these
few values cannot produce false positives.",
+ "tables": {
+ "tbl": {
+ "schema": [
+ {"name": "intCol", "type": "INT"},
+ {"name": "longCol", "type": "LONG"},
+ {"name": "floatCol", "type": "FLOAT"},
+ {"name": "doubleCol", "type": "DOUBLE"},
+ {"name": "strCol", "type": "STRING"},
+ {"name": "bytesCol", "type": "BYTES"},
+ {"name": "boolCol", "type": "BOOLEAN"},
+ {"name": "tsCol", "type": "TIMESTAMP"},
+ {"name": "grp", "type": "STRING"}
+ ],
+ "inputs": [
+ [1, 10000000001, 1.5, 1.25, "a", "AA01", true, "2020-01-01
00:00:00", "g1"],
+ [2, 10000000002, 2.5, 2.25, "b", "AA02", true, "2020-01-02
00:00:00", "g1"],
+ [3, 10000000003, 3.5, 3.25, "c", "AA03", false, "2020-01-03
00:00:00", "g1"],
+ [4, 10000000004, 4.5, 4.25, "d", "AA04", false, "2020-01-04
00:00:00", "g2"],
+ ["------"],
+ [5, 10000000005, 5.5, 5.25, "e", "AA05", true, "2020-01-05
00:00:00", "g2"],
+ [6, 10000000006, 6.5, 6.25, "f", "AA06", false, "2020-01-06
00:00:00", "g2"]
+ ]
+ },
+ "tbl2": {
+ "schema": [
+ {"name": "intCol", "type": "INT"},
+ {"name": "longCol", "type": "LONG"},
+ {"name": "floatCol", "type": "FLOAT"},
+ {"name": "doubleCol", "type": "DOUBLE"},
+ {"name": "strCol", "type": "STRING"},
+ {"name": "bytesCol", "type": "BYTES"},
+ {"name": "boolCol", "type": "BOOLEAN"},
+ {"name": "tsCol", "type": "TIMESTAMP"},
+ {"name": "grp", "type": "STRING"}
+ ],
+ "inputs": [
+ [2, 10000000002, 2.5, 2.25, "b", "AA02", true, "2020-01-02
00:00:00", "g1"],
+ [3, 10000000003, 3.5, 3.25, "c", "AA03", false, "2020-01-03
00:00:00", "g1"],
+ [4, 10000000004, 4.5, 4.25, "d", "AA04", true, "2020-01-04
00:00:00", "g2"],
+ ["------"],
+ [7, 10000000007, 7.5, 7.25, "g", "AA07", false, "2020-01-07
00:00:00", "g2"],
+ [8, 10000000008, 8.5, 8.25, "h", "AA08", true, "2020-01-08
00:00:00", "g3"],
+ [2, 10000000002, 2.5, 2.25, "b", "AA02", false, "2020-01-02
00:00:00", "g3"],
+ [9, 10000000009, 9.5, 9.25, "i", "AA09", false, "2020-01-09
00:00:00", "g4"],
+ [10, 10000000010, 10.5, 10.25, "j", "AA10", true, "2020-01-10
00:00:00", "g4"],
+ [11, 10000000011, 11.5, 11.25, "k", "AA11", true, "2020-01-11
00:00:00", "g4"]
+ ]
+ }
+ },
+ "queries": [
+ {
+ "description": "INT set (RoaringBitmap) from a scalar subquery",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(intCol, (SELECT
IDSET(intCol) FROM {tbl} WHERE grp = 'g1')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol IN (SELECT intCol
FROM {tbl} WHERE grp = 'g1')"
+ },
+ {
+ "description": "INT set (RoaringBitmap), negated",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(intCol, (SELECT
IDSET(intCol) FROM {tbl} WHERE grp = 'g1')) = 0",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol NOT IN (SELECT
intCol FROM {tbl} WHERE grp = 'g1')"
+ },
+ {
+ "description": "INT set forced into a Bloom filter",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(intCol, (SELECT
IDSET(intCol, 'sizeThresholdInBytes=0;expectedInsertions=100;fpp=0.0001') FROM
{tbl} WHERE grp = 'g2')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol IN (SELECT intCol
FROM {tbl} WHERE grp = 'g2')"
+ },
+ {
+ "description": "LONG set (Roaring64NavigableMap)",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(longCol, (SELECT
IDSET(longCol) FROM {tbl} WHERE grp = 'g1')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE longCol IN (SELECT longCol
FROM {tbl} WHERE grp = 'g1')"
+ },
+ {
+ "description": "FLOAT set (Bloom filter)",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(floatCol, (SELECT
IDSET(floatCol, 'expectedInsertions=100;fpp=0.0001') FROM {tbl} WHERE grp =
'g1')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE floatCol IN (SELECT
floatCol FROM {tbl} WHERE grp = 'g1')"
+ },
+ {
+ "description": "DOUBLE set (Bloom filter)",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(doubleCol, (SELECT
IDSET(doubleCol, 'expectedInsertions=100;fpp=0.0001') FROM {tbl} WHERE grp =
'g2')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE doubleCol IN (SELECT
doubleCol FROM {tbl} WHERE grp = 'g2')"
+ },
+ {
+ "description": "STRING set (Bloom filter)",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(strCol, (SELECT
IDSET(strCol, 'expectedInsertions=100;fpp=0.0001') FROM {tbl} WHERE grp =
'g1')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE strCol IN (SELECT strCol
FROM {tbl} WHERE grp = 'g1')"
+ },
+ {
+ "description": "A 9 MB STRING set crosses the mailbox in more than one
chunk",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(strCol, (SELECT
IDSET(strCol, 'expectedInsertions=10000000;fpp=0.03') FROM {tbl} WHERE grp =
'g1')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE strCol IN (SELECT strCol
FROM {tbl} WHERE grp = 'g1')"
+ },
+ {
+ "description": "BYTES set (Bloom filter)",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(bytesCol, (SELECT
IDSET(bytesCol, 'expectedInsertions=100;fpp=0.0001') FROM {tbl} WHERE grp =
'g2')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE bytesCol IN (SELECT
bytesCol FROM {tbl} WHERE grp = 'g2')"
+ },
+ {
+ "description": "BOOLEAN set, looked up through the stored INT value",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(boolCol, (SELECT
IDSET(boolCol) FROM {tbl} WHERE intCol = 1)) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE boolCol IN (SELECT boolCol
FROM {tbl} WHERE intCol = 1)"
+ },
+ {
+ "description": "TIMESTAMP set, looked up through the stored LONG
value",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(tsCol, (SELECT
IDSET(tsCol) FROM {tbl} WHERE grp = 'g1')) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE tsCol IN (SELECT tsCol
FROM {tbl} WHERE grp = 'g1')"
+ },
+ {
+ "description": "A subquery that matches no rows yields a NULL set with
null handling on, which holds no ids",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(intCol, (SELECT
IDSET(intCol) FROM {tbl} WHERE intCol < 0)) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol IN (SELECT intCol
FROM {tbl} WHERE intCol < 0)"
+ },
+ {
+ "description": "A subquery that matches no rows yields a NULL set with
null handling on, negated",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(intCol, (SELECT
IDSET(intCol) FROM {tbl} WHERE intCol < 0)) = 0",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol NOT IN (SELECT
intCol FROM {tbl} WHERE intCol < 0)"
+ },
+ {
+ "description": "A subquery that matches no rows yields an empty set
with null handling off",
+ "sql": "SET enableNullHandling=false; SELECT COUNT(*) FROM {tbl2}
WHERE IN_ID_SET(intCol, (SELECT IDSET(intCol) FROM {tbl} WHERE intCol < 0)) =
1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol IN (SELECT intCol
FROM {tbl} WHERE intCol < 0)"
+ },
+ {
+ "description": "A subquery that matches no rows yields an empty set
with null handling off, negated",
+ "sql": "SET enableNullHandling=false; SELECT COUNT(*) FROM {tbl2}
WHERE IN_ID_SET(intCol, (SELECT IDSET(intCol) FROM {tbl} WHERE intCol < 0)) =
0",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol NOT IN (SELECT
intCol FROM {tbl} WHERE intCol < 0)"
+ },
+ {
+ "description": "Correlated subquery: one set per group, built by a
grouped IDSET",
+ "sql": "SELECT COUNT(*) FROM {tbl2} b WHERE IN_ID_SET(b.intCol,
(SELECT IDSET(a.intCol) FROM {tbl} a WHERE a.grp = b.grp)) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} b WHERE b.intCol IN (SELECT
a.intCol FROM {tbl} a WHERE a.grp = b.grp)"
+ },
+ {
+ "description": "Correlated subquery, negated: groups with no match see
an empty set",
+ "sql": "SELECT COUNT(*) FROM {tbl2} b WHERE IN_ID_SET(b.intCol,
(SELECT IDSET(a.intCol) FROM {tbl} a WHERE a.grp = b.grp)) = 0",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} b WHERE b.intCol NOT IN (SELECT
a.intCol FROM {tbl} a WHERE a.grp = b.grp)"
+ },
+ {
+ "description": "CTE cross join supplies the set as a column",
+ "sql": "WITH s AS (SELECT IDSET(strCol,
'expectedInsertions=100;fpp=0.0001') AS ids FROM {tbl} WHERE grp = 'g2') SELECT
COUNT(*) FROM {tbl2}, s WHERE IN_ID_SET({tbl2}.strCol, s.ids) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE strCol IN (SELECT strCol
FROM {tbl} WHERE grp = 'g2')"
+ },
+ {
+ "description": "HAVING with a set from a scalar subquery",
+ "sql": "SELECT grp, COUNT(*) FROM {tbl2} GROUP BY grp HAVING
IN_ID_SET(MAX(intCol), (SELECT IDSET(intCol) FROM {tbl})) = 1",
+ "h2Sql": "SELECT grp, COUNT(*) FROM {tbl2} GROUP BY grp HAVING
MAX(intCol) IN (SELECT intCol FROM {tbl})"
+ },
+ {
+ "description": "Post-join filter on columns of both sides, set from a
scalar subquery",
+ "sql": "SELECT COUNT(*) FROM {tbl} a JOIN {tbl2} b ON a.grp = b.grp
WHERE IN_ID_SET(a.intCol + b.intCol, (SELECT IDSET(intCol) FROM {tbl2})) = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} a JOIN {tbl2} b ON a.grp = b.grp
WHERE a.intCol + b.intCol IN (SELECT intCol FROM {tbl2})"
+ },
+ {
+ "description": "Literal set in a leaf filter (the two-query flow); the
set holds 2, 4 and 6",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(intCol,
'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol IN (2, 4, 6)"
+ },
+ {
+ "description": "Literal set in a leaf filter, bare boolean and
negated",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE NOT IN_ID_SET(intCol,
'ATowAAABAAAAAAACABAAAAACAAQABgA=')",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl2} WHERE intCol NOT IN (2, 4, 6)"
+ },
+ {
+ "description": "Literal set in a post-join filter on columns of both
sides",
+ "sql": "SELECT COUNT(*) FROM {tbl} a JOIN {tbl2} b ON a.grp = b.grp
WHERE IN_ID_SET(a.intCol + b.intCol, 'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} a JOIN {tbl2} b ON a.grp = b.grp
WHERE a.intCol + b.intCol IN (2, 4, 6)"
+ },
+ {
+ "description": "Literal set in a join condition",
+ "sql": "SELECT COUNT(*) FROM {tbl} a JOIN {tbl2} b ON a.grp = b.grp
AND IN_ID_SET(a.intCol + b.intCol, 'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} a JOIN {tbl2} b ON a.grp = b.grp
AND a.intCol + b.intCol IN (2, 4, 6)"
+ },
+ {
+ "description": "Literal set in HAVING",
+ "sql": "SELECT grp, COUNT(*) FROM {tbl2} GROUP BY grp HAVING
IN_ID_SET(MAX(intCol), 'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "h2Sql": "SELECT grp, COUNT(*) FROM {tbl2} GROUP BY grp HAVING
MAX(intCol) IN (2, 4, 6)"
+ },
+ {
+ "description": "Scalar path rejects a set of another value type",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(intCol, (SELECT
IDSET(longCol) FROM {tbl})) = 1",
+ "expectedException": ".*Cannot look up INT values in an IdSet built
from LONG values.*"
+ },
+ {
+ "description": "Leaf path rejects a set of another value type",
+ "sql": "SELECT COUNT(*) FROM {tbl2} WHERE IN_ID_SET(longCol,
'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "expectedException": ".*Cannot look up LONG values in an IdSet built
from INT values.*"
+ }
+ ]
+ },
+ "idset_nullable_probe": {
+ "comments": "IN_ID_SET returns NULL for a NULL value, except with an empty
or NULL set, where it is false, as for an IN subquery. The planner must type it
as nullable, or it simplifies IS NOT TRUE and COALESCE away. NULL values come
from outer joins and from nullable columns, which the planner sees only with
column-based null handling.",
+ "extraProps": {
+ "enableColumnBasedNullHandling": true
+ },
+ "tables": {
+ "tbl": {
+ "schema": [
+ {"name": "intCol", "type": "INT"}
+ ],
+ "inputs": [
+ [1],
+ [2],
+ ["------"],
+ [3],
+ [4]
+ ]
+ },
+ "tbl2": {
+ "schema": [
+ {"name": "intCol", "type": "INT"}
+ ],
+ "inputs": [
+ [2],
+ [4]
+ ]
+ },
+ "nullable": {
+ "schema": [
+ {"name": "intCol", "type": "INT"}
+ ],
+ "inputs": [
+ [1],
+ [2],
+ [null]
+ ]
+ }
+ },
+ "queries": [
+ {
+ "description": "IS NOT TRUE over the NULL values of a left join; the
set holds 2, 4 and 6",
+ "sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol =
b.intCol WHERE IN_ID_SET(b.intCol, 'ATowAAABAAAAAAACABAAAAACAAQABgA=') IS NOT
TRUE",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol
= b.intCol WHERE (b.intCol IN (2, 4, 6)) IS NOT TRUE"
+ },
+ {
+ "description": "IS FALSE over the NULL values of a left join",
+ "sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol =
b.intCol WHERE IN_ID_SET(b.intCol, 'ATowAAABAAAAAAACABAAAAACAAQABgA=') IS
FALSE",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol
= b.intCol WHERE (b.intCol IN (2, 4, 6)) IS FALSE"
+ },
+ {
+ "description": "NOT COALESCE over the NULL values of a left join",
+ "sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol =
b.intCol WHERE NOT COALESCE(IN_ID_SET(b.intCol,
'ATowAAABAAAAAAACABAAAAACAAQABgA='), FALSE)",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol
= b.intCol WHERE NOT COALESCE(b.intCol IN (2, 4, 6), FALSE)"
+ },
+ {
+ "description": "COALESCE in the SELECT list over the NULL values of a
left join",
+ "sql": "SELECT a.intCol, COALESCE(IN_ID_SET(b.intCol,
'ATowAAABAAAAAAACABAAAAACAAQABgA='), FALSE) FROM {tbl} a LEFT JOIN {tbl2} b ON
a.intCol = b.intCol",
+ "h2Sql": "SELECT a.intCol, COALESCE(b.intCol IN (2, 4, 6), FALSE) FROM
{tbl} a LEFT JOIN {tbl2} b ON a.intCol = b.intCol"
+ },
+ {
+ "description": "= 0 over the NULL values of a left join drops them,
like NOT IN",
+ "sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol =
b.intCol WHERE IN_ID_SET(b.intCol, 'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 0",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} a LEFT JOIN {tbl2} b ON a.intCol
= b.intCol WHERE b.intCol NOT IN (2, 4, 6)"
+ },
+ {
+ "description": "IS NOT TRUE on a nullable column in a leaf stage",
+ "sql": "SELECT COUNT(*) FROM {nullable} WHERE IN_ID_SET(intCol,
'ATowAAABAAAAAAACABAAAAACAAQABgA=') IS NOT TRUE",
+ "h2Sql": "SELECT COUNT(*) FROM {nullable} WHERE (intCol IN (2, 4, 6))
IS NOT TRUE"
+ },
+ {
+ "description": "A NULL value is in no empty set, from a subquery that
matches no rows",
+ "sql": "SELECT COUNT(*) FROM {nullable} WHERE IN_ID_SET(intCol,
(SELECT IDSET(intCol) FROM {tbl} WHERE intCol < 0)) = 0",
+ "h2Sql": "SELECT COUNT(*) FROM {nullable} WHERE intCol NOT IN (SELECT
intCol FROM {tbl} WHERE intCol < 0)"
+ },
+ {
+ "description": "A NULL value is in no empty set, from a literal in a
leaf stage",
+ "sql": "SELECT COUNT(*) FROM {nullable} WHERE IN_ID_SET(intCol,
'AA==') = 0",
+ "h2Sql": "SELECT COUNT(*) FROM {nullable} WHERE intCol NOT IN (SELECT
intCol FROM {tbl} WHERE intCol < 0)"
+ },
+ {
+ "description": "A literal value is left to the runtime instead of
being folded at planning time",
+ "sql": "SELECT COUNT(*) FROM {tbl} WHERE IN_ID_SET(2,
'ATowAAABAAAAAAACABAAAAACAAQABgA=') = 1",
+ "h2Sql": "SELECT COUNT(*) FROM {tbl} WHERE 2 IN (2, 4, 6)"
+ }
+ ]
+ },
+ "idset_serialized_output": {
+ "comments": "IDSET in the multi-stage engine returns the same bytes as an
IdSet built directly from the values, which is what the single-stage engine
returns, so a set built by either engine works in the other.",
+ "tables": {
+ "tbl": {
+ "schema": [
+ {"name": "intCol", "type": "INT"},
+ {"name": "longCol", "type": "LONG"},
+ {"name": "strCol", "type": "STRING"},
+ {"name": "grp", "type": "STRING"}
+ ],
+ "inputs": [
+ [1, 10000000001, "a", "g1"],
+ [2, 10000000002, "b", "g1"],
+ [3, 10000000003, "c", "g1"],
+ ["------"],
+ [4, 10000000004, "d", "g2"],
+ [5, 10000000005, "e", "g2"],
+ [6, 10000000006, "f", "g2"]
+ ]
+ }
+ },
+ "queries": [
+ {
+ "description": "INT values 1 to 6 as a RoaringBitmap",
+ "sql": "SELECT IDSET(intCol) FROM {tbl}",
+ "outputs": [["ATowAAABAAAAAAAFABAAAAABAAIAAwAEAAUABgA="]]
+ },
+ {
+ "description": "LONG values as a Roaring64NavigableMap",
+ "sql": "SELECT IDSET(longCol) FROM {tbl}",
+ "outputs": [["AgAAAAABAAAAAjowAAABAAAAC1QFABAAAAAB5ALkA+QE5AXkBuQ="]]
+ },
+ {
+ "description": "STRING values as a Bloom filter",
+ "sql": "SELECT IDSET(strCol, 'expectedInsertions=100;fpp=0.0001') FROM
{tbl}",
+ "outputs":
[["AwIBDQAAAB4AAoACAAEAAkAAAAAIAACAgCBAABAAAAAAABAAAAAAAASgAAAAAAAAAAAABAAAAAAgAAAEAACAAgAAAAAgAAEAQAAAAABAgAAIAAIAAEAAAAAAAQAAAAAAAAQAAAAAAAgAAEAAAAAAAAAAAAAAAAAAAAAAQCIQAAAAAAAgAAAAAACQAAACAAAAAAAAAAwAAACAAAAEgAAAAAAAAgAAAACABEAAAAIAQAAAAAAgASAAAAAAACAAAIAAIAgEAAAAAEAAEACAAAAAAAAASAAAACAAACAIAEAAAAgQAAAAAAAAAAAAAIQAAgAABAAAAAIEAAA="]]
+ },
+ {
+ "description": "One set per group",
+ "sql": "SELECT grp, IDSET(intCol) FROM {tbl} GROUP BY grp",
+ "outputs": [
+ ["g1", "ATowAAABAAAAAAACABAAAAABAAIAAwA="],
+ ["g2", "ATowAAABAAAAAAACABAAAAAEAAUABgA="]
+ ]
+ }
+ ]
+ },
+ "idset_multi_value": {
+ "comments": "IDSET over a multi-value column adds every element.",
+ "tables": {
+ "mv": {
+ "schema": [
+ {"name": "mvCol", "type": "INT", "isSingleValue": false}
+ ],
+ "inputs": [
+ [[1, 2]],
+ [[3]]
+ ]
+ },
+ "probe": {
+ "schema": [
+ {"name": "intCol", "type": "INT"}
+ ],
+ "inputs": [
+ [1],
+ [2],
+ [3],
+ [4],
+ [5]
+ ]
+ }
+ },
+ "queries": [
+ {
+ "sql": "SELECT COUNT(*) FROM {probe} WHERE IN_ID_SET(intCol, (SELECT
IDSET(mvCol) FROM {mv})) = 1",
+ "outputs": [[3]]
+ },
+ {
+ "sql": "SELECT COUNT(*) FROM {probe} WHERE IN_ID_SET(intCol, (SELECT
IDSET(mvCol) FROM {mv})) = 0",
+ "outputs": [[2]]
+ }
+ ]
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]