diff --git a/.gitignore b/.gitignore
index bbd3d34656..3d0535d0aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,3 +58,4 @@ kubernetes/helm/**/Chart.lock
#Develocity
.mvn/.gradle-enterprise/
.mvn/.develocity/
+.claude/
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/datablock/DataBlockEquals.java b/pinot-common/src/main/java/org/apache/pinot/common/datablock/DataBlockEquals.java
index 2f8a2e9054..f022c8b7eb 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/datablock/DataBlockEquals.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/datablock/DataBlockEquals.java
@@ -295,6 +295,7 @@ public boolean equals(DataBlock left, DataBlock right) {
break;
case STRING:
case JSON:
+ case URN:
for (int did = 0; did < numRows; did++) {
if (!left.getString(did, colId).equals(right.getString(did, colId))) {
if (_failOnFalse) {
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionUtils.java b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionUtils.java
index c1445e98bd..81fcdbf105 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionUtils.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/function/FunctionUtils.java
@@ -193,6 +193,7 @@ public static RelDataType getRelDataType(RelDataTypeFactory typeFactory, Class
return typeFactory.createSqlType(SqlTypeName.TIMESTAMP);
case STRING:
case JSON:
+ case URN:
return typeFactory.createSqlType(SqlTypeName.VARCHAR);
case BYTES:
return typeFactory.createSqlType(SqlTypeName.VARBINARY);
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/UrnFunctions.java b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/UrnFunctions.java
new file mode 100644
index 0000000000..91bc4989e5
--- /dev/null
+++ b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/UrnFunctions.java
@@ -0,0 +1,190 @@
+/**
+ * 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.common.function.scalar;
+
+import javax.annotation.Nullable;
+import org.apache.pinot.spi.annotations.ScalarFunction;
+import org.apache.pinot.spi.utils.Urn;
+
+
+/**
+ * Scalar functions for working with URN-typed columns.
+ *
+ *
All functions accept a URN string and return a component of it. They are null-safe: a
+ * {@code null} input always produces a {@code null} output. An unparseable (malformed) URN
+ * produces a {@code null} rather than throwing so that queries over mixed-quality data do not fail
+ * entirely.
+ *
+ *
Example usage in SQL:
+ *
+ * SELECT urnEntity(memberId), COUNT(*) FROM myTable GROUP BY 1
+ * SELECT urnKey(memberId) FROM myTable WHERE urnEntity(memberId) = 'urn:li:memberId'
+ * SELECT * FROM myTable WHERE urnEntityType(memberId) = 'memberId'
+ * SELECT urnIsComposite(datasetId), urnKeyPart(datasetId, 1) FROM myTable
+ *
+ */
+public class UrnFunctions {
+ private UrnFunctions() {
+ }
+
+ /**
+ * Returns the fully-qualified entity type prefix of a URN, e.g. {@code "urn:li:memberId"} for
+ * {@code "urn:li:memberId:123456"}.
+ *
+ *
This is the canonical string to use when grouping or filtering on entity type — it includes
+ * the full namespace, so {@code "urn:li:memberId"} is unambiguous across different namespaces.
+ */
+ @Nullable
+ @ScalarFunction(names = {"urnEntity", "urn_entity"}, nullableParameters = true)
+ public static String urnEntity(@Nullable String urnStr) {
+ if (urnStr == null) {
+ return null;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ return urn != null ? urn.entityPrefix() : null;
+ }
+
+ /**
+ * Returns the entity namespace component, e.g. {@code "li"} for {@code "urn:li:memberId:123"}.
+ */
+ @Nullable
+ @ScalarFunction(names = {"urnNamespace", "urn_namespace"}, nullableParameters = true)
+ public static String urnNamespace(@Nullable String urnStr) {
+ if (urnStr == null) {
+ return null;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ return urn != null ? urn.namespace() : null;
+ }
+
+ /**
+ * Returns the entity type component (without namespace), e.g. {@code "memberId"} for
+ * {@code "urn:li:memberId:123456"}.
+ *
+ *
Useful for grouping by entity type across namespaces or when the namespace is constant.
+ */
+ @Nullable
+ @ScalarFunction(names = {"urnEntityType", "urn_entity_type"}, nullableParameters = true)
+ public static String urnEntityType(@Nullable String urnStr) {
+ if (urnStr == null) {
+ return null;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ return urn != null ? urn.entityType() : null;
+ }
+
+ /**
+ * Returns the key component of a simple (non-composite) URN as a string, e.g. {@code "123456"}
+ * for {@code "urn:li:memberId:123456"}.
+ *
+ *
For composite URNs the full tuple string including parentheses is returned, e.g.
+ * {@code "(urn:li:dataPlatform:pinot,memberEducation,EI)"} for a dataset URN.
+ */
+ @Nullable
+ @ScalarFunction(names = {"urnKey", "urn_key"}, nullableParameters = true)
+ public static String urnKey(@Nullable String urnStr) {
+ if (urnStr == null) {
+ return null;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ if (urn == null) {
+ return null;
+ }
+ if (urn.isSimple()) {
+ return urn.simpleKey();
+ }
+ // Composite: reconstruct the key tuple string
+ StringBuilder sb = new StringBuilder("(");
+ for (int i = 0; i < urn.parts().size(); i++) {
+ if (i > 0) {
+ sb.append(',');
+ }
+ sb.append(urn.parts().get(i).toString());
+ }
+ sb.append(')');
+ return sb.toString();
+ }
+
+ /**
+ * Returns the key of a simple URN as a long, or {@code Long.MIN_VALUE} if the key is not
+ * numeric. Returns {@code null} on null input or unparseable URN.
+ *
+ *
This is the most efficient function for numeric-key URN columns — it allows the query
+ * engine to operate directly on long values without repeated string-to-long conversion.
+ */
+ @Nullable
+ @ScalarFunction(names = {"urnKeyLong", "urn_key_long"}, nullableParameters = true)
+ public static Long urnKeyLong(@Nullable String urnStr) {
+ if (urnStr == null) {
+ return null;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ if (urn == null || !urn.isSimple()) {
+ return null;
+ }
+ try {
+ return Long.parseLong(urn.simpleKey());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns {@code true} if the URN is composite (its key is a tuple), {@code false} if it is
+ * simple. Returns {@code null} on null input or unparseable URN.
+ */
+ @Nullable
+ @ScalarFunction(names = {"urnIsComposite", "urn_is_composite"}, nullableParameters = true)
+ public static Boolean urnIsComposite(@Nullable String urnStr) {
+ if (urnStr == null) {
+ return null;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ return urn != null ? !urn.isSimple() : null;
+ }
+
+ /**
+ * Returns the {@code index}-th part of a composite URN's tuple key as a string (0-based).
+ * Returns {@code null} for simple URNs, out-of-range indices, null input, or unparseable URN.
+ *
+ *
Example: {@code urnKeyPart('urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)', 1)}
+ * returns {@code "foo"}.
+ */
+ @Nullable
+ @ScalarFunction(names = {"urnKeyPart", "urn_key_part"}, nullableParameters = true)
+ public static String urnKeyPart(@Nullable String urnStr, int index) {
+ if (urnStr == null) {
+ return null;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ if (urn == null || urn.isSimple() || index < 0 || index >= urn.parts().size()) {
+ return null;
+ }
+ return urn.parts().get(index).toString();
+ }
+
+ /**
+ * Returns {@code true} if the string is a valid, parseable URN; {@code false} otherwise.
+ * Never returns {@code null}; a null input returns {@code false}.
+ */
+ @ScalarFunction(names = {"isUrn", "is_urn"}, nullableParameters = true)
+ public static boolean isUrn(@Nullable String value) {
+ return Urn.isUrn(value);
+ }
+}
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/ArrowResponseEncoder.java b/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/ArrowResponseEncoder.java
index a17eeb7053..09ac5c6fa1 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/ArrowResponseEncoder.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/ArrowResponseEncoder.java
@@ -108,6 +108,7 @@ private VectorSchemaRoot createVectorSchemaRoot(ResultTable resultTable, DataSch
case BYTES:
case BIG_DECIMAL:
case JSON:
+ case URN:
case OBJECT:
field = new Field(colName, FieldType.nullable(new ArrowType.Utf8()), null);
vector = new VarCharVector(colName, ALLOCATOR);
@@ -234,6 +235,7 @@ private VectorSchemaRoot createVectorSchemaRoot(ResultTable resultTable, DataSch
case BYTES:
case BIG_DECIMAL:
case JSON:
+ case URN:
case OBJECT:
byte[] bytes = ((String) value).getBytes(StandardCharsets.UTF_8);
((VarCharVector) vector).setSafe(rowIndex, bytes);
@@ -410,6 +412,7 @@ public ResultTable decodeResultTable(byte[] bytes, int rowSize, DataSchema schem
case BYTES:
case BIG_DECIMAL:
case JSON:
+ case URN:
case OBJECT:
row[col] = new String(((VarCharVector) vector).get(i), StandardCharsets.UTF_8);
break;
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/JsonResponseEncoder.java b/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/JsonResponseEncoder.java
index 8cac71ea40..70e0d689a4 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/JsonResponseEncoder.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/response/encoder/JsonResponseEncoder.java
@@ -223,6 +223,7 @@ private static Object extractValue(DataSchema.ColumnDataType columnDataType, Jso
case BYTES:
case TIMESTAMP:
case JSON:
+ case URN:
case BIG_DECIMAL:
case OBJECT:
return jsonValue.textValue();
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/utils/DataSchema.java b/pinot-common/src/main/java/org/apache/pinot/common/utils/DataSchema.java
index 405ab99378..b75abd7921 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/utils/DataSchema.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/utils/DataSchema.java
@@ -290,6 +290,12 @@ public RelDataType toType(RelDataTypeFactory typeFactory) {
return typeFactory.createSqlType(SqlTypeName.VARCHAR);
}
},
+ URN(STRING, NullValuePlaceHolder.STRING) {
+ @Override
+ public RelDataType toType(RelDataTypeFactory typeFactory) {
+ return typeFactory.createSqlType(SqlTypeName.VARCHAR);
+ }
+ },
MAP(NullValuePlaceHolder.MAP) {
@Override
public RelDataType toType(RelDataTypeFactory typeFactory) {
@@ -452,6 +458,8 @@ public DataType toDataType() {
return DataType.STRING;
case JSON:
return DataType.JSON;
+ case URN:
+ return DataType.URN;
case BYTES:
case BYTES_ARRAY:
return DataType.BYTES;
@@ -571,6 +579,7 @@ public Serializable convert(Object value) {
return new Timestamp((long) value);
case STRING:
case JSON:
+ case URN:
return value.toString();
case BYTES:
return ((ByteArray) value).getBytes();
@@ -641,6 +650,7 @@ public Serializable convertAndFormat(Object value) {
return new Timestamp((long) value).toString();
case STRING:
case JSON:
+ case URN:
return value.toString();
case BYTES:
return ((ByteArray) value).toHexString();
@@ -844,6 +854,8 @@ public static ColumnDataType fromDataTypeSV(DataType dataType) {
return STRING;
case JSON:
return JSON;
+ case URN:
+ return URN;
case BYTES:
return BYTES;
case MAP:
diff --git a/pinot-common/src/main/java/org/apache/pinot/common/utils/PinotDataType.java b/pinot-common/src/main/java/org/apache/pinot/common/utils/PinotDataType.java
index 030c5a9df9..d3d915483d 100644
--- a/pinot-common/src/main/java/org/apache/pinot/common/utils/PinotDataType.java
+++ b/pinot-common/src/main/java/org/apache/pinot/common/utils/PinotDataType.java
@@ -1493,6 +1493,11 @@ public static PinotDataType getPinotDataTypeForIngestion(FieldSpec fieldSpec) {
return JSON;
}
throw new IllegalStateException("There is no multi-value type for JSON");
+ case URN:
+ if (fieldSpec.isSingleValueField()) {
+ return STRING;
+ }
+ throw new IllegalStateException("There is no multi-value type for URN");
case STRING:
return fieldSpec.isSingleValueField() ? STRING : STRING_ARRAY;
case BYTES:
@@ -1532,6 +1537,8 @@ public static PinotDataType getPinotDataTypeForExecution(ColumnDataType columnDa
return STRING;
case JSON:
return JSON;
+ case URN:
+ return STRING;
case BYTES:
return BYTES;
case OBJECT:
diff --git a/pinot-common/src/test/java/org/apache/pinot/common/function/scalar/UrnFunctionsTest.java b/pinot-common/src/test/java/org/apache/pinot/common/function/scalar/UrnFunctionsTest.java
new file mode 100644
index 0000000000..7b002f320b
--- /dev/null
+++ b/pinot-common/src/test/java/org/apache/pinot/common/function/scalar/UrnFunctionsTest.java
@@ -0,0 +1,202 @@
+/**
+ * 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.common.function.scalar;
+
+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;
+
+
+public class UrnFunctionsTest {
+
+ // --- urnEntity ---
+
+ @Test
+ public void urnEntitySimple() {
+ assertEquals(UrnFunctions.urnEntity("urn:li:memberId:123456"), "urn:li:memberId");
+ }
+
+ @Test
+ public void urnEntityComposite() {
+ assertEquals(UrnFunctions.urnEntity("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)"),
+ "urn:li:dataset");
+ }
+
+ @Test
+ public void urnEntityNullInput() {
+ assertNull(UrnFunctions.urnEntity(null));
+ }
+
+ @Test
+ public void urnEntityMalformed() {
+ assertNull(UrnFunctions.urnEntity("not-a-urn"));
+ }
+
+ // --- urnNamespace ---
+
+ @Test
+ public void urnNamespaceReturnsLi() {
+ assertEquals(UrnFunctions.urnNamespace("urn:li:memberId:1"), "li");
+ }
+
+ @Test
+ public void urnNamespaceNull() {
+ assertNull(UrnFunctions.urnNamespace(null));
+ }
+
+ // --- urnEntityType ---
+
+ @Test
+ public void urnEntityTypeSimple() {
+ assertEquals(UrnFunctions.urnEntityType("urn:li:memberId:123"), "memberId");
+ }
+
+ @Test
+ public void urnEntityTypeMalformed() {
+ assertNull(UrnFunctions.urnEntityType("bad-urn"));
+ }
+
+ // --- urnKey ---
+
+ @Test
+ public void urnKeySimple() {
+ assertEquals(UrnFunctions.urnKey("urn:li:memberId:123456"), "123456");
+ }
+
+ @Test
+ public void urnKeyComposite() {
+ String result = UrnFunctions.urnKey("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)");
+ assertEquals(result, "(urn:li:dataPlatform:pinot,foo,PROD)");
+ }
+
+ @Test
+ public void urnKeyNull() {
+ assertNull(UrnFunctions.urnKey(null));
+ }
+
+ @Test
+ public void urnKeyMalformed() {
+ assertNull(UrnFunctions.urnKey("bad"));
+ }
+
+ // --- urnKeyLong ---
+
+ @Test
+ public void urnKeyLongNumericKey() {
+ assertEquals(UrnFunctions.urnKeyLong("urn:li:memberId:123456"), Long.valueOf(123456L));
+ }
+
+ @Test
+ public void urnKeyLongZero() {
+ assertEquals(UrnFunctions.urnKeyLong("urn:li:memberId:0"), Long.valueOf(0L));
+ }
+
+ @Test
+ public void urnKeyLongNonNumericKey() {
+ assertNull(UrnFunctions.urnKeyLong("urn:li:corpUser:alice"));
+ }
+
+ @Test
+ public void urnKeyLongCompositeReturnsNull() {
+ assertNull(UrnFunctions.urnKeyLong("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)"));
+ }
+
+ @Test
+ public void urnKeyLongNull() {
+ assertNull(UrnFunctions.urnKeyLong(null));
+ }
+
+ @Test
+ public void urnKeyLongMaxValue() {
+ assertEquals(UrnFunctions.urnKeyLong("urn:li:memberId:" + Long.MAX_VALUE), Long.valueOf(Long.MAX_VALUE));
+ }
+
+ // --- urnIsComposite ---
+
+ @Test
+ public void urnIsCompositeSimpleUrn() {
+ assertFalse(UrnFunctions.urnIsComposite("urn:li:memberId:1"));
+ }
+
+ @Test
+ public void urnIsCompositeCompositeUrn() {
+ assertTrue(UrnFunctions.urnIsComposite("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)"));
+ }
+
+ @Test
+ public void urnIsCompositeNull() {
+ assertNull(UrnFunctions.urnIsComposite(null));
+ }
+
+ // --- urnKeyPart ---
+
+ @Test
+ public void urnKeyPartFirstPart() {
+ assertEquals(
+ UrnFunctions.urnKeyPart("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)", 0),
+ "urn:li:dataPlatform:pinot");
+ }
+
+ @Test
+ public void urnKeyPartMiddlePart() {
+ assertEquals(
+ UrnFunctions.urnKeyPart("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)", 1),
+ "foo");
+ }
+
+ @Test
+ public void urnKeyPartLastPart() {
+ assertEquals(
+ UrnFunctions.urnKeyPart("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)", 2),
+ "PROD");
+ }
+
+ @Test
+ public void urnKeyPartOutOfRange() {
+ assertNull(UrnFunctions.urnKeyPart("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)", 5));
+ }
+
+ @Test
+ public void urnKeyPartSimpleUrnReturnsNull() {
+ assertNull(UrnFunctions.urnKeyPart("urn:li:memberId:123", 0));
+ }
+
+ @Test
+ public void urnKeyPartNullInput() {
+ assertNull(UrnFunctions.urnKeyPart(null, 0));
+ }
+
+ // --- isUrn ---
+
+ @Test
+ public void isUrnValid() {
+ assertTrue(UrnFunctions.isUrn("urn:li:memberId:1"));
+ assertTrue(UrnFunctions.isUrn("urn:li:dataset:(urn:li:dataPlatform:pinot,foo,PROD)"));
+ }
+
+ @Test
+ public void isUrnInvalid() {
+ assertFalse(UrnFunctions.isUrn("not-a-urn"));
+ assertFalse(UrnFunctions.isUrn(null));
+ assertFalse(UrnFunctions.isUrn(""));
+ }
+}
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/EqualsPredicateEvaluatorFactory.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/EqualsPredicateEvaluatorFactory.java
index d99b8249d4..b9f6470095 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/EqualsPredicateEvaluatorFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/EqualsPredicateEvaluatorFactory.java
@@ -81,6 +81,7 @@ public static EqRawPredicateEvaluator newRawValueBasedEvaluator(EqPredicate eqPr
return new LongRawValueBasedEqPredicateEvaluator(eqPredicate, TimestampUtils.toMillisSinceEpoch(value));
case STRING:
case JSON:
+ case URN:
return new StringRawValueBasedEqPredicateEvaluator(eqPredicate, value);
case BYTES:
return new BytesRawValueBasedEqPredicateEvaluator(eqPredicate, BytesUtils.toBytes(value));
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/InPredicateEvaluatorFactory.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/InPredicateEvaluatorFactory.java
index ed7fb5273f..1bc51b2c8f 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/InPredicateEvaluatorFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/InPredicateEvaluatorFactory.java
@@ -129,7 +129,8 @@ public static InRawPredicateEvaluator newRawValueBasedEvaluator(InPredicate inPr
return new LongRawValueBasedInPredicateEvaluator(inPredicate, matchingValues);
}
case STRING:
- case JSON: {
+ case JSON:
+ case URN: {
List stringValues = inPredicate.getValues();
Set matchingValues = new ObjectOpenHashSet<>(HashUtil.getMinHashSetSize(stringValues.size()));
// NOTE: Add value-by-value to avoid overhead
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotEqualsPredicateEvaluatorFactory.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotEqualsPredicateEvaluatorFactory.java
index 742b69c1b1..c70e948d68 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotEqualsPredicateEvaluatorFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotEqualsPredicateEvaluatorFactory.java
@@ -77,6 +77,7 @@ public static NeqRawPredicateEvaluator newRawValueBasedEvaluator(NotEqPredicate
return new LongRawValueBasedNeqPredicateEvaluator(notEqPredicate, TimestampUtils.toMillisSinceEpoch(value));
case STRING:
case JSON:
+ case URN:
return new StringRawValueBasedNeqPredicateEvaluator(notEqPredicate, value);
case BYTES:
return new BytesRawValueBasedNeqPredicateEvaluator(notEqPredicate, BytesUtils.toBytes(value));
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotInPredicateEvaluatorFactory.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotInPredicateEvaluatorFactory.java
index daabde1069..1bc216a646 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotInPredicateEvaluatorFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/NotInPredicateEvaluatorFactory.java
@@ -129,7 +129,8 @@ public static NotInRawPredicateEvaluator newRawValueBasedEvaluator(NotInPredicat
return new LongRawValueBasedNotInPredicateEvaluator(notInPredicate, nonMatchingValues);
}
case STRING:
- case JSON: {
+ case JSON:
+ case URN: {
List stringValues = notInPredicate.getValues();
Set nonMatchingValues = new ObjectOpenHashSet<>(HashUtil.getMinHashSetSize(stringValues.size()));
// NOTE: Add value-by-value to avoid overhead
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/PredicateUtils.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/PredicateUtils.java
index c7b93cf086..41ccc0c397 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/PredicateUtils.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/PredicateUtils.java
@@ -144,6 +144,7 @@ public static IntSet getDictIdSet(BaseInPredicate inPredicate, Dictionary dictio
}
}
break;
+ case URN:
case STRING:
if (queryContext == null || values.size() <= 1) {
dictionary.getDictIds(values, dictIdSet);
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RangePredicateEvaluatorFactory.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RangePredicateEvaluatorFactory.java
index e9bd3b4b0a..ae9e1e9b89 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RangePredicateEvaluatorFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RangePredicateEvaluatorFactory.java
@@ -104,6 +104,7 @@ public static BaseRawValueBasedPredicateEvaluator newRawValueBasedEvaluator(Rang
lowerUnbounded ? Long.MIN_VALUE : TimestampUtils.toMillisSinceEpoch(lowerBound),
upperUnbounded ? Long.MAX_VALUE : TimestampUtils.toMillisSinceEpoch(upperBound), lowerInclusive,
upperInclusive);
+ case URN:
case STRING:
return new StringRawValueBasedRangePredicateEvaluator(rangePredicate, lowerUnbounded ? null : lowerBound,
upperUnbounded ? null : upperBound, lowerInclusive, upperInclusive);
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RegexpLikePredicateEvaluatorFactory.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RegexpLikePredicateEvaluatorFactory.java
index e9c2bb73fd..6e49b27f26 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RegexpLikePredicateEvaluatorFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/predicate/RegexpLikePredicateEvaluatorFactory.java
@@ -42,7 +42,7 @@ private RegexpLikePredicateEvaluatorFactory() {
*/
public static BaseDictionaryBasedPredicateEvaluator newDictionaryBasedEvaluator(
RegexpLikePredicate regexpLikePredicate, Dictionary dictionary, DataType dataType) {
- boolean condition = (dataType == DataType.STRING || dataType == DataType.JSON);
+ boolean condition = (dataType == DataType.STRING || dataType == DataType.JSON || dataType == DataType.URN);
Preconditions.checkArgument(condition, "Unsupported data type: " + dataType);
return new DictionaryBasedRegexpLikePredicateEvaluator(regexpLikePredicate, dictionary);
}
@@ -56,7 +56,8 @@ public static BaseDictionaryBasedPredicateEvaluator newDictionaryBasedEvaluator(
*/
public static BaseRawValueBasedPredicateEvaluator newRawValueBasedEvaluator(RegexpLikePredicate regexpLikePredicate,
DataType dataType) {
- Preconditions.checkArgument(dataType == DataType.STRING, "Unsupported data type: " + dataType);
+ Preconditions.checkArgument(dataType == DataType.STRING || dataType == DataType.URN,
+ "Unsupported data type: " + dataType);
return new RawValueBasedRegexpLikePredicateEvaluator(regexpLikePredicate);
}
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/CaseTransformFunction.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/CaseTransformFunction.java
index 019040a273..f6f58ffa93 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/CaseTransformFunction.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/CaseTransformFunction.java
@@ -208,6 +208,7 @@ private void checkLiteral(DataType dataType, String literal) {
break;
case STRING:
case JSON:
+ case URN:
break;
case BYTES:
try {
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
index 86c2e0a6e4..3120951a16 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/TransformFunctionFactory.java
@@ -269,6 +269,11 @@ private static Map> createRegistry()
registry.put(canonicalize(name), entry.getValue());
}
}
+ // URN-aware optimized implementations — override the scalar function fallback.
+ registry.put(canonicalize(UrnEntityTransformFunction.FUNCTION_NAME), UrnEntityTransformFunction.class);
+ registry.put(canonicalize(UrnEntityTransformFunction.FUNCTION_NAME_ALT), UrnEntityTransformFunction.class);
+ registry.put(canonicalize(UrnKeyLongTransformFunction.FUNCTION_NAME), UrnKeyLongTransformFunction.class);
+ registry.put(canonicalize(UrnKeyLongTransformFunction.FUNCTION_NAME_ALT), UrnKeyLongTransformFunction.class);
return registry;
}
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/UrnEntityTransformFunction.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/UrnEntityTransformFunction.java
new file mode 100644
index 0000000000..9e5d65571e
--- /dev/null
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/UrnEntityTransformFunction.java
@@ -0,0 +1,181 @@
+/**
+ * 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.operator.transform.function;
+
+import com.google.common.base.Preconditions;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.operator.ColumnContext;
+import org.apache.pinot.core.operator.blocks.ValueBlock;
+import org.apache.pinot.core.operator.transform.TransformResultMetadata;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.DimensionFieldSpec.EntityDictRange;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.utils.Urn;
+
+
+/**
+ * {@code urnEntity(col)} transform function — returns the entity-type prefix of a URN column.
+ *
+ *
Performance tiers, picked at {@link #init} time. None of them parse URN strings per row:
+ *
+ *
Constant — single-entity URN column (declared or auto-detected at segment build):
+ * every row gets the same literal string, so the block is filled with one
+ * {@link Arrays#fill} call.
+ *
Range-lookup — multi-entity URN column whose segment metadata records the
+ * per-entity dict-ID ranges. Reads main-column dict IDs and finds the owning entity range
+ * with a small linear scan (typically ≤ 16 entries).
+ *
Dict-lookup — multi-entity URN column stored as STRING (mixed-type entities, e.g.
+ * LONG memberId + STRING corpuser). At init time we walk the dictionary once and build a
+ * {@code dictId → entityPrefix} array; per row we read the dict ID and index into the
+ * array. {@link org.apache.pinot.segment.local.recordtransformer.UrnValidationTransformer}
+ * guarantees every value in the dictionary is a simple URN, so the init walk never sees
+ * malformed data.
+ *
+ */
+public class UrnEntityTransformFunction extends BaseTransformFunction {
+
+ public static final String FUNCTION_NAME = "urnEntity";
+ public static final String FUNCTION_NAME_ALT = "urn_entity";
+
+ private enum Strategy {
+ CONSTANT, RANGE_LOOKUP, DICT_LOOKUP
+ }
+
+ private Strategy _strategy;
+ private String _constantEntityType;
+ private String _mainColumnName;
+ private int[] _rangeEnds;
+ private String[] _entityTypesByRange;
+ private String[] _entityTypeByDictId;
+
+ @Override
+ public String getName() {
+ return FUNCTION_NAME;
+ }
+
+ @Override
+ public void init(List arguments, Map columnContextMap) {
+ super.init(arguments, columnContextMap);
+ Preconditions.checkArgument(arguments.size() == 1, "urnEntity requires exactly 1 argument");
+
+ Preconditions.checkArgument(arguments.get(0) instanceof IdentifierTransformFunction,
+ "urnEntity argument must be a column identifier");
+ String colName = ((IdentifierTransformFunction) arguments.get(0)).getColumnName();
+ ColumnContext colCtx = columnContextMap.get(colName);
+ Preconditions.checkArgument(colCtx != null && colCtx.getDataType() == FieldSpec.DataType.URN,
+ "urnEntity argument must reference a URN column: %s", colName);
+
+ DataSource dataSource = colCtx.getDataSource();
+ Preconditions.checkNotNull(dataSource, "urnEntity requires a backing data source for column: %s", colName);
+ FieldSpec fieldSpec = dataSource.getDataSourceMetadata().getFieldSpec();
+ Preconditions.checkArgument(fieldSpec instanceof DimensionFieldSpec,
+ "urnEntity expects a dimension URN column: %s", colName);
+ DimensionFieldSpec dim = (DimensionFieldSpec) fieldSpec;
+
+ // Single-entity: same prefix on every row.
+ if (dim.isSingleUrnEntityType()) {
+ _strategy = Strategy.CONSTANT;
+ _constantEntityType = dim.getUrnTypes().get(0).getEntityType();
+ return;
+ }
+
+ // Multi-entity sorted-LONG layout: routing via per-entity dict-ID ranges.
+ List ranges = dim.getEntityDictRanges();
+ if (ranges != null && !ranges.isEmpty()) {
+ _mainColumnName = colName;
+ _rangeEnds = new int[ranges.size()];
+ _entityTypesByRange = new String[ranges.size()];
+ for (int i = 0; i < ranges.size(); i++) {
+ _rangeEnds[i] = ranges.get(i).getEndDictIdExclusive();
+ _entityTypesByRange[i] = ranges.get(i).getEntityType();
+ }
+ _strategy = Strategy.RANGE_LOOKUP;
+ return;
+ }
+
+ // Multi-entity STRING-stored column: precompute entity-per-dict-id once.
+ Dictionary dictionary = dataSource.getDictionary();
+ Preconditions.checkNotNull(dictionary,
+ "urnEntity requires a dictionary for multi-entity STRING-stored URN column: %s", colName);
+ int dictLength = dictionary.length();
+ _entityTypeByDictId = new String[dictLength];
+ for (int dictId = 0; dictId < dictLength; dictId++) {
+ String value = dictionary.getStringValue(dictId);
+ if (value == null) {
+ continue;
+ }
+ Urn urn = Urn.tryParse(value);
+ // UrnValidationTransformer guarantees parseability for ingested data; a parse failure
+ // here would indicate a corrupted segment or a non-URN value smuggled in by an upstream
+ // bypass — surface the column name to help diagnose.
+ Preconditions.checkState(urn != null,
+ "Unparseable URN value at dictId %s in column %s: %s", dictId, colName, value);
+ _entityTypeByDictId[dictId] = urn.entityPrefix();
+ }
+ _mainColumnName = colName;
+ _strategy = Strategy.DICT_LOOKUP;
+ }
+
+ @Override
+ public TransformResultMetadata getResultMetadata() {
+ return STRING_SV_NO_DICTIONARY_METADATA;
+ }
+
+ @Override
+ public String[] transformToStringValuesSV(ValueBlock valueBlock) {
+ int length = valueBlock.getNumDocs();
+ initStringValuesSV(length);
+ switch (_strategy) {
+ case CONSTANT:
+ Arrays.fill(_stringValuesSV, 0, length, _constantEntityType);
+ break;
+ case RANGE_LOOKUP:
+ BlockValSet mainBlock = valueBlock.getBlockValueSet(_mainColumnName);
+ int[] dictIds = mainBlock.getDictionaryIdsSV();
+ int numRanges = _rangeEnds.length;
+ for (int i = 0; i < length; i++) {
+ int dictId = dictIds[i];
+ String entityType = null;
+ for (int r = 0; r < numRanges; r++) {
+ if (dictId < _rangeEnds[r]) {
+ entityType = _entityTypesByRange[r];
+ break;
+ }
+ }
+ _stringValuesSV[i] = entityType;
+ }
+ break;
+ case DICT_LOOKUP:
+ default:
+ BlockValSet dictBlock = valueBlock.getBlockValueSet(_mainColumnName);
+ int[] dictLookupIds = dictBlock.getDictionaryIdsSV();
+ for (int i = 0; i < length; i++) {
+ int id = dictLookupIds[i];
+ _stringValuesSV[i] = id >= 0 ? _entityTypeByDictId[id] : null;
+ }
+ break;
+ }
+ return _stringValuesSV;
+ }
+}
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/UrnKeyLongTransformFunction.java b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/UrnKeyLongTransformFunction.java
new file mode 100644
index 0000000000..06b237bbbe
--- /dev/null
+++ b/pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/UrnKeyLongTransformFunction.java
@@ -0,0 +1,116 @@
+/**
+ * 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.operator.transform.function;
+
+import com.google.common.base.Preconditions;
+import java.util.List;
+import java.util.Map;
+import org.apache.pinot.core.operator.ColumnContext;
+import org.apache.pinot.core.operator.blocks.ValueBlock;
+import org.apache.pinot.core.operator.transform.TransformResultMetadata;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.utils.Urn;
+
+
+/**
+ * {@code urnKeyLong(col)} transform function — returns the numeric key of a URN as a LONG.
+ *
+ *
Performance tiers, evaluated at {@link #init} time:
+ *
+ *
Direct LONG read — typed URN column stored as LONG: reads the raw LONG key
+ * directly from the column without any URN string construction or parsing. This is the
+ * fast path for queries like {@code WHERE urnKeyLong(memberId) > 1000}.
+ *
Parse-string — untyped URN column stored as STRING: reads the string value and
+ * parses it to extract the numeric key; returns {@link Long#MIN_VALUE} for non-numeric
+ * keys (matches null-value sentinel).
+ *
+ */
+public class UrnKeyLongTransformFunction extends BaseTransformFunction {
+
+ public static final String FUNCTION_NAME = "urnKeyLong";
+ public static final String FUNCTION_NAME_ALT = "urn_key_long";
+
+ private boolean _isTypedLongUrn;
+
+ @Override
+ public String getName() {
+ return FUNCTION_NAME;
+ }
+
+ @Override
+ public void init(List arguments, Map columnContextMap) {
+ super.init(arguments, columnContextMap);
+ Preconditions.checkArgument(arguments.size() == 1, "urnKeyLong requires exactly 1 argument");
+
+ TransformFunction arg = arguments.get(0);
+ if (arg instanceof IdentifierTransformFunction) {
+ String colName = ((IdentifierTransformFunction) arg).getColumnName();
+ ColumnContext colCtx = columnContextMap.get(colName);
+ if (colCtx != null && colCtx.getDataType() == FieldSpec.DataType.URN) {
+ DataSource dataSource = colCtx.getDataSource();
+ if (dataSource != null) {
+ FieldSpec fieldSpec = dataSource.getDataSourceMetadata().getFieldSpec();
+ if (fieldSpec instanceof DimensionFieldSpec) {
+ DimensionFieldSpec dim = (DimensionFieldSpec) fieldSpec;
+ if (dim.hasTypedUrnStorage()) {
+ _isTypedLongUrn = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public TransformResultMetadata getResultMetadata() {
+ return LONG_SV_NO_DICTIONARY_METADATA;
+ }
+
+ @Override
+ public long[] transformToLongValuesSV(ValueBlock valueBlock) {
+ int length = valueBlock.getNumDocs();
+ if (_isTypedLongUrn) {
+ // Fast path: the column physically stores LONG keys — read them directly.
+ return _arguments.get(0).transformToLongValuesSV(valueBlock);
+ }
+ // Slow path: parse the URN string and extract the numeric key.
+ initLongValuesSV(length);
+ String[] strValues = _arguments.get(0).transformToStringValuesSV(valueBlock);
+ for (int i = 0; i < length; i++) {
+ String urnStr = strValues[i];
+ if (urnStr == null) {
+ _longValuesSV[i] = Long.MIN_VALUE;
+ continue;
+ }
+ Urn urn = Urn.tryParse(urnStr);
+ if (urn == null || !urn.isSimple()) {
+ _longValuesSV[i] = Long.MIN_VALUE;
+ continue;
+ }
+ try {
+ _longValuesSV[i] = Long.parseLong(urn.simpleKey());
+ } catch (NumberFormatException e) {
+ _longValuesSV[i] = Long.MIN_VALUE;
+ }
+ }
+ return _longValuesSV;
+ }
+}
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
index 805c70a1c2..7123239ae2 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
@@ -28,8 +28,13 @@
import org.apache.pinot.common.request.context.ExpressionContext;
import org.apache.pinot.common.request.context.FilterContext;
import org.apache.pinot.common.request.context.FunctionContext;
+import org.apache.pinot.common.request.context.predicate.EqPredicate;
+import org.apache.pinot.common.request.context.predicate.InPredicate;
import org.apache.pinot.common.request.context.predicate.JsonMatchPredicate;
+import org.apache.pinot.common.request.context.predicate.NotEqPredicate;
+import org.apache.pinot.common.request.context.predicate.NotInPredicate;
import org.apache.pinot.common.request.context.predicate.Predicate;
+import org.apache.pinot.common.request.context.predicate.RangePredicate;
import org.apache.pinot.common.request.context.predicate.RegexpLikePredicate;
import org.apache.pinot.common.request.context.predicate.TextContainsPredicate;
import org.apache.pinot.common.request.context.predicate.TextMatchPredicate;
@@ -52,6 +57,7 @@
import org.apache.pinot.core.operator.filter.predicate.PredicateEvaluator;
import org.apache.pinot.core.operator.filter.predicate.PredicateEvaluatorProvider;
import org.apache.pinot.core.operator.transform.function.ItemTransformFunction;
+import org.apache.pinot.core.operator.transform.function.UrnKeyLongTransformFunction;
import org.apache.pinot.core.query.request.context.QueryContext;
import org.apache.pinot.segment.local.realtime.impl.invertedindex.NativeMutableTextIndex;
import org.apache.pinot.segment.local.segment.index.readers.text.NativeTextIndexReader;
@@ -65,6 +71,9 @@
import org.apache.pinot.segment.spi.index.reader.TextIndexReader;
import org.apache.pinot.segment.spi.index.reader.VectorIndexReader;
import org.apache.pinot.spi.config.table.FieldConfig;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.exception.BadQueryRequestException;
import org.roaringbitmap.buffer.MutableRoaringBitmap;
@@ -247,7 +256,7 @@ private BaseFilterOperator constructPhysicalOperator(FilterContext filter, int n
BaseFilterOperator childFilterOperator = constructPhysicalOperator(childFilters.get(0), numDocs);
return FilterOperatorUtils.getNotFilterOperator(_queryContext, childFilterOperator, numDocs);
case PREDICATE:
- Predicate predicate = filter.getPredicate();
+ Predicate predicate = rewriteUrnKeyLongPredicate(filter.getPredicate());
ExpressionContext lhs = predicate.getLhs();
if (lhs.getType() == ExpressionContext.Type.FUNCTION) {
if (canApplyH3IndexForDistanceCheck(predicate, lhs.getFunction())) {
@@ -347,4 +356,87 @@ private BaseFilterOperator constructPhysicalOperator(FilterContext filter, int n
throw new IllegalStateException();
}
}
+
+ /**
+ * If {@code predicate} has the form {@code urnKeyLong(col) OP literal} where {@code col} is a
+ * single-entity typed URN column with LONG/INT storage, rewrites it to {@code col OP
+ * entityPrefix:literal} so the existing identifier-based predicate path (with prefix-stripping
+ * inside {@code UrnReconstructingLongDictionary}) handles it. Otherwise returns the predicate
+ * unchanged.
+ *
+ *
Supports EQ, NOT_EQ, IN, NOT_IN, RANGE. Multi-entity URN columns are skipped because the
+ * literal alone cannot identify which entity type's prefix to apply.
+ */
+ // Package-private for testing.
+ Predicate rewriteUrnKeyLongPredicate(Predicate predicate) {
+ ExpressionContext lhs = predicate.getLhs();
+ if (lhs.getType() != ExpressionContext.Type.FUNCTION) {
+ return predicate;
+ }
+ FunctionContext function = lhs.getFunction();
+ String name = function.getFunctionName();
+ if (!UrnKeyLongTransformFunction.FUNCTION_NAME.equalsIgnoreCase(name)
+ && !UrnKeyLongTransformFunction.FUNCTION_NAME_ALT.equalsIgnoreCase(name)) {
+ return predicate;
+ }
+ if (function.getArguments().size() != 1) {
+ return predicate;
+ }
+ ExpressionContext arg = function.getArguments().get(0);
+ if (arg.getType() != ExpressionContext.Type.IDENTIFIER) {
+ return predicate;
+ }
+ Schema schema = _queryContext.getSchema();
+ if (schema == null) {
+ return predicate;
+ }
+ String colName = arg.getIdentifier();
+ FieldSpec fs = schema.getFieldSpecFor(colName);
+ if (!(fs instanceof DimensionFieldSpec)) {
+ return predicate;
+ }
+ DimensionFieldSpec dim = (DimensionFieldSpec) fs;
+ if (!dim.hasTypedUrnStorage() || dim.usesSortedUrnDictionary()) {
+ return predicate;
+ }
+ String prefix = dim.getUrnTypes().get(0).getEntityType() + ":";
+ ExpressionContext newLhs = ExpressionContext.forIdentifier(colName);
+ switch (predicate.getType()) {
+ case EQ: {
+ EqPredicate eq = (EqPredicate) predicate;
+ return new EqPredicate(newLhs, prefix + eq.getValue());
+ }
+ case NOT_EQ: {
+ NotEqPredicate neq = (NotEqPredicate) predicate;
+ return new NotEqPredicate(newLhs, prefix + neq.getValue());
+ }
+ case IN: {
+ InPredicate in = (InPredicate) predicate;
+ List rewritten = new ArrayList<>(in.getValues().size());
+ for (String v : in.getValues()) {
+ rewritten.add(prefix + v);
+ }
+ return new InPredicate(newLhs, rewritten);
+ }
+ case NOT_IN: {
+ NotInPredicate nin = (NotInPredicate) predicate;
+ List rewritten = new ArrayList<>(nin.getValues().size());
+ for (String v : nin.getValues()) {
+ rewritten.add(prefix + v);
+ }
+ return new NotInPredicate(newLhs, rewritten);
+ }
+ case RANGE: {
+ RangePredicate r = (RangePredicate) predicate;
+ String newLower = RangePredicate.UNBOUNDED.equals(r.getLowerBound()) ? r.getLowerBound()
+ : prefix + r.getLowerBound();
+ String newUpper = RangePredicate.UNBOUNDED.equals(r.getUpperBound()) ? r.getUpperBound()
+ : prefix + r.getUpperBound();
+ return new RangePredicate(newLhs, r.isLowerInclusive(), newLower, r.isUpperInclusive(), newUpper,
+ FieldSpec.DataType.URN);
+ }
+ default:
+ return predicate;
+ }
+ }
}
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java
index 79913301a3..acee10b036 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/AggregationFunctionUtils.java
@@ -225,6 +225,7 @@ public static Object getConvertedFinalResult(DataTable dataTable, ColumnDataType
return new Timestamp(dataTable.getLong(rowId, colId));
case STRING:
case JSON:
+ case URN:
return dataTable.getString(rowId, colId);
case BYTES:
return dataTable.getBytes(rowId, colId).getBytes();
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java
index 15daa2f2a4..f61cf7dd49 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/function/ParentExprMinMaxAggregationFunction.java
@@ -216,6 +216,11 @@ private void initializeProjectionColumnValSet(Map
measuringColTypes[i] = DataSchema.ColumnDataType.DOUBLE;
break;
case STRING:
+ case URN:
exprMinMaxWrapperMeasuringColumnSets.add(
new ExprMinMaxMeasuringValSetWrapper(true, DataSchema.ColumnDataType.STRING, blockValSet));
measuringColTypes[i] = DataSchema.ColumnDataType.STRING;
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxObject.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxObject.java
index 437e6fc304..3459e58b2f 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxObject.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxObject.java
@@ -239,6 +239,7 @@ public Comparable[] getExtremumKey() {
extremumKeys[i] = _immutableMeasuringKeys.getDouble(0, i);
break;
case STRING:
+ case URN:
extremumKeys[i] = _immutableMeasuringKeys.getString(0, i);
break;
case BIG_DECIMAL:
@@ -273,6 +274,7 @@ public Object getField(int rowId, int colId) {
return _immutableProjectionVals.getDouble(rowId, colId);
case JSON:
case STRING:
+ case URN:
return _immutableProjectionVals.getString(rowId, colId);
case BYTES:
return _immutableProjectionVals.getBytes(rowId, colId);
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxProjectionValSetWrapper.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxProjectionValSetWrapper.java
index e855c55c9d..1e0281e57f 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxProjectionValSetWrapper.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxProjectionValSetWrapper.java
@@ -50,6 +50,7 @@ public Object getValue(int i) {
case BIG_DECIMAL:
case BYTES:
case JSON:
+ case URN:
return _objectsValues[i];
case INT_ARRAY:
return _intValuesMV[i].length == 0 ? null : _intValuesMV[i];
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxWrapperValSet.java b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxWrapperValSet.java
index 7090e4e9e0..8c73233043 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxWrapperValSet.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/aggregation/utils/exprminmax/ExprMinMaxWrapperValSet.java
@@ -65,6 +65,7 @@ public void setNewBlock(BlockValSet blockValSet) {
break;
case STRING:
case JSON:
+ case URN:
_objectsValues = blockValSet.getStringValuesSV();
break;
case BIG_DECIMAL:
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java
index 09553bf9e5..f14aa5820a 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/query/reduce/GroupByDataTableReducer.java
@@ -465,6 +465,7 @@ private Object getConvertedKey(DataTable dataTable, ColumnDataType columnDataTyp
return new Timestamp(dataTable.getLong(rowId, colId));
case STRING:
case JSON:
+ case URN:
return dataTable.getString(rowId, colId);
case BYTES:
return dataTable.getBytes(rowId, colId).getBytes();
diff --git a/pinot-core/src/main/java/org/apache/pinot/core/util/GapfillUtils.java b/pinot-core/src/main/java/org/apache/pinot/core/util/GapfillUtils.java
index d5e75de3f3..8782154bbd 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/util/GapfillUtils.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/util/GapfillUtils.java
@@ -89,6 +89,7 @@ static public Serializable getDefaultValue(DataSchema.ColumnDataType dataType) {
return dataType.convertAndFormat(0);
case STRING:
case JSON:
+ case URN:
case BYTES:
return "";
case INT_ARRAY:
diff --git a/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnEntityDictLookupTest.java b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnEntityDictLookupTest.java
new file mode 100644
index 0000000000..849912bd83
--- /dev/null
+++ b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnEntityDictLookupTest.java
@@ -0,0 +1,155 @@
+/**
+ * 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.operator.transform.function;
+
+import com.google.common.io.Files;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.request.context.RequestContextUtils;
+import org.apache.pinot.core.operator.DocIdSetOperator;
+import org.apache.pinot.core.operator.ProjectionOperator;
+import org.apache.pinot.core.operator.blocks.ProjectionBlock;
+import org.apache.pinot.core.operator.filter.MatchAllFilterOperator;
+import org.apache.pinot.core.plan.DocIdSetPlanNode;
+import org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentUtil;
+import org.apache.pinot.segment.spi.ImmutableSegment;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+
+
+/**
+ * End-to-end coverage for mixed-type multi-entity URN columns (LONG memberId + STRING corpuser).
+ * With the polymorphic wire format, each entity gets its own typed sub-dictionary in one
+ * dictionary file, the per-entity dict-ID ranges are persisted in segment metadata, and
+ * {@code urnEntity()} reads the entity prefix per row through the RANGE_LOOKUP fast path — no
+ * dictionary-string lookup, no URN parsing.
+ */
+public class UrnEntityDictLookupTest {
+
+ private static final String SEGMENT_NAME = "urnEntityMixedTypeSegment";
+ private static final String URN_COL = "subject";
+ private static final String TIME_COL = "t";
+ private static final int NUM_ROWS = 60;
+
+ private File _segmentOutputDir;
+ private ImmutableSegment _segment;
+ private Map _dataSourceMap;
+ private ProjectionBlock _projectionBlock;
+ private final String[] _expectedEntities = new String[NUM_ROWS];
+
+ @BeforeClass
+ public void setUp()
+ throws Exception {
+ _segmentOutputDir = Files.createTempDir();
+ TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName("test").build();
+
+ // Mixed-type multi-entity URN: LONG memberId + STRING corpuser. The polymorphic creator
+ // writes a LongDictionary region for memberId and a StringDictionary region for corpuser
+ // back-to-back in one dictionary file, with per-entity dict-ID ranges recorded in metadata.
+ DimensionFieldSpec multiDim = new DimensionFieldSpec(URN_COL, FieldSpec.DataType.URN, true);
+ multiDim.setUrnTypes(Arrays.asList(
+ DimensionFieldSpec.UrnType.of("urn:li:memberId", FieldSpec.DataType.LONG),
+ DimensionFieldSpec.UrnType.of("urn:li:corpuser", FieldSpec.DataType.STRING)));
+
+ Schema schema = new Schema.SchemaBuilder()
+ .addField(multiDim)
+ .addDateTime(TIME_COL, FieldSpec.DataType.LONG, "1:MILLISECONDS:EPOCH", "1:MILLISECONDS")
+ .build();
+
+ List rows = new ArrayList<>();
+ for (int i = 0; i < NUM_ROWS; i++) {
+ String entity = (i % 2 == 0) ? "urn:li:memberId" : "urn:li:corpuser";
+ String key = (i % 2 == 0) ? String.valueOf(1000 + i) : ("user" + i);
+ _expectedEntities[i] = entity;
+ GenericRow row = new GenericRow();
+ row.putValue(URN_COL, entity + ":" + key);
+ row.putValue(TIME_COL, System.currentTimeMillis());
+ rows.add(row);
+ }
+
+ File segDir = PinotSegmentUtil.createSegment(tableConfig, schema, SEGMENT_NAME,
+ _segmentOutputDir.toString(), new GenericRowRecordReader(rows));
+
+ _segment = ImmutableSegmentLoader.load(segDir, ReadMode.mmap);
+
+ _dataSourceMap = new HashMap<>();
+ _dataSourceMap.put(URN_COL, _segment.getDataSource(URN_COL));
+ _dataSourceMap.put(TIME_COL, _segment.getDataSource(TIME_COL));
+
+ int numDocs = _segment.getSegmentMetadata().getTotalDocs();
+ _projectionBlock = new ProjectionOperator(_dataSourceMap,
+ new DocIdSetOperator(new MatchAllFilterOperator(numDocs),
+ DocIdSetPlanNode.MAX_DOC_PER_CALL)).nextBlock();
+ }
+
+ @AfterClass
+ public void tearDown() {
+ if (_segment != null) {
+ _segment.destroy();
+ }
+ FileUtils.deleteQuietly(_segmentOutputDir);
+ }
+
+ @Test
+ public void mixedTypeColumnPersistsTypedDictRanges() {
+ DimensionFieldSpec dim = (DimensionFieldSpec) _segment.getDataSource(URN_COL)
+ .getDataSourceMetadata().getFieldSpec();
+ List ranges = dim.getEntityDictRanges();
+ assertNotNull(ranges);
+ assertEquals(ranges.size(), 2);
+ assertEquals(ranges.get(0).getEntityType(), "urn:li:memberId");
+ assertEquals(ranges.get(0).getDictClass(), FieldSpec.DataType.LONG);
+ assertEquals(ranges.get(0).getNumBytesPerValue(), Long.BYTES);
+ assertEquals(ranges.get(1).getEntityType(), "urn:li:corpuser");
+ assertEquals(ranges.get(1).getDictClass(), FieldSpec.DataType.STRING);
+ }
+
+ @Test
+ public void mixedTypeColumnUrnEntityReturnsPerRowEntityType() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + URN_COL + ")"), _dataSourceMap);
+ assertNotNull(fn);
+ String[] result = fn.transformToStringValuesSV(_projectionBlock);
+ assertNotNull(result);
+ assertEquals(result.length, NUM_ROWS);
+ for (int i = 0; i < NUM_ROWS; i++) {
+ assertEquals(result[i], _expectedEntities[i], "Row " + i);
+ }
+ }
+}
diff --git a/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnEntityMultiEntityTest.java b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnEntityMultiEntityTest.java
new file mode 100644
index 0000000000..5bda752857
--- /dev/null
+++ b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnEntityMultiEntityTest.java
@@ -0,0 +1,159 @@
+/**
+ * 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.operator.transform.function;
+
+import com.google.common.io.Files;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.request.context.RequestContextUtils;
+import org.apache.pinot.core.operator.DocIdSetOperator;
+import org.apache.pinot.core.operator.ProjectionOperator;
+import org.apache.pinot.core.operator.blocks.ProjectionBlock;
+import org.apache.pinot.core.operator.filter.MatchAllFilterOperator;
+import org.apache.pinot.core.plan.DocIdSetPlanNode;
+import org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentUtil;
+import org.apache.pinot.segment.spi.ImmutableSegment;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+
+
+/**
+ * Tests the multi-entity {@code urnEntity()} fast path: COMPANION_LOOKUP strategy that reads
+ * the entity dict ID ranges loaded from segment metadata to determine each row's owning entity
+ * type from its main-column dict ID, without reading any per-row companion data.
+ */
+public class UrnEntityMultiEntityTest {
+
+ private static final String SEGMENT_NAME = "urnEntityMultiSegment";
+ private static final String URN_COL = "actorId";
+ private static final String TIME_COL = "t";
+ private static final int NUM_ROWS = 60;
+
+ private File _segmentOutputDir;
+ private ImmutableSegment _segment;
+ private Map _dataSourceMap;
+ private ProjectionBlock _projectionBlock;
+ private final String[] _expectedEntities = new String[NUM_ROWS];
+
+ @BeforeClass
+ public void setUp()
+ throws Exception {
+ _segmentOutputDir = Files.createTempDir();
+ TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName("test").build();
+
+ DimensionFieldSpec multiDim = new DimensionFieldSpec(URN_COL, FieldSpec.DataType.URN, true);
+ multiDim.setUrnTypes(Arrays.asList(
+ DimensionFieldSpec.UrnType.of("urn:li:memberId", FieldSpec.DataType.LONG),
+ DimensionFieldSpec.UrnType.of("urn:li:groupId", FieldSpec.DataType.LONG)));
+
+ Schema schema = new Schema.SchemaBuilder()
+ .addField(multiDim)
+ .addDateTime(TIME_COL, FieldSpec.DataType.LONG, "1:MILLISECONDS:EPOCH", "1:MILLISECONDS")
+ .build();
+
+ List rows = new ArrayList<>();
+ for (int i = 0; i < NUM_ROWS; i++) {
+ String entity = (i % 2 == 0) ? "urn:li:memberId" : "urn:li:groupId";
+ String urn = entity + ":" + (1000 + i);
+ _expectedEntities[i] = entity;
+ GenericRow row = new GenericRow();
+ row.putValue(URN_COL, urn);
+ row.putValue(TIME_COL, System.currentTimeMillis());
+ rows.add(row);
+ }
+
+ File segDir = PinotSegmentUtil.createSegment(tableConfig, schema, SEGMENT_NAME,
+ _segmentOutputDir.toString(), new GenericRowRecordReader(rows));
+
+ _segment = ImmutableSegmentLoader.load(segDir, ReadMode.mmap);
+
+ _dataSourceMap = new HashMap<>();
+ _dataSourceMap.put(URN_COL, _segment.getDataSource(URN_COL));
+ _dataSourceMap.put(TIME_COL, _segment.getDataSource(TIME_COL));
+
+ int numDocs = _segment.getSegmentMetadata().getTotalDocs();
+ _projectionBlock = new ProjectionOperator(_dataSourceMap,
+ new DocIdSetOperator(new MatchAllFilterOperator(numDocs),
+ DocIdSetPlanNode.MAX_DOC_PER_CALL)).nextBlock();
+ }
+
+ @AfterClass
+ public void tearDown() {
+ if (_segment != null) {
+ _segment.destroy();
+ }
+ FileUtils.deleteQuietly(_segmentOutputDir);
+ }
+
+ @Test
+ public void entityDictRangesPersistedInMetadata() {
+ org.apache.pinot.spi.data.DimensionFieldSpec dim =
+ (org.apache.pinot.spi.data.DimensionFieldSpec) _segment.getDataSource(URN_COL)
+ .getDataSourceMetadata().getFieldSpec();
+ assertNotNull(dim.getEntityDictRanges());
+ assertEquals(dim.getEntityDictRanges().size(), 2);
+ assertEquals(dim.getEntityDictRanges().get(0).getEntityType(), "urn:li:memberId");
+ assertEquals(dim.getEntityDictRanges().get(1).getEntityType(), "urn:li:groupId");
+ }
+
+ @Test
+ public void urnEntityUsesRangeLookupFastPath() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + URN_COL + ")"), _dataSourceMap);
+ assertNotNull(fn);
+
+ String[] result = fn.transformToStringValuesSV(_projectionBlock);
+ assertNotNull(result);
+ assertEquals(result.length, NUM_ROWS);
+
+ // Each row's main-column dict ID falls into one of the per-entity ranges; the transform
+ // returns the owning entity type for that dict ID.
+ int memberIds = 0;
+ int groupIds = 0;
+ for (String r : result) {
+ if ("urn:li:memberId".equals(r)) {
+ memberIds++;
+ } else if ("urn:li:groupId".equals(r)) {
+ groupIds++;
+ }
+ }
+ assertEquals(memberIds, NUM_ROWS / 2);
+ assertEquals(groupIds, NUM_ROWS / 2);
+ }
+}
diff --git a/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnTransformFunctionTest.java b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnTransformFunctionTest.java
new file mode 100644
index 0000000000..5a00496d8a
--- /dev/null
+++ b/pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/UrnTransformFunctionTest.java
@@ -0,0 +1,332 @@
+/**
+ * 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.operator.transform.function;
+
+import com.google.common.collect.Lists;
+import com.google.common.io.Files;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.request.context.RequestContextUtils;
+import org.apache.pinot.common.request.context.predicate.EqPredicate;
+import org.apache.pinot.common.request.context.predicate.InPredicate;
+import org.apache.pinot.common.request.context.predicate.RangePredicate;
+import org.apache.pinot.core.operator.DocIdSetOperator;
+import org.apache.pinot.core.operator.ProjectionOperator;
+import org.apache.pinot.core.operator.blocks.ProjectionBlock;
+import org.apache.pinot.core.operator.filter.MatchAllFilterOperator;
+import org.apache.pinot.core.operator.filter.predicate.BaseDictionaryBasedPredicateEvaluator;
+import org.apache.pinot.core.operator.filter.predicate.EqualsPredicateEvaluatorFactory;
+import org.apache.pinot.core.operator.filter.predicate.InPredicateEvaluatorFactory;
+import org.apache.pinot.core.operator.filter.predicate.RangePredicateEvaluatorFactory;
+import org.apache.pinot.core.plan.DocIdSetPlanNode;
+import org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentUtil;
+import org.apache.pinot.segment.spi.ImmutableSegment;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+
+/**
+ * Correctness tests for {@link UrnEntityTransformFunction} and {@link UrnKeyLongTransformFunction}
+ * on typed URN LONG columns (fast path) and untyped URN STRING columns (fallback path).
+ */
+public class UrnTransformFunctionTest {
+
+ private static final String SEGMENT_NAME = "urnTransformFunctionTestSegment";
+ private static final String URN_COL = "memberId";
+ private static final String UNTYPED_URN_COL = "untypedMemberId";
+ private static final String TIME_COL = "t";
+ private static final int NUM_ROWS = 100;
+
+ private File _segmentOutputDir;
+ private ImmutableSegment _segment;
+ private Map _dataSourceMap;
+ private ProjectionBlock _projectionBlock;
+
+ // Track expected values for assertions
+ private final long[] _expectedKeys = new long[NUM_ROWS];
+
+ @BeforeClass
+ public void setUp()
+ throws Exception {
+ _segmentOutputDir = Files.createTempDir();
+ TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName("test").build();
+
+ DimensionFieldSpec typedDim = new DimensionFieldSpec(URN_COL, FieldSpec.DataType.URN, true);
+ typedDim.setUrnTypes(List.of(DimensionFieldSpec.UrnType.of("urn:li:memberId", FieldSpec.DataType.LONG)));
+
+ Schema schema = new Schema.SchemaBuilder()
+ .addField(typedDim)
+ .addSingleValueDimension(UNTYPED_URN_COL, FieldSpec.DataType.URN)
+ .addDateTime(TIME_COL, FieldSpec.DataType.LONG, "1:MILLISECONDS:EPOCH", "1:MILLISECONDS")
+ .build();
+
+ List rows = new ArrayList<>();
+ for (int i = 0; i < NUM_ROWS; i++) {
+ long key = 100L + i;
+ _expectedKeys[i] = key;
+ GenericRow row = new GenericRow();
+ row.putValue(URN_COL, "urn:li:memberId:" + key);
+ row.putValue(UNTYPED_URN_COL, "urn:li:memberId:" + key);
+ row.putValue(TIME_COL, System.currentTimeMillis());
+ rows.add(row);
+ }
+
+ File segDir = PinotSegmentUtil.createSegment(tableConfig, schema, SEGMENT_NAME,
+ _segmentOutputDir.toString(), new GenericRowRecordReader(rows));
+
+ _segment = ImmutableSegmentLoader.load(segDir, ReadMode.mmap);
+
+ _dataSourceMap = new HashMap<>();
+ // Include the typed URN column and its companion column (if any)
+ _dataSourceMap.put(URN_COL, _segment.getDataSource(URN_COL));
+ _dataSourceMap.put(UNTYPED_URN_COL, _segment.getDataSource(UNTYPED_URN_COL));
+ _dataSourceMap.put(TIME_COL, _segment.getDataSource(TIME_COL));
+
+ int numDocs = _segment.getSegmentMetadata().getTotalDocs();
+ _projectionBlock = new ProjectionOperator(_dataSourceMap,
+ new DocIdSetOperator(new MatchAllFilterOperator(numDocs),
+ DocIdSetPlanNode.MAX_DOC_PER_CALL)).nextBlock();
+ }
+
+ @AfterClass
+ public void tearDown() {
+ if (_segment != null) {
+ _segment.destroy();
+ }
+ FileUtils.deleteQuietly(_segmentOutputDir);
+ }
+
+ // --- urnEntity on typed LONG column (CONSTANT fast path) ---
+
+ @Test
+ public void testUrnEntityOnTypedLongColumn() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + URN_COL + ")"), _dataSourceMap);
+ assertNotNull(fn);
+
+ String[] result = fn.transformToStringValuesSV(_projectionBlock);
+ assertNotNull(result);
+ assertEquals(result.length, NUM_ROWS);
+ for (int i = 0; i < NUM_ROWS; i++) {
+ assertEquals(result[i], "urn:li:memberId", "Row " + i);
+ }
+ }
+
+ @Test
+ public void testUrnEntityUnderscoreAliasOnTypedLongColumn() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urn_entity(" + URN_COL + ")"), _dataSourceMap);
+ String[] result = fn.transformToStringValuesSV(_projectionBlock);
+ for (int i = 0; i < NUM_ROWS; i++) {
+ assertEquals(result[i], "urn:li:memberId");
+ }
+ }
+
+ // --- urnEntity on untyped URN column (auto-detected to CONSTANT here: single-prefix data) ---
+
+ @Test
+ public void testUrnEntityOnUntypedUrnColumn() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + UNTYPED_URN_COL + ")"), _dataSourceMap);
+
+ String[] result = fn.transformToStringValuesSV(_projectionBlock);
+ assertNotNull(result);
+ for (int i = 0; i < NUM_ROWS; i++) {
+ assertEquals(result[i], "urn:li:memberId", "Row " + i);
+ }
+ }
+
+ // --- urnKeyLong on typed LONG column (DIRECT LONG fast path) ---
+
+ @Test
+ public void testUrnKeyLongOnTypedLongColumn() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnKeyLong(" + URN_COL + ")"), _dataSourceMap);
+ assertNotNull(fn);
+
+ long[] result = fn.transformToLongValuesSV(_projectionBlock);
+ assertNotNull(result);
+ assertEquals(result.length, NUM_ROWS);
+
+ // Values should match the sorted order of keys stored in the dictionary.
+ // Build expected: keys sorted ascending (100..199 are already sorted)
+ long[] allKeys = Arrays.copyOf(_expectedKeys, NUM_ROWS);
+ Arrays.sort(allKeys);
+
+ // The segment stores docs in ingestion order and the dict is sorted, so we need to verify
+ // via the actual doc order. Build a set of expected keys.
+ java.util.Set expectedSet = new java.util.HashSet<>();
+ for (long k : _expectedKeys) {
+ expectedSet.add(k);
+ }
+ for (int i = 0; i < NUM_ROWS; i++) {
+ expectedSet.remove(result[i]);
+ }
+ assertEquals(expectedSet.isEmpty(), true, "Some expected keys were not returned: " + expectedSet);
+ }
+
+ @Test
+ public void testUrnKeyLongResultIsLongType() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnKeyLong(" + URN_COL + ")"), _dataSourceMap);
+ assertEquals(fn.getResultMetadata().getDataType(), FieldSpec.DataType.LONG);
+ }
+
+ // --- urnKeyLong on untyped URN column ---
+
+ @Test
+ public void testUrnKeyLongOnUntypedUrnColumn() {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnKeyLong(" + UNTYPED_URN_COL + ")"), _dataSourceMap);
+ assertNotNull(fn);
+
+ long[] result = fn.transformToLongValuesSV(_projectionBlock);
+ assertNotNull(result);
+ assertEquals(result.length, NUM_ROWS);
+
+ java.util.Set expectedSet = new java.util.HashSet<>();
+ for (long k : _expectedKeys) {
+ expectedSet.add(k);
+ }
+ for (int i = 0; i < NUM_ROWS; i++) {
+ expectedSet.remove(result[i]);
+ }
+ assertEquals(expectedSet.isEmpty(), true, "Untyped urnKeyLong missing keys: " + expectedSet);
+ }
+
+ // --- Consistency: typed and untyped columns return same entity ---
+
+ @Test
+ public void testTypedAndUntypedReturnSameEntity() {
+ TransformFunction typedFn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + URN_COL + ")"), _dataSourceMap);
+ TransformFunction untypedFn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + UNTYPED_URN_COL + ")"), _dataSourceMap);
+
+ String[] typed = typedFn.transformToStringValuesSV(_projectionBlock);
+ String[] untyped = untypedFn.transformToStringValuesSV(_projectionBlock);
+
+ for (int i = 0; i < NUM_ROWS; i++) {
+ assertEquals(typed[i], untyped[i], "Entity mismatch at row " + i);
+ }
+ }
+
+ // --- Dictionary reconstruction: SELECT on typed column returns full URN strings ---
+
+ @Test
+ public void testTypedUrnDictionaryReconstructsUrn() {
+ DataSource ds = _dataSourceMap.get(URN_COL);
+ assertNotNull(ds);
+ Dictionary dict = ds.getDictionary();
+ assertNotNull(dict, "Dictionary should exist for typed URN column");
+
+ for (int dictId = 0; dictId < dict.length(); dictId++) {
+ String strVal = dict.getStringValue(dictId);
+ assertNotNull(strVal);
+ assertTrue(strVal.startsWith("urn:li:memberId:"), "Expected URN string but got: " + strVal);
+ }
+ }
+
+ // --- Predicate evaluator: EQ and IN on typed LONG URN column ---
+
+ @Test
+ public void testEqualityPredicateOnTypedUrn() {
+ DataSource ds = _dataSourceMap.get(URN_COL);
+ Dictionary dict = ds.getDictionary();
+
+ // "urn:li:memberId:100" is the first ingested row, so its key exists in the dict
+ ExpressionContext col = ExpressionContext.forIdentifier(URN_COL);
+ EqPredicate eq = new EqPredicate(col, "urn:li:memberId:100");
+ BaseDictionaryBasedPredicateEvaluator eval =
+ EqualsPredicateEvaluatorFactory.newDictionaryBasedEvaluator(eq, dict, FieldSpec.DataType.URN);
+
+ int[] matching = eval.getMatchingDictIds();
+ assertEquals(matching.length, 1);
+ assertEquals(dict.getStringValue(matching[0]), "urn:li:memberId:100");
+ }
+
+ @Test
+ public void testInPredicateOnTypedUrn() {
+ DataSource ds = _dataSourceMap.get(URN_COL);
+ Dictionary dict = ds.getDictionary();
+
+ ExpressionContext col = ExpressionContext.forIdentifier(URN_COL);
+ InPredicate in = new InPredicate(col, Lists.newArrayList("urn:li:memberId:100", "urn:li:memberId:101"));
+ BaseDictionaryBasedPredicateEvaluator eval =
+ InPredicateEvaluatorFactory.newDictionaryBasedEvaluator(in, dict, FieldSpec.DataType.URN, null);
+
+ int[] matching = eval.getMatchingDictIds();
+ assertEquals(matching.length, 2);
+ IntSet matchingStrings = new IntOpenHashSet();
+ for (int dictId : matching) {
+ String val = dict.getStringValue(dictId);
+ assertTrue(val.equals("urn:li:memberId:100") || val.equals("urn:li:memberId:101"),
+ "Unexpected value: " + val);
+ matchingStrings.add(dictId);
+ }
+ assertEquals(matchingStrings.size(), 2);
+ }
+
+ @Test
+ public void testRangePredicateOnTypedUrn() {
+ DataSource ds = _dataSourceMap.get(URN_COL);
+ Dictionary dict = ds.getDictionary();
+
+ // Range: memberId BETWEEN 'urn:li:memberId:100' AND 'urn:li:memberId:102' (inclusive)
+ ExpressionContext col = ExpressionContext.forIdentifier(URN_COL);
+ RangePredicate range = new RangePredicate(col, true, "urn:li:memberId:100", true, "urn:li:memberId:102",
+ FieldSpec.DataType.URN);
+ BaseDictionaryBasedPredicateEvaluator eval =
+ RangePredicateEvaluatorFactory.newDictionaryBasedEvaluator(range, dict, FieldSpec.DataType.URN);
+
+ // Should match keys 100, 101, 102
+ int[] matching = eval.getMatchingDictIds();
+ assertEquals(matching.length, 3, "Expected 3 matching dict IDs for range [100, 102]");
+ for (int dictId : matching) {
+ String val = dict.getStringValue(dictId);
+ long key = Long.parseLong(val.substring("urn:li:memberId:".length()));
+ assertTrue(key >= 100 && key <= 102, "Key out of range: " + key);
+ }
+ }
+}
diff --git a/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeUrnRewriteTest.java b/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeUrnRewriteTest.java
new file mode 100644
index 0000000000..6490d14e58
--- /dev/null
+++ b/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeUrnRewriteTest.java
@@ -0,0 +1,185 @@
+/**
+ * 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.plan;
+
+import com.google.common.collect.Lists;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.request.context.FunctionContext;
+import org.apache.pinot.common.request.context.predicate.EqPredicate;
+import org.apache.pinot.common.request.context.predicate.InPredicate;
+import org.apache.pinot.common.request.context.predicate.NotEqPredicate;
+import org.apache.pinot.common.request.context.predicate.NotInPredicate;
+import org.apache.pinot.common.request.context.predicate.Predicate;
+import org.apache.pinot.common.request.context.predicate.RangePredicate;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
+import org.apache.pinot.segment.spi.IndexSegment;
+import org.apache.pinot.segment.spi.SegmentContext;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.mockito.Mockito;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertSame;
+import static org.testng.Assert.assertTrue;
+
+
+/**
+ * Unit tests for {@link FilterPlanNode#rewriteUrnKeyLongPredicate} — the rewrite that turns
+ * {@code urnKeyLong(typed_long_urn_col) OP literal} into {@code col OP entityPrefix:literal} so
+ * the predicate hits the dictionary-based fast path instead of going through
+ * {@code ExpressionFilterOperator}.
+ */
+public class FilterPlanNodeUrnRewriteTest {
+
+ private static final String TYPED_COL = "memberId";
+ private static final String MULTI_COL = "actorId";
+ private static final String UNTYPED_COL = "rawUrn";
+ private static final String STR_COL = "name";
+
+ private FilterPlanNode _planNode;
+
+ @BeforeClass
+ public void setUp() {
+ DimensionFieldSpec typedDim = new DimensionFieldSpec(TYPED_COL, FieldSpec.DataType.URN, true);
+ typedDim.setUrnTypes(List.of(DimensionFieldSpec.UrnType.of("urn:li:memberId", FieldSpec.DataType.LONG)));
+
+ DimensionFieldSpec multiDim = new DimensionFieldSpec(MULTI_COL, FieldSpec.DataType.URN, true);
+ multiDim.setUrnTypes(Arrays.asList(
+ DimensionFieldSpec.UrnType.of("urn:li:memberId", FieldSpec.DataType.LONG),
+ DimensionFieldSpec.UrnType.of("urn:li:groupId", FieldSpec.DataType.LONG)));
+
+ Schema schema = new Schema.SchemaBuilder()
+ .addField(typedDim)
+ .addField(multiDim)
+ .addSingleValueDimension(UNTYPED_COL, FieldSpec.DataType.URN)
+ .addSingleValueDimension(STR_COL, FieldSpec.DataType.STRING)
+ .build();
+
+ QueryContext queryContext = QueryContextConverterUtils.getQueryContext("SELECT 1 FROM t");
+ queryContext.setSchema(schema);
+
+ IndexSegment indexSegment = Mockito.mock(IndexSegment.class);
+ SegmentContext segmentContext = Mockito.mock(SegmentContext.class);
+ Mockito.when(segmentContext.getIndexSegment()).thenReturn(indexSegment);
+ _planNode = new FilterPlanNode(segmentContext, queryContext);
+ }
+
+ @Test
+ public void rewritesEqOnTypedLongUrn() {
+ Predicate in = new EqPredicate(urnKeyLongOf(TYPED_COL), "1000");
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(in);
+ assertTrue(out instanceof EqPredicate);
+ EqPredicate eq = (EqPredicate) out;
+ assertEquals(eq.getLhs().getIdentifier(), TYPED_COL);
+ assertEquals(eq.getValue(), "urn:li:memberId:1000");
+ }
+
+ @Test
+ public void rewritesNotEqOnTypedLongUrn() {
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(new NotEqPredicate(urnKeyLongOf(TYPED_COL), "42"));
+ assertTrue(out instanceof NotEqPredicate);
+ NotEqPredicate neq = (NotEqPredicate) out;
+ assertEquals(neq.getLhs().getIdentifier(), TYPED_COL);
+ assertEquals(neq.getValue(), "urn:li:memberId:42");
+ }
+
+ @Test
+ public void rewritesInOnTypedLongUrn() {
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(
+ new InPredicate(urnKeyLongOf(TYPED_COL), Lists.newArrayList("1", "2", "3")));
+ assertTrue(out instanceof InPredicate);
+ InPredicate in = (InPredicate) out;
+ assertEquals(in.getLhs().getIdentifier(), TYPED_COL);
+ assertEquals(in.getValues(),
+ Arrays.asList("urn:li:memberId:1", "urn:li:memberId:2", "urn:li:memberId:3"));
+ }
+
+ @Test
+ public void rewritesNotInOnTypedLongUrn() {
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(
+ new NotInPredicate(urnKeyLongOf(TYPED_COL), Lists.newArrayList("9", "10")));
+ assertTrue(out instanceof NotInPredicate);
+ NotInPredicate nin = (NotInPredicate) out;
+ assertEquals(nin.getValues(), Arrays.asList("urn:li:memberId:9", "urn:li:memberId:10"));
+ }
+
+ @Test
+ public void rewritesBoundedRangeOnTypedLongUrn() {
+ RangePredicate in = new RangePredicate(urnKeyLongOf(TYPED_COL), true, "100", false, "200",
+ FieldSpec.DataType.LONG);
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(in);
+ assertTrue(out instanceof RangePredicate);
+ RangePredicate r = (RangePredicate) out;
+ assertEquals(r.getLhs().getIdentifier(), TYPED_COL);
+ assertEquals(r.getLowerBound(), "urn:li:memberId:100");
+ assertEquals(r.getUpperBound(), "urn:li:memberId:200");
+ assertEquals(r.isLowerInclusive(), true);
+ assertEquals(r.isUpperInclusive(), false);
+ }
+
+ @Test
+ public void rangeWithUnboundedLowerKeepsSentinel() {
+ RangePredicate in = new RangePredicate(urnKeyLongOf(TYPED_COL), false, RangePredicate.UNBOUNDED, true, "500",
+ FieldSpec.DataType.LONG);
+ RangePredicate out = (RangePredicate) _planNode.rewriteUrnKeyLongPredicate(in);
+ assertEquals(out.getLowerBound(), RangePredicate.UNBOUNDED);
+ assertEquals(out.getUpperBound(), "urn:li:memberId:500");
+ }
+
+ @Test
+ public void skipsMultiEntityUrnColumn() {
+ Predicate in = new EqPredicate(urnKeyLongOf(MULTI_COL), "1000");
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(in);
+ assertSame(out, in, "Multi-entity URN columns are ambiguous; rewrite must be skipped");
+ }
+
+ @Test
+ public void skipsUntypedUrnColumn() {
+ Predicate in = new EqPredicate(urnKeyLongOf(UNTYPED_COL), "1000");
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(in);
+ assertSame(out, in);
+ }
+
+ @Test
+ public void skipsNonUrnFunctionCall() {
+ ExpressionContext expr = ExpressionContext.forFunction(new FunctionContext(FunctionContext.Type.TRANSFORM,
+ "length", List.of(ExpressionContext.forIdentifier(STR_COL))));
+ Predicate in = new EqPredicate(expr, "5");
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(in);
+ assertSame(out, in);
+ }
+
+ @Test
+ public void skipsIdentifierPredicate() {
+ Predicate in = new EqPredicate(ExpressionContext.forIdentifier(STR_COL), "x");
+ Predicate out = _planNode.rewriteUrnKeyLongPredicate(in);
+ assertSame(out, in);
+ }
+
+ private static ExpressionContext urnKeyLongOf(String column) {
+ return ExpressionContext.forFunction(new FunctionContext(FunctionContext.Type.TRANSFORM, "urnKeyLong",
+ List.of(ExpressionContext.forIdentifier(column))));
+ }
+}
diff --git a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkUrnTransform.java b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkUrnTransform.java
new file mode 100644
index 0000000000..a3251e0fed
--- /dev/null
+++ b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkUrnTransform.java
@@ -0,0 +1,508 @@
+/**
+ * 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.perf;
+
+import com.google.common.io.Files;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import org.apache.pinot.common.request.context.RequestContextUtils;
+import org.apache.pinot.core.common.BlockDocIdIterator;
+import org.apache.pinot.core.operator.DocIdSetOperator;
+import org.apache.pinot.core.operator.ProjectionOperator;
+import org.apache.pinot.core.operator.blocks.ProjectionBlock;
+import org.apache.pinot.core.operator.filter.BaseFilterOperator;
+import org.apache.pinot.core.operator.filter.MatchAllFilterOperator;
+import org.apache.pinot.core.operator.transform.function.TransformFunction;
+import org.apache.pinot.core.operator.transform.function.TransformFunctionFactory;
+import org.apache.pinot.core.plan.DocIdSetPlanNode;
+import org.apache.pinot.core.plan.FilterPlanNode;
+import org.apache.pinot.core.query.request.context.QueryContext;
+import org.apache.pinot.core.query.request.context.utils.QueryContextConverterUtils;
+import org.apache.pinot.segment.local.indexsegment.immutable.ImmutableSegmentLoader;
+import org.apache.pinot.segment.local.segment.readers.GenericRowRecordReader;
+import org.apache.pinot.segment.local.segment.readers.PinotSegmentUtil;
+import org.apache.pinot.segment.spi.Constants;
+import org.apache.pinot.segment.spi.ImmutableSegment;
+import org.apache.pinot.segment.spi.SegmentContext;
+import org.apache.pinot.segment.spi.datasource.DataSource;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.utils.ReadMode;
+import org.apache.pinot.spi.utils.builder.TableConfigBuilder;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+
+/**
+ * End-to-end JMH benchmark comparing typed and untyped URN columns:
+ *
+ *
Transform function microbenchmarks (urnEntity, urnKeyLong) on single-entity columns.
Full filter pipeline (FilterPlanNode -> execution) for "col = literal" and
+ * "urnEntity(col) = literal" queries, on both single-entity and multi-entity columns.
+ *
On-disk storage sizes for single-entity and multi-entity segments, printed at setup.
+ *
+ */
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(1)
+@Warmup(iterations = 2, time = 2)
+@Measurement(iterations = 3, time = 2)
+@State(Scope.Benchmark)
+public class BenchmarkUrnTransform {
+
+ private static final int NUM_ROWS = 100_000;
+ private static final int CARDINALITY = 10_000;
+
+ private static final String SINGLE_ENTITY = "urn:li:memberId";
+ private static final String[] MULTI_ENTITIES =
+ {"urn:li:corpuser", "urn:li:corpGroup", "urn:li:applicationName"};
+
+ // Existing transform-benchmark segment with both typed and untyped columns.
+ private static final String TYPED_COL = "typedId";
+ private static final String UNTYPED_COL = "untypedId";
+ private static final String TIME_COL = "t";
+
+ private File _segmentDir;
+ private ImmutableSegment _segment;
+ private Map _dataSourceMap;
+ private ProjectionBlock _projectionBlock;
+ private Dictionary _typedDict;
+ private Dictionary _untypedDict;
+ private String[] _lookupValues;
+
+ // Dedicated segments per layout (typed vs untyped, single vs multi) for storage and filter
+ // benchmarks. Each segment contains exactly one URN column plus the time column so the
+ // on-disk size is directly comparable.
+ private SegmentBundle _singleTyped;
+ private SegmentBundle _singleUntyped;
+ private SegmentBundle _multiTyped;
+ private SegmentBundle _multiUntyped;
+
+ private QueryContext _qEqSingleTyped;
+ private QueryContext _qEqSingleUntyped;
+ private QueryContext _qEqMultiTyped;
+ private QueryContext _qEqMultiUntyped;
+
+ private QueryContext _qUrnEntitySingleTyped;
+ private QueryContext _qUrnEntitySingleUntyped;
+ private QueryContext _qUrnEntityMultiTyped;
+ private QueryContext _qUrnEntityMultiUntyped;
+
+ public static void main(String[] args) throws Exception {
+ new Runner(new OptionsBuilder()
+ .include(BenchmarkUrnTransform.class.getSimpleName())
+ .build()).run();
+ }
+
+ @Setup
+ public void setUp() throws Exception {
+ setUpTransformSegment();
+ setUpFilterSegments();
+ setUpQueryContexts();
+ printStorageSizes();
+ }
+
+ private void setUpTransformSegment() throws Exception {
+ _segmentDir = Files.createTempDir();
+
+ DimensionFieldSpec typed = new DimensionFieldSpec(TYPED_COL, FieldSpec.DataType.URN, true);
+ typed.setUrnTypes(List.of(DimensionFieldSpec.UrnType.of(SINGLE_ENTITY, FieldSpec.DataType.LONG)));
+
+ Schema schema = new Schema.SchemaBuilder()
+ .addField(typed)
+ .addSingleValueDimension(UNTYPED_COL, FieldSpec.DataType.URN)
+ .addDateTime(TIME_COL, FieldSpec.DataType.LONG, "1:MILLISECONDS:EPOCH", "1:MILLISECONDS")
+ .build();
+
+ TableConfig tableConfig = new TableConfigBuilder(TableType.OFFLINE).setTableName("t").build();
+
+ List rows = new ArrayList<>(NUM_ROWS);
+ for (int i = 0; i < NUM_ROWS; i++) {
+ long key = i % CARDINALITY;
+ String urn = SINGLE_ENTITY + ":" + key;
+ GenericRow row = new GenericRow();
+ row.putValue(TYPED_COL, urn);
+ row.putValue(UNTYPED_COL, urn);
+ row.putValue(TIME_COL, System.currentTimeMillis());
+ rows.add(row);
+ }
+
+ File segDir = PinotSegmentUtil.createSegment(tableConfig, schema, "benchmarkUrnSegment",
+ _segmentDir.toString(), new GenericRowRecordReader(rows));
+ _segment = ImmutableSegmentLoader.load(segDir, ReadMode.mmap);
+
+ _dataSourceMap = new HashMap<>();
+ _dataSourceMap.put(TYPED_COL, _segment.getDataSource(TYPED_COL));
+ _dataSourceMap.put(UNTYPED_COL, _segment.getDataSource(UNTYPED_COL));
+ _dataSourceMap.put(TIME_COL, _segment.getDataSource(TIME_COL));
+
+ int numDocs = _segment.getSegmentMetadata().getTotalDocs();
+ _projectionBlock = new ProjectionOperator(_dataSourceMap,
+ new DocIdSetOperator(new MatchAllFilterOperator(numDocs),
+ DocIdSetPlanNode.MAX_DOC_PER_CALL)).nextBlock();
+
+ _typedDict = _dataSourceMap.get(TYPED_COL).getDictionary();
+ _untypedDict = _dataSourceMap.get(UNTYPED_COL).getDictionary();
+
+ _lookupValues = new String[CARDINALITY];
+ for (int i = 0; i < CARDINALITY; i++) {
+ _lookupValues[i] = SINGLE_ENTITY + ":" + i;
+ }
+ }
+
+ private void setUpFilterSegments() throws Exception {
+ List singleRows = generateSingleEntityRows();
+ List multiRows = generateMultiEntityRows();
+
+ _singleTyped = buildBundle("singleTyped", singleEntitySchema(true), singleRows, "memberId");
+ _singleUntyped = buildBundle("singleUntyped", singleEntitySchema(false), singleRows, "memberId");
+ _multiTyped = buildBundle("multiTyped", multiEntitySchema(true), multiRows, "actorId");
+ _multiUntyped = buildBundle("multiUntyped", multiEntitySchema(false), multiRows, "actorId");
+ }
+
+ private void setUpQueryContexts() {
+ String singleLiteral = SINGLE_ENTITY + ":1234";
+ String multiLiteral = MULTI_ENTITIES[0] + ":1234";
+
+ _qEqSingleTyped = compile(_singleTyped, "SELECT t FROM t WHERE memberId = '" + singleLiteral + "'");
+ _qEqSingleUntyped = compile(_singleUntyped, "SELECT t FROM t WHERE memberId = '" + singleLiteral + "'");
+ _qEqMultiTyped = compile(_multiTyped, "SELECT t FROM t WHERE actorId = '" + multiLiteral + "'");
+ _qEqMultiUntyped = compile(_multiUntyped, "SELECT t FROM t WHERE actorId = '" + multiLiteral + "'");
+
+ _qUrnEntitySingleTyped = compile(_singleTyped,
+ "SELECT t FROM t WHERE urnEntity(memberId) = '" + SINGLE_ENTITY + "'");
+ _qUrnEntitySingleUntyped = compile(_singleUntyped,
+ "SELECT t FROM t WHERE urnEntity(memberId) = '" + SINGLE_ENTITY + "'");
+ _qUrnEntityMultiTyped = compile(_multiTyped,
+ "SELECT t FROM t WHERE urnEntity(actorId) = '" + MULTI_ENTITIES[0] + "'");
+ _qUrnEntityMultiUntyped = compile(_multiUntyped,
+ "SELECT t FROM t WHERE urnEntity(actorId) = '" + MULTI_ENTITIES[0] + "'");
+ }
+
+ private void printStorageSizes() {
+ System.out.println();
+ System.out.println("===== Storage sizes (NUM_ROWS=" + NUM_ROWS + ", CARDINALITY=" + CARDINALITY + ") =====");
+ long st = FileUtils.sizeOfDirectory(_singleTyped._segDir);
+ long su = FileUtils.sizeOfDirectory(_singleUntyped._segDir);
+ long mt = FileUtils.sizeOfDirectory(_multiTyped._segDir);
+ long mu = FileUtils.sizeOfDirectory(_multiUntyped._segDir);
+ System.out.println("Total segment size:");
+ System.out.printf(" single-entity typed: %,11d bytes%n", st);
+ System.out.printf(" single-entity untyped: %,11d bytes (%.2fx vs typed)%n", su, ratio(su, st));
+ System.out.printf(" multi-entity typed: %,11d bytes%n", mt);
+ System.out.printf(" multi-entity untyped: %,11d bytes (%.2fx vs typed)%n", mu, ratio(mu, mt));
+ System.out.println();
+ long std = dictSize(_singleTyped);
+ long sud = dictSize(_singleUntyped);
+ long mtd = dictSize(_multiTyped);
+ long mud = dictSize(_multiUntyped);
+ System.out.println("Dictionary file size (.dict only):");
+ System.out.printf(" single-entity typed: %,11d bytes%n", std);
+ System.out.printf(" single-entity untyped: %,11d bytes (%.2fx vs typed)%n", sud, ratio(sud, std));
+ System.out.printf(" multi-entity typed: %,11d bytes%n", mtd);
+ System.out.printf(" multi-entity untyped: %,11d bytes (%.2fx vs typed)%n", mud, ratio(mud, mtd));
+ System.out.println("================================================================");
+ System.out.println();
+ }
+
+ /**
+ * Returns the on-disk size of the URN main column's dictionary in this segment bundle.
+ * Reads v3-layout {@code index_map} for the {@code
.dictionary.size} entry; falls back
+ * to a v1-layout {@code
.dict} sidecar file when present.
+ */
+ private static long dictSize(SegmentBundle bundle) {
+ File segDir = bundle._segDir;
+ File v3IndexMap = new File(new File(segDir, "v3"), "index_map");
+ if (v3IndexMap.isFile()) {
+ try {
+ String key = bundle._urnColName + ".dictionary.size";
+ for (String line : java.nio.file.Files.readAllLines(v3IndexMap.toPath())) {
+ String trimmed = line.trim();
+ if (trimmed.startsWith(key)) {
+ int eq = trimmed.indexOf('=');
+ if (eq > 0) {
+ return Long.parseLong(trimmed.substring(eq + 1).trim());
+ }
+ }
+ }
+ } catch (java.io.IOException e) {
+ // fall through to v1 sidecar
+ }
+ }
+ File v1Dict = new File(segDir, bundle._urnColName + ".dict");
+ return v1Dict.isFile() ? v1Dict.length() : 0;
+ }
+
+ private static double ratio(long a, long b) {
+ return b == 0 ? 0.0 : (double) a / (double) b;
+ }
+
+ // ---------------- Schemas / data ----------------
+
+ private static Schema singleEntitySchema(boolean typed) {
+ Schema.SchemaBuilder builder = new Schema.SchemaBuilder();
+ if (typed) {
+ DimensionFieldSpec dim = new DimensionFieldSpec("memberId", FieldSpec.DataType.URN, true);
+ dim.setUrnTypes(List.of(DimensionFieldSpec.UrnType.of(SINGLE_ENTITY, FieldSpec.DataType.LONG)));
+ builder.addField(dim);
+ } else {
+ builder.addSingleValueDimension("memberId", FieldSpec.DataType.URN);
+ }
+ return builder.addDateTime(TIME_COL, FieldSpec.DataType.LONG, "1:MILLISECONDS:EPOCH", "1:MILLISECONDS").build();
+ }
+
+ private static Schema multiEntitySchema(boolean typed) {
+ Schema.SchemaBuilder builder = new Schema.SchemaBuilder();
+ if (typed) {
+ DimensionFieldSpec dim = new DimensionFieldSpec("actorId", FieldSpec.DataType.URN, true);
+ dim.setUrnTypes(Arrays.asList(
+ DimensionFieldSpec.UrnType.of(MULTI_ENTITIES[0], FieldSpec.DataType.LONG),
+ DimensionFieldSpec.UrnType.of(MULTI_ENTITIES[1], FieldSpec.DataType.LONG),
+ DimensionFieldSpec.UrnType.of(MULTI_ENTITIES[2], FieldSpec.DataType.LONG)));
+ builder.addField(dim);
+ } else {
+ builder.addSingleValueDimension("actorId", FieldSpec.DataType.URN);
+ }
+ return builder.addDateTime(TIME_COL, FieldSpec.DataType.LONG, "1:MILLISECONDS:EPOCH", "1:MILLISECONDS").build();
+ }
+
+ private List generateSingleEntityRows() {
+ long now = System.currentTimeMillis();
+ List rows = new ArrayList<>(NUM_ROWS);
+ for (int i = 0; i < NUM_ROWS; i++) {
+ long key = i % CARDINALITY;
+ GenericRow row = new GenericRow();
+ row.putValue("memberId", SINGLE_ENTITY + ":" + key);
+ row.putValue(TIME_COL, now);
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ private List generateMultiEntityRows() {
+ long now = System.currentTimeMillis();
+ List rows = new ArrayList<>(NUM_ROWS);
+ int perEntity = CARDINALITY / MULTI_ENTITIES.length;
+ for (int i = 0; i < NUM_ROWS; i++) {
+ String entity = MULTI_ENTITIES[i % MULTI_ENTITIES.length];
+ long key = (i / MULTI_ENTITIES.length) % perEntity;
+ GenericRow row = new GenericRow();
+ row.putValue("actorId", entity + ":" + key);
+ row.putValue(TIME_COL, now);
+ rows.add(row);
+ }
+ return rows;
+ }
+
+ private SegmentBundle buildBundle(String name, Schema schema, List rows, String urnColName)
+ throws Exception {
+ File parent = Files.createTempDir();
+ TableConfig tc = new TableConfigBuilder(TableType.OFFLINE).setTableName("t").build();
+ File segDir =
+ PinotSegmentUtil.createSegment(tc, schema, name, parent.toString(), new GenericRowRecordReader(rows));
+ ImmutableSegment seg = ImmutableSegmentLoader.load(segDir, ReadMode.mmap);
+ return new SegmentBundle(parent, segDir, seg, schema, urnColName);
+ }
+
+ private QueryContext compile(SegmentBundle bundle, String sql) {
+ QueryContext qc = QueryContextConverterUtils.getQueryContext(sql);
+ qc.setSchema(bundle._schema);
+ return qc;
+ }
+
+ @TearDown
+ public void tearDown() {
+ if (_segment != null) {
+ _segment.destroy();
+ }
+ FileUtils.deleteQuietly(_segmentDir);
+ closeBundle(_singleTyped);
+ closeBundle(_singleUntyped);
+ closeBundle(_multiTyped);
+ closeBundle(_multiUntyped);
+ }
+
+ private static void closeBundle(SegmentBundle bundle) {
+ if (bundle == null) {
+ return;
+ }
+ if (bundle._segment != null) {
+ bundle._segment.destroy();
+ }
+ FileUtils.deleteQuietly(bundle._parentDir);
+ }
+
+ // ---------------- Transform microbenchmarks (existing) ----------------
+
+ @Benchmark
+ public void transformTypedEntity(Blackhole bh) {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + TYPED_COL + ")"), _dataSourceMap);
+ bh.consume(fn.transformToStringValuesSV(_projectionBlock));
+ }
+
+ @Benchmark
+ public void transformUntypedEntity(Blackhole bh) {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnEntity(" + UNTYPED_COL + ")"), _dataSourceMap);
+ bh.consume(fn.transformToStringValuesSV(_projectionBlock));
+ }
+
+ @Benchmark
+ public void transformTypedKey(Blackhole bh) {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnKeyLong(" + TYPED_COL + ")"), _dataSourceMap);
+ bh.consume(fn.transformToLongValuesSV(_projectionBlock));
+ }
+
+ @Benchmark
+ public void transformUntypedKey(Blackhole bh) {
+ TransformFunction fn = TransformFunctionFactory.get(
+ RequestContextUtils.getExpression("urnKeyLong(" + UNTYPED_COL + ")"), _dataSourceMap);
+ bh.consume(fn.transformToLongValuesSV(_projectionBlock));
+ }
+
+ @Benchmark
+ public void dictGetStringTyped(Blackhole bh) {
+ int n = _typedDict.length();
+ for (int i = 0; i < n; i++) {
+ bh.consume(_typedDict.getStringValue(i));
+ }
+ }
+
+ @Benchmark
+ public void dictGetStringUntyped(Blackhole bh) {
+ int n = _untypedDict.length();
+ for (int i = 0; i < n; i++) {
+ bh.consume(_untypedDict.getStringValue(i));
+ }
+ }
+
+ @Benchmark
+ public void dictIndexOfTyped(Blackhole bh) {
+ for (String v : _lookupValues) {
+ bh.consume(_typedDict.indexOf(v));
+ }
+ }
+
+ @Benchmark
+ public void dictIndexOfUntyped(Blackhole bh) {
+ for (String v : _lookupValues) {
+ bh.consume(_untypedDict.indexOf(v));
+ }
+ }
+
+ // ---------------- End-to-end "col = literal" filter ----------------
+
+ @Benchmark
+ public void filterEqSingleTyped(Blackhole bh) {
+ runFilter(_singleTyped, _qEqSingleTyped, bh);
+ }
+
+ @Benchmark
+ public void filterEqSingleUntyped(Blackhole bh) {
+ runFilter(_singleUntyped, _qEqSingleUntyped, bh);
+ }
+
+ @Benchmark
+ public void filterEqMultiTyped(Blackhole bh) {
+ runFilter(_multiTyped, _qEqMultiTyped, bh);
+ }
+
+ @Benchmark
+ public void filterEqMultiUntyped(Blackhole bh) {
+ runFilter(_multiUntyped, _qEqMultiUntyped, bh);
+ }
+
+ // ---------------- End-to-end "urnEntity(col) = literal" filter ----------------
+
+ @Benchmark
+ public void filterUrnEntitySingleTyped(Blackhole bh) {
+ runFilter(_singleTyped, _qUrnEntitySingleTyped, bh);
+ }
+
+ @Benchmark
+ public void filterUrnEntitySingleUntyped(Blackhole bh) {
+ runFilter(_singleUntyped, _qUrnEntitySingleUntyped, bh);
+ }
+
+ @Benchmark
+ public void filterUrnEntityMultiTyped(Blackhole bh) {
+ runFilter(_multiTyped, _qUrnEntityMultiTyped, bh);
+ }
+
+ @Benchmark
+ public void filterUrnEntityMultiUntyped(Blackhole bh) {
+ runFilter(_multiUntyped, _qUrnEntityMultiUntyped, bh);
+ }
+
+ // ---------------- Helpers ----------------
+
+ private static void runFilter(SegmentBundle bundle, QueryContext qc, Blackhole bh) {
+ SegmentContext sc = new SegmentContext(bundle._segment);
+ BaseFilterOperator op = new FilterPlanNode(sc, qc).run();
+ BlockDocIdIterator it = op.nextBlock().getBlockDocIdSet().iterator();
+ int count = 0;
+ int docId;
+ while ((docId = it.next()) != Constants.EOF) {
+ count++;
+ }
+ bh.consume(count);
+ }
+
+ private static final class SegmentBundle {
+ private final File _parentDir;
+ private final File _segDir;
+ private final ImmutableSegment _segment;
+ private final Schema _schema;
+ private final String _urnColName;
+
+ SegmentBundle(File parentDir, File segDir, ImmutableSegment segment, Schema schema, String urnColName) {
+ _parentDir = parentDir;
+ _segDir = segDir;
+ _segment = segment;
+ _schema = schema;
+ _urnColName = urnColName;
+ }
+ }
+}
diff --git a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RexExpressionUtils.java b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RexExpressionUtils.java
index afef239ff9..d8bad3eb8e 100644
--- a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RexExpressionUtils.java
+++ b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RexExpressionUtils.java
@@ -137,7 +137,8 @@ public static RexLiteral toRexLiteral(RelBuilder builder, RexExpression.Literal
return rexBuilder.makeTimestampLiteral(tsString, 1);
}
case JSON:
- case STRING: {
+ case STRING:
+ case URN: {
assert value != null;
return rexBuilder.makeLiteral((String) value);
}
diff --git a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/RexExpressionToProtoExpression.java b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/RexExpressionToProtoExpression.java
index 072256fe01..edd3f5c068 100644
--- a/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/RexExpressionToProtoExpression.java
+++ b/pinot-query-planner/src/main/java/org/apache/pinot/query/planner/serde/RexExpressionToProtoExpression.java
@@ -150,6 +150,8 @@ public static Expressions.ColumnDataType convertColumnDataType(ColumnDataType da
return Expressions.ColumnDataType.STRING;
case JSON:
return Expressions.ColumnDataType.JSON;
+ case URN:
+ return Expressions.ColumnDataType.STRING;
case BYTES:
return Expressions.ColumnDataType.BYTES;
case MAP:
diff --git a/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeFactory.java b/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeFactory.java
index 8a1450b584..82fb2800d3 100644
--- a/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeFactory.java
+++ b/pinot-query-planner/src/main/java/org/apache/pinot/query/type/TypeFactory.java
@@ -93,6 +93,7 @@ private static SqlTypeName getSqlTypeName(FieldSpec fieldSpec) {
return SqlTypeName.TIMESTAMP;
case STRING:
case JSON:
+ case URN:
return SqlTypeName.VARCHAR;
case BYTES:
return SqlTypeName.VARBINARY;
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/CompositeTransformer.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/CompositeTransformer.java
index 8643dbd43c..c2d2794cdf 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/CompositeTransformer.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/CompositeTransformer.java
@@ -110,6 +110,8 @@ public static List getDefaultTransformers(TableConfig tableCo
addIfNotNoOp(transformers, new FilterTransformer(tableConfig));
addIfNotNoOp(transformers, new SchemaConformingTransformer(tableConfig, schema));
addIfNotNoOp(transformers, new DataTypeTransformer(tableConfig, schema));
+ addIfNotNoOp(transformers, new UrnValidationTransformer(tableConfig, schema));
+ addIfNotNoOp(transformers, new UrnKeyEncodingTransformer(schema));
addIfNotNoOp(transformers, new TimeValidationTransformer(tableConfig, schema));
addIfNotNoOp(transformers, new SpecialValueTransformer(schema));
addIfNotNoOp(transformers, new NullValueTransformer(tableConfig, schema));
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/SchemaConformingTransformer.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/SchemaConformingTransformer.java
index 43a08a2f62..640c0d6f72 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/SchemaConformingTransformer.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/SchemaConformingTransformer.java
@@ -591,6 +591,7 @@ private void putExtrasField(String fieldName, DataType fieldType, MapThree handling modes:
+ *
+ *
Single-entity typed
+ * ({@link DimensionFieldSpec#hasTypedUrnStorage()} but not polymorphic): the URN string
+ * is replaced with its numeric key. No transient field — one entity means no ambiguity.
+ *
Multi-entity polymorphic, all-numeric
+ * ({@link DimensionFieldSpec#usesSortedUrnDictionary()}): URN string replaced with the LONG
+ * (or INT) numeric key, entity index stashed in {@code
__urnEntityIdx}. Downstream
+ * the stats collector pairs (entityIdx, longKey) and the polymorphic creator lays out
+ * per-entity dict-ID ranges.
+ *
Multi-entity polymorphic, mixed-type
+ * ({@link DimensionFieldSpec#usesPolymorphicUrnDictionary()} but not all-numeric): URN
+ * string replaced with the per-entity-declared typed key — {@link Long} for {@code LONG},
+ * {@link Integer} for {@code INT}, {@link String} for {@code STRING}. Entity index stashed
+ * as above. The row's value type is now heterogeneous across rows of the same column, but
+ * the downstream consumers read it through the entity index so the type stays paired with
+ * its entity.
+ *
+ *
+ *
If the URN is {@code null} or unparseable, both the main and transient fields are nulled;
+ * downstream {@link NullValueTransformer} fills defaults. Must run after
+ * {@link UrnValidationTransformer}, which guarantees URN-shaped input.
+ */
+public class UrnKeyEncodingTransformer implements RecordTransformer {
+
+ private final List _encodings;
+
+ public UrnKeyEncodingTransformer(Schema schema) {
+ List encodings = new ArrayList<>();
+ for (FieldSpec fieldSpec : schema.getAllFieldSpecs()) {
+ if (fieldSpec.getDataType() != FieldSpec.DataType.URN
+ || !(fieldSpec instanceof DimensionFieldSpec)) {
+ continue;
+ }
+ DimensionFieldSpec dim = (DimensionFieldSpec) fieldSpec;
+ if (dim.usesPolymorphicUrnDictionary()) {
+ // Multi-entity polymorphic (numeric or mixed). Per-row encoding is type-driven by the
+ // matched entity's declared valueType.
+ encodings.add(new ColumnEncoding(dim.getName(), /*singleEntityStoredType=*/null,
+ dim.getUrnTypes(), dim.transientUrnEntityIdxFieldName()));
+ } else if (dim.hasTypedUrnStorage()) {
+ // Single-entity typed LONG/INT — replace URN string with numeric key, no transient field.
+ encodings.add(new ColumnEncoding(dim.getName(), dim.getEffectiveStoredType(),
+ dim.getUrnTypes(), null));
+ }
+ }
+ _encodings = Collections.unmodifiableList(encodings);
+ }
+
+ @Override
+ public boolean isNoOp() {
+ return _encodings.isEmpty();
+ }
+
+ @Nullable
+ @Override
+ public GenericRow transform(GenericRow record) {
+ for (ColumnEncoding enc : _encodings) {
+ Object raw = record.getValue(enc._columnName);
+ if (raw == null) {
+ if (enc._entityIdxFieldName != null) {
+ record.putValue(enc._entityIdxFieldName, null);
+ }
+ continue;
+ }
+
+ Urn urn = Urn.tryParse(raw.toString());
+ if (urn == null || !urn.isSimple()) {
+ nullOut(record, enc);
+ continue;
+ }
+
+ if (enc._entityIdxFieldName != null) {
+ // Polymorphic multi-entity path.
+ int entityIdx = -1;
+ String entityType = urn.entityPrefix();
+ for (int i = 0; i < enc._urnTypes.size(); i++) {
+ if (enc._urnTypes.get(i).getEntityType().equals(entityType)) {
+ entityIdx = i;
+ break;
+ }
+ }
+ if (entityIdx < 0) {
+ nullOut(record, enc);
+ continue;
+ }
+ DimensionFieldSpec.UrnType matched = enc._urnTypes.get(entityIdx);
+ Object typedKey = parseKey(urn.simpleKey(), matched.getValueType());
+ if (typedKey == null) {
+ nullOut(record, enc);
+ continue;
+ }
+ record.putValue(enc._entityIdxFieldName, entityIdx);
+ record.putValue(enc._columnName, typedKey);
+ } else if (enc._singleEntityStoredType != null) {
+ // Single-entity typed path.
+ Object typedKey = parseKey(urn.simpleKey(), enc._singleEntityStoredType);
+ if (typedKey == null) {
+ record.putValue(enc._columnName, null);
+ } else {
+ record.putValue(enc._columnName, typedKey);
+ }
+ }
+ }
+ return record;
+ }
+
+ @Nullable
+ private static Object parseKey(String keyStr, @Nullable FieldSpec.DataType type) {
+ if (type == null) {
+ // Untyped entity in a polymorphic column — should not happen because
+ // usesPolymorphicUrnDictionary() requires every entity to declare a valueType.
+ return keyStr;
+ }
+ try {
+ switch (type) {
+ case LONG:
+ return Long.parseLong(keyStr);
+ case INT:
+ return Integer.parseInt(keyStr);
+ case STRING:
+ return keyStr;
+ default:
+ return null;
+ }
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ private static void nullOut(GenericRow record, ColumnEncoding enc) {
+ record.putValue(enc._columnName, null);
+ if (enc._entityIdxFieldName != null) {
+ record.putValue(enc._entityIdxFieldName, null);
+ }
+ }
+
+ private static final class ColumnEncoding {
+ final String _columnName;
+ /** Single-entity typed path only. {@code null} for polymorphic columns. */
+ @Nullable
+ final FieldSpec.DataType _singleEntityStoredType;
+ final List _urnTypes;
+ /** Transient entity-index field name for polymorphic columns. {@code null} for single-entity. */
+ @Nullable
+ final String _entityIdxFieldName;
+
+ ColumnEncoding(String columnName, @Nullable FieldSpec.DataType singleEntityStoredType,
+ List urnTypes, @Nullable String entityIdxFieldName) {
+ _columnName = columnName;
+ _singleEntityStoredType = singleEntityStoredType;
+ _urnTypes = urnTypes;
+ _entityIdxFieldName = entityIdxFieldName;
+ }
+ }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/UrnValidationTransformer.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/UrnValidationTransformer.java
new file mode 100644
index 0000000000..41f9ef090e
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/recordtransformer/UrnValidationTransformer.java
@@ -0,0 +1,158 @@
+/**
+ * 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.segment.local.recordtransformer;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.ingestion.IngestionConfig;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec;
+import org.apache.pinot.spi.data.Schema;
+import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.recordtransformer.RecordTransformer;
+import org.apache.pinot.spi.utils.Urn;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/**
+ * Validates values in URN-typed columns and rejects non-URN-shaped input.
+ *
+ *
Two validation modes, selected per column based on its schema:
+ *
+ *
Declared urnTypes: the value must parse as a simple URN and its entity prefix
+ * must appear in the column's {@link DimensionFieldSpec#getUrnTypes()} list.
+ *
Untyped URN (no {@code urnTypes}): the value must parse as a simple URN. This is
+ * the load-bearing constraint that lets {@link
+ * org.apache.pinot.segment.local.segment.creator.impl.SegmentColumnarIndexCreator}
+ * always auto-detect URN structure at segment build. Together with the per-segment
+ * structure metadata, it removes any need for the {@code urnEntity()} transform to parse
+ * URN strings at query time.
+ *
+ *
+ *
Behavior on validation failure follows the table's {@code continueOnError} flag, mirroring
+ * Pinot's JSON ingest mode pattern:
+ *
+ *
{@code continueOnError = false} (default): throws {@link IllegalStateException}; the row
+ * is rejected and ingestion fails (the equivalent of JSON's {@code FAIL} mode).
+ *
{@code continueOnError = true}: the value is replaced with {@code null}
+ * ({@link NullValueTransformer} fills the column default), the row is marked
+ * {@link GenericRow#INCOMPLETE_RECORD_KEY incomplete}, and ingestion continues — the
+ * equivalent of JSON's {@code SKIP} mode.
+ *
+ *
+ *
NOTE: must run after {@link DataTypeTransformer} so that all values are already
+ * converted to their schema types, and before {@link NullValueTransformer} so that
+ * invalid values set to {@code null} are handled correctly.
+ */
+public class UrnValidationTransformer implements RecordTransformer {
+ private static final Logger LOGGER = LoggerFactory.getLogger(UrnValidationTransformer.class);
+
+ /** Maps column name → set of allowed entity prefixes for typed columns (empty set means untyped). */
+ private final Map> _columnAllowedPrefixes;
+ /** Untyped URN columns -- value must parse as a simple URN but any entity is accepted. */
+ private final Set _untypedUrnColumns;
+ private final boolean _continueOnError;
+
+ public UrnValidationTransformer(TableConfig tableConfig, Schema schema) {
+ Map> typed = new HashMap<>();
+ Set untyped = new LinkedHashSet<>();
+ for (FieldSpec fieldSpec : schema.getAllFieldSpecs()) {
+ if (fieldSpec.getDataType() != FieldSpec.DataType.URN
+ || !(fieldSpec instanceof DimensionFieldSpec)) {
+ continue;
+ }
+ DimensionFieldSpec dim = (DimensionFieldSpec) fieldSpec;
+ List urnTypes = dim.getUrnTypes();
+ if (urnTypes == null || urnTypes.isEmpty()) {
+ untyped.add(fieldSpec.getName());
+ continue;
+ }
+ Set prefixes = new LinkedHashSet<>();
+ for (DimensionFieldSpec.UrnType t : urnTypes) {
+ prefixes.add(t.getEntityType());
+ }
+ typed.put(fieldSpec.getName(), Collections.unmodifiableSet(prefixes));
+ }
+ _columnAllowedPrefixes = Collections.unmodifiableMap(typed);
+ _untypedUrnColumns = Collections.unmodifiableSet(untyped);
+
+ IngestionConfig ingestionConfig = tableConfig.getIngestionConfig();
+ _continueOnError = ingestionConfig != null && ingestionConfig.isContinueOnError();
+ }
+
+ @Override
+ public boolean isNoOp() {
+ return _columnAllowedPrefixes.isEmpty() && _untypedUrnColumns.isEmpty();
+ }
+
+ @Nullable
+ @Override
+ public GenericRow transform(GenericRow record) {
+ for (Map.Entry> entry : _columnAllowedPrefixes.entrySet()) {
+ validateAgainstAllowedPrefixes(record, entry.getKey(), entry.getValue());
+ }
+ for (String column : _untypedUrnColumns) {
+ validateUntypedUrnShape(record, column);
+ }
+ return record;
+ }
+
+ private void validateAgainstAllowedPrefixes(GenericRow record, String column, Set allowed) {
+ Object rawValue = record.getValue(column);
+ if (rawValue == null) {
+ return;
+ }
+ String valueStr = rawValue.toString();
+ Urn urn = Urn.tryParse(valueStr);
+ if (urn == null || !allowed.contains(urn.entityPrefix())) {
+ reject(record, column, String.format(
+ "URN value '%s' in column '%s' does not match any declared urnTypes %s", valueStr, column, allowed));
+ }
+ }
+
+ private void validateUntypedUrnShape(GenericRow record, String column) {
+ Object rawValue = record.getValue(column);
+ if (rawValue == null) {
+ return;
+ }
+ String valueStr = rawValue.toString();
+ Urn urn = Urn.tryParse(valueStr);
+ if (urn == null || !urn.isSimple()) {
+ reject(record, column,
+ String.format("URN value '%s' in untyped URN column '%s' is not a simple URN", valueStr, column));
+ }
+ }
+
+ private void reject(GenericRow record, String column, String errorMessage) {
+ if (_continueOnError) {
+ LOGGER.debug(errorMessage);
+ record.putValue(column, null);
+ record.putValue(GenericRow.INCOMPLETE_RECORD_KEY, true);
+ } else {
+ throw new IllegalStateException(errorMessage);
+ }
+ }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentColumnarIndexCreator.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentColumnarIndexCreator.java
index a55ef41dfd..a1db012465 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentColumnarIndexCreator.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentColumnarIndexCreator.java
@@ -25,6 +25,7 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -37,6 +38,7 @@
import org.apache.pinot.common.utils.FileUtils;
import org.apache.pinot.segment.local.io.util.PinotDataBitSet;
import org.apache.pinot.segment.local.segment.creator.impl.nullvalue.NullValueVectorCreator;
+import org.apache.pinot.segment.local.segment.creator.impl.urn.UrnPolymorphicDictionaryCreator;
import org.apache.pinot.segment.local.segment.index.dictionary.DictionaryIndexPlugin;
import org.apache.pinot.segment.local.segment.index.dictionary.DictionaryIndexType;
import org.apache.pinot.segment.local.segment.index.forward.ForwardIndexType;
@@ -64,12 +66,14 @@
import org.apache.pinot.spi.data.ComplexFieldSpec;
import org.apache.pinot.spi.data.DateTimeFieldSpec;
import org.apache.pinot.spi.data.DateTimeFormatSpec;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.FieldSpec.FieldType;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.data.readers.GenericRow;
import org.apache.pinot.spi.env.CommonsConfigurationUtils;
+import org.apache.pinot.spi.utils.JsonUtils;
import org.apache.pinot.spi.utils.TimeUtils;
import org.joda.time.DateTimeZone;
import org.joda.time.Interval;
@@ -104,6 +108,8 @@
import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.SCHEMA_MAX_LENGTH_EXCEED_STRATEGY;
import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.TOTAL_DOCS;
import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.TOTAL_NUMBER_OF_ENTRIES;
+import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.URN_ENTITY_DICT_RANGES;
+import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.URN_TYPES;
import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Column.getKeyFor;
import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Segment.COMPLEX_COLUMNS;
import static org.apache.pinot.segment.spi.V1Constants.MetadataKeys.Segment.DATETIME_COLUMNS;
@@ -140,6 +146,17 @@ public class SegmentColumnarIndexCreator implements SegmentCreator {
*/
private Map, IndexCreator>> _creatorsByColAndIndex = new HashMap<>();
private final Map _nullValueVectorCreatorMap = new HashMap<>();
+ /**
+ * For untyped URN columns, records the entity types and per-entity value types discovered from
+ * the data at build time. Each entity gets {@code LONG} when every value for that entity parses
+ * as a long; otherwise {@code STRING}. The discovered list surfaces through
+ * {@link #resolveFieldSpec} so the column is treated as polymorphic for the rest of the build
+ * and load paths.
+ */
+ private final Map> _discoveredMultiEntityUrnTypes = new HashMap<>();
+ /** Cache for synthetic FieldSpecs produced by {@link #resolveFieldSpec} so per-build mutations
+ * (like {@link DimensionFieldSpec#setEntityDictRanges}) persist across resolve calls. */
+ private final Map _syntheticFieldSpecCache = new HashMap<>();
private String _segmentName;
private Schema _schema;
private File _indexDir;
@@ -230,17 +247,40 @@ public void init(SegmentGeneratorConfig segmentCreationSpec, SegmentIndexCreatio
dictConfig = new DictionaryIndexConfig(dictConfig, columnIndexCreationInfo.isUseVarLengthDictionary());
}
- SegmentDictionaryCreator creator =
- new DictionaryIndexPlugin().getIndexType().createIndexCreator(context, dictConfig);
+ // URN column preprocessing: detect single-prefix or multi-prefix patterns and choose the
+ // right dict creator. The standard path handles everything else.
+ Object dictValues = maybeStripUrnPrefix(columnName, fieldSpec, context.getSortedUniqueElementsArray());
+ SegmentDictionaryCreator creator;
+ boolean usesPolymorphicUrnDict = dictValues instanceof org.apache.pinot.segment.local.segment.creator.impl.urn
+ .EntityKeyPair[]
+ || dictValues instanceof org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair[]
+ || (fieldSpec instanceof DimensionFieldSpec
+ && ((DimensionFieldSpec) fieldSpec).usesPolymorphicUrnDictionary());
+ if (usesPolymorphicUrnDict) {
+ List urnTypesForCreator =
+ _discoveredMultiEntityUrnTypes.containsKey(columnName)
+ ? _discoveredMultiEntityUrnTypes.get(columnName)
+ : ((DimensionFieldSpec) fieldSpec).getUrnTypes();
+ creator = new UrnPolymorphicDictionaryCreator(fieldSpec, _indexDir, urnTypesForCreator);
+ } else {
+ creator = new DictionaryIndexPlugin().getIndexType().createIndexCreator(context, dictConfig);
+ }
try {
- creator.build(context.getSortedUniqueElementsArray());
+ creator.build(dictValues);
} catch (Exception e) {
LOGGER.error("Error building dictionary for field: {}, cardinality: {}, number of bytes per entry: {}",
context.getFieldSpec().getName(), context.getCardinality(), creator.getNumBytesPerEntry());
throw e;
}
+ // For multi-entity URN columns, surface the computed per-entity dict ID ranges so the
+ // metadata write can persist them under urn.entityDictRanges.
+ if (creator instanceof UrnPolymorphicDictionaryCreator) {
+ DimensionFieldSpec dim = (DimensionFieldSpec) resolveFieldSpec(columnName);
+ dim.setEntityDictRanges(((UrnPolymorphicDictionaryCreator) creator).getRanges());
+ }
+
_dictionaryCreatorMap.put(columnName, creator);
}
@@ -283,6 +323,184 @@ private boolean isNullable(FieldSpec fieldSpec) {
return _schema.isEnableColumnBasedNullHandling() ? fieldSpec.isNullable() : _config.isDefaultNullHandlingEnabled();
}
+ private FieldSpec resolveFieldSpec(String columnName) {
+ FieldSpec fs = _schema.getFieldSpecFor(columnName);
+ if (fs == null) {
+ return null;
+ }
+ if (fs instanceof DimensionFieldSpec && fs.getDataType() == DataType.URN
+ && !((DimensionFieldSpec) fs).hasUrnTypes()) {
+ DimensionFieldSpec cached = _syntheticFieldSpecCache.get(columnName);
+ if (cached != null) {
+ return cached;
+ }
+ DimensionFieldSpec original = (DimensionFieldSpec) fs;
+ // For untyped URN columns where multiple prefixes were discovered with numeric keys,
+ // surface synthetic multi-entity urnTypes so the column behaves like declared multi-entity
+ // typed when the segment is loaded.
+ List discovered = _discoveredMultiEntityUrnTypes.get(columnName);
+ if (discovered != null) {
+ DimensionFieldSpec synthetic = new DimensionFieldSpec(original.getName(), DataType.URN,
+ original.isSingleValueField(), original.getMaxLength(), original.getDefaultNullValue(),
+ original.getMaxLengthExceedStrategy());
+ synthetic.setUrnTypes(discovered);
+ _syntheticFieldSpecCache.put(columnName, synthetic);
+ return synthetic;
+ }
+ }
+ return fs;
+ }
+
+ /**
+ * Preprocesses URN-column dictionary values. Returns either:
+ *
+ *
{@code EntityKeyPair[]} or {@code EntityValuePair[]} when the column is declared
+ * polymorphic (the stats collector already produced these) — pass-through to {@link
+ * org.apache.pinot.segment.local.segment.creator.impl.urn.UrnPolymorphicDictionaryCreator}.
+ *
{@code EntityValuePair[]} from auto-detection when the column is untyped but every
+ * value parses as a simple URN — feeds the polymorphic creator, with per-entity
+ * {@code valueType} chosen from the data (LONG when all keys for an entity parse as
+ * long, otherwise STRING).
+ *
{@code values} unchanged for non-URN columns and untyped URN columns whose data isn't
+ * URN-shaped.
+ *
+ * Side effect: populates {@link #_discoveredMultiEntityUrnTypes} so {@link #resolveFieldSpec}
+ * surfaces a synthetic polymorphic FieldSpec at metadata-write time.
+ */
+ private Object maybeStripUrnPrefix(String columnName, FieldSpec fieldSpec, Object values) {
+ if (!(fieldSpec instanceof DimensionFieldSpec) || fieldSpec.getDataType() != DataType.URN) {
+ return values;
+ }
+ DimensionFieldSpec dim = (DimensionFieldSpec) fieldSpec;
+ // Declared polymorphic URN (single- or multi-entity): the stats collector already produced
+ // EntityKeyPair[] (all-numeric) or EntityValuePair[] (mixed-type). Pass straight through.
+ if (dim.usesPolymorphicUrnDictionary()) {
+ return values;
+ }
+ if (dim.hasUrnTypes() || !(values instanceof Object[])) {
+ return values;
+ }
+ Object[] arr = (Object[]) values;
+ if (arr.length == 0) {
+ return values;
+ }
+ // Untyped URN column: discover entities + per-entity value types from the data and build a
+ // polymorphic EntityValuePair[] for the polymorphic dictionary creator. Per entity: LONG
+ // when every value's key parses as a long, otherwise STRING.
+ return tryUrnAutoDetection(columnName, arr);
+ }
+
+ /**
+ * Builds a sorted {@link org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair}{@code []}
+ * for an untyped URN column by parsing every value as a URN, grouping by entity prefix, and
+ * choosing a per-entity sub-dictionary type (LONG when all keys for that entity parse as long,
+ * otherwise STRING). Records the discovered urnTypes so {@link #resolveFieldSpec} surfaces a
+ * synthetic FieldSpec at metadata-write time. Returns the original {@code values} when any
+ * value isn't a simple URN — the column then falls back to plain STRING storage.
+ */
+ private Object tryUrnAutoDetection(String columnName, Object[] values) {
+ java.util.LinkedHashMap entityToIdx = new java.util.LinkedHashMap<>();
+ boolean[] perEntityAllLong = new boolean[8]; // grows as needed
+ Object[] parsedSuffixes = new Object[values.length]; // String suffix per row, null for skipped
+ int[] perRowEntityIdx = new int[values.length];
+ java.util.Arrays.fill(perRowEntityIdx, -1);
+
+ String nullSentinel = FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_URN;
+ int skipped = 0;
+ for (int rowIdx = 0; rowIdx < values.length; rowIdx++) {
+ String s = values[rowIdx].toString();
+ // Filter the URN null sentinel before entity discovery — NullValueTransformer fills null
+ // URN rows with this value, and we don't want it to be promoted to a real entity.
+ if (nullSentinel.equals(s)) {
+ skipped++;
+ continue;
+ }
+ org.apache.pinot.spi.utils.Urn urn = org.apache.pinot.spi.utils.Urn.tryParse(s);
+ if (urn == null || !urn.isSimple()) {
+ return values;
+ }
+ String entityType = urn.entityPrefix();
+ Integer existing = entityToIdx.get(entityType);
+ int idx;
+ if (existing != null) {
+ idx = existing;
+ } else {
+ idx = entityToIdx.size();
+ entityToIdx.put(entityType, idx);
+ if (idx >= perEntityAllLong.length) {
+ perEntityAllLong = java.util.Arrays.copyOf(perEntityAllLong, perEntityAllLong.length * 2);
+ }
+ perEntityAllLong[idx] = true;
+ }
+ perRowEntityIdx[rowIdx] = idx;
+ parsedSuffixes[rowIdx] = urn.simpleKey();
+ if (perEntityAllLong[idx]) {
+ try {
+ Long.parseLong(urn.simpleKey());
+ } catch (NumberFormatException e) {
+ perEntityAllLong[idx] = false;
+ }
+ }
+ }
+
+ int numEntities = entityToIdx.size();
+ if (numEntities == 0) {
+ // Every value was the URN null sentinel; nothing to auto-detect.
+ return values;
+ }
+ List urnTypes = new ArrayList<>(numEntities);
+ DataType[] perEntityType = new DataType[numEntities];
+ int e = 0;
+ for (String entityType : entityToIdx.keySet()) {
+ DataType vt = perEntityAllLong[e] ? DataType.LONG : DataType.STRING;
+ perEntityType[e] = vt;
+ urnTypes.add(DimensionFieldSpec.UrnType.of(entityType, vt));
+ e++;
+ }
+ _discoveredMultiEntityUrnTypes.put(columnName, urnTypes);
+
+ // Skipped null-sentinel rows still need a dict-ID slot in the forward index. Reserve one
+ // entry in entity 0's sub-dictionary with a type-appropriate placeholder so the per-row
+ // indexer can resolve them; NullValueVector marks the actual null status so the placeholder
+ // value is never user-visible. nullPlaceholder() picks Long.MIN_VALUE for LONG entity 0,
+ // Integer.MIN_VALUE for INT, the URN sentinel string itself for STRING.
+ Comparable> nullPlaceholder = skipped > 0 ? nullPlaceholderFor(perEntityType[0], nullSentinel) : null;
+
+ int pairCount = skipped > 0 ? values.length - skipped + 1 : values.length;
+ org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair[] pairs =
+ new org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair[pairCount];
+ int pi = 0;
+ for (int rowIdx = 0; rowIdx < values.length; rowIdx++) {
+ int idx = perRowEntityIdx[rowIdx];
+ if (idx < 0) {
+ continue; // skipped null sentinel
+ }
+ String suffix = (String) parsedSuffixes[rowIdx];
+ Comparable> typedValue = perEntityType[idx] == DataType.LONG ? Long.valueOf(suffix) : suffix;
+ pairs[pi++] = new org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair(idx, typedValue);
+ }
+ if (nullPlaceholder != null) {
+ pairs[pi] = new org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair(0, nullPlaceholder);
+ }
+ java.util.Arrays.sort(pairs);
+ return pairs;
+ }
+
+ private static Comparable> nullPlaceholderFor(DataType entityZeroType, String nullSentinel) {
+ switch (entityZeroType) {
+ case LONG:
+ return Long.MIN_VALUE;
+ case INT:
+ return Integer.MIN_VALUE;
+ default:
+ return nullSentinel;
+ }
+ }
+
+ /**
+ * Strips the discovered URN entity prefix from a row's value at index time so the dictionary
+ * lookup hits the suffix-only entry.
+ */
private FieldIndexConfigs adaptConfig(String columnName, FieldIndexConfigs config,
ColumnIndexCreationInfo columnIndexCreationInfo, SegmentGeneratorConfig segmentCreationSpec) {
FieldIndexConfigs.Builder builder = new FieldIndexConfigs.Builder(config);
@@ -366,11 +584,73 @@ public void indexRow(GenericRow row)
if (columnValueToIndex == null) {
throw new RuntimeException("Null value for column:" + columnName);
}
-
Map, IndexCreator> creatorsByIndex = byColEntry.getValue();
- FieldSpec fieldSpec = _schema.getFieldSpecFor(columnName);
+ FieldSpec fieldSpec = resolveFieldSpec(columnName);
SegmentDictionaryCreator dictionaryCreator = _dictionaryCreatorMap.get(columnName);
+ // For multi-entity URN columns, pair the row's main column LONG with the entity index the
+ // URN encoding transformer stashed in a transient field so the UrnPolymorphicDictionaryCreator
+ // can map (entityIdx, longKey) → dict ID. The transient field is dropped after build.
+ //
+ // For *auto-detected* multi-entity URN columns (untyped schema, multi-prefix discovered),
+ // the URN encoding transformer didn't run -- the row's value is still a URN string. We
+ // parse it inline here.
+ if (dictionaryCreator instanceof UrnPolymorphicDictionaryCreator) {
+ DimensionFieldSpec dim = (DimensionFieldSpec) fieldSpec;
+ Object entityIdxVal = row.getValue(dim.transientUrnEntityIdxFieldName());
+ if (entityIdxVal != null) {
+ // Declared multi-entity path: UrnKeyEncodingTransformer already stashed entityIdx and
+ // converted the column value to the per-entity-typed key (Long for LONG, Integer for
+ // INT, String for STRING). Wrap it as an EntityValuePair so the polymorphic creator
+ // can look it up against its per-entity sub-dictionary.
+ int entityIdx = ((Number) entityIdxVal).intValue();
+ columnValueToIndex = new org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair(
+ entityIdx, (Comparable>) columnValueToIndex);
+ } else if (!(columnValueToIndex instanceof String) && columnValueToIndex instanceof Comparable) {
+ // Null URN path: the row's URN was null, UrnKeyEncodingTransformer cleared the
+ // transient entity index, and NullValueTransformer filled the column with the default
+ // null sentinel. Stats collector associated the sentinel with entity 0; do the same
+ // here so indexOfSV finds it.
+ columnValueToIndex = new org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair(
+ 0, (Comparable>) columnValueToIndex);
+ } else if (columnValueToIndex instanceof String) {
+ // Auto-detected polymorphic path: UrnKeyEncodingTransformer didn't run (column was
+ // untyped at schema time) — parse the URN here against the discovered urnTypes and
+ // emit an EntityValuePair carrying the entity's declared typed key (Long for LONG,
+ // String for STRING). Null URN rows arrive here as the URN sentinel string; map them
+ // to entity 0's null-placeholder reserved by tryUrnAutoDetection.
+ String s = (String) columnValueToIndex;
+ if (FieldSpec.DEFAULT_DIMENSION_NULL_VALUE_OF_URN.equals(s)) {
+ List urnTypes = dim.getUrnTypes();
+ if (urnTypes != null && !urnTypes.isEmpty()) {
+ DataType entityZeroType = urnTypes.get(0).getValueType();
+ Comparable> placeholder = nullPlaceholderFor(entityZeroType, s);
+ columnValueToIndex = new org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair(
+ 0, placeholder);
+ }
+ } else {
+ org.apache.pinot.spi.utils.Urn urn = org.apache.pinot.spi.utils.Urn.tryParse(s);
+ if (urn != null && urn.isSimple()) {
+ List urnTypes = dim.getUrnTypes();
+ int entityIdx = -1;
+ String entityType = urn.entityPrefix();
+ for (int i = 0; i < urnTypes.size(); i++) {
+ if (urnTypes.get(i).getEntityType().equals(entityType)) {
+ entityIdx = i;
+ break;
+ }
+ }
+ if (entityIdx >= 0) {
+ DataType valueType = urnTypes.get(entityIdx).getValueType();
+ Comparable> typedKey =
+ valueType == DataType.LONG ? (Comparable>) Long.valueOf(urn.simpleKey()) : urn.simpleKey();
+ columnValueToIndex = new org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair(
+ entityIdx, typedKey);
+ }
+ }
+ }
+ }
+ }
try {
if (fieldSpec.isSingleValueField()) {
indexSingleValueRow(dictionaryCreator, columnValueToIndex, creatorsByIndex);
@@ -404,7 +684,7 @@ public void indexColumn(String columnName, @Nullable int[] sortedDocIds, IndexSe
try (PinotSegmentColumnReader colReader = new PinotSegmentColumnReader(segment, columnName)) {
Map, IndexCreator> creatorsByIndex = _creatorsByColAndIndex.get(columnName);
NullValueVectorCreator nullVec = _nullValueVectorCreatorMap.get(columnName);
- FieldSpec fieldSpec = _schema.getFieldSpecFor(columnName);
+ FieldSpec fieldSpec = resolveFieldSpec(columnName);
SegmentDictionaryCreator dictionaryCreator = _dictionaryCreatorMap.get(columnName);
if (sortedDocIds != null) {
int onDiskDocId = 0;
@@ -604,7 +884,7 @@ private void writeMetadata()
ColumnIndexCreationInfo columnIndexCreationInfo = entry.getValue();
SegmentDictionaryCreator dictionaryCreator = _dictionaryCreatorMap.get(column);
int dictionaryElementSize = (dictionaryCreator != null) ? dictionaryCreator.getNumBytesPerEntry() : 0;
- addColumnMetadataInfo(properties, column, columnIndexCreationInfo, _totalDocs, _schema.getFieldSpecFor(column),
+ addColumnMetadataInfo(properties, column, columnIndexCreationInfo, _totalDocs, resolveFieldSpec(column),
dictionaryCreator != null, dictionaryElementSize);
}
@@ -666,6 +946,26 @@ public static void addColumnMetadataInfo(PropertiesConfiguration properties, Str
properties.setProperty(getKeyFor(column, DATETIME_GRANULARITY), dateTimeFieldSpec.getGranularity());
}
+ // URN field: persist declared entity types so they survive a segment reload
+ if (dataType == DataType.URN && fieldSpec instanceof DimensionFieldSpec) {
+ DimensionFieldSpec dimSpec = (DimensionFieldSpec) fieldSpec;
+ if (dimSpec.hasUrnTypes()) {
+ try {
+ properties.setProperty(getKeyFor(column, URN_TYPES), JsonUtils.objectToString(dimSpec.getUrnTypes()));
+ } catch (Exception e) {
+ LOGGER.warn("Failed to serialize urnTypes for column {}: {}", column, e.getMessage());
+ }
+ }
+ // Multi-entity sorted-LONG UrnDictionary layout: persist per-entity dict ID ranges.
+ List entityDictRanges = dimSpec.getEntityDictRanges();
+ if (entityDictRanges != null && !entityDictRanges.isEmpty()) {
+ String encoded = DimensionFieldSpec.serializeEntityDictRanges(entityDictRanges);
+ if (encoded != null) {
+ properties.setProperty(getKeyFor(column, URN_ENTITY_DICT_RANGES), encoded);
+ }
+ }
+ }
+
// complex field
if (fieldSpec.getFieldType() == FieldType.COMPLEX) {
ComplexFieldSpec complexFieldSpec = (ComplexFieldSpec) fieldSpec;
@@ -677,16 +977,21 @@ public static void addColumnMetadataInfo(PropertiesConfiguration properties, Str
}
// NOTE: Min/max could be null for real-time aggregate metrics.
- if ((fieldSpec.getFieldType() != FieldType.COMPLEX) && (totalDocs > 0)) {
+ // Skip min/max for multi-entity URN columns: cross-entity comparisons aren't semantically
+ // meaningful, and the stats values (collected as strings for auto-detected columns) won't
+ // round-trip through the LONG effective-stored-type parsing at load time.
+ boolean isMultiEntityUrn = fieldSpec instanceof DimensionFieldSpec
+ && ((DimensionFieldSpec) fieldSpec).usesPolymorphicUrnDictionary();
+ if ((fieldSpec.getFieldType() != FieldType.COMPLEX) && (totalDocs > 0) && !isMultiEntityUrn) {
Object min = columnIndexCreationInfo.getMin();
Object max = columnIndexCreationInfo.getMax();
if (min != null && max != null) {
- addColumnMinMaxValueInfo(properties, column, min, max, dataType.getStoredType());
+ addColumnMinMaxValueInfo(properties, column, min, max, fieldSpec.getEffectiveStoredType());
}
}
String defaultNullValue = columnIndexCreationInfo.getDefaultNullValue().toString();
- if (dataType.getStoredType() == DataType.STRING) {
+ if (fieldSpec.getEffectiveStoredType() == DataType.STRING) {
// NOTE: Do not limit length of default null value because we need exact value to determine whether the default
// null value changes
defaultNullValue = CommonsConfigurationUtils.replaceSpecialCharacterInPropertyValue(defaultNullValue);
@@ -734,6 +1039,26 @@ public static void addFieldSpec(PropertiesConfiguration properties, String colum
properties.setProperty(getKeyFor(column, DATETIME_GRANULARITY), dateTimeFieldSpec.getGranularity());
}
+ // URN field: persist declared entity types so they survive a segment reload
+ if (dataType == DataType.URN && fieldSpec instanceof DimensionFieldSpec) {
+ DimensionFieldSpec dimSpec = (DimensionFieldSpec) fieldSpec;
+ if (dimSpec.hasUrnTypes()) {
+ try {
+ properties.setProperty(getKeyFor(column, URN_TYPES), JsonUtils.objectToString(dimSpec.getUrnTypes()));
+ } catch (Exception e) {
+ LOGGER.warn("Failed to serialize urnTypes for column {}: {}", column, e.getMessage());
+ }
+ }
+ // Multi-entity sorted-LONG UrnDictionary layout: persist per-entity dict ID ranges.
+ List entityDictRanges = dimSpec.getEntityDictRanges();
+ if (entityDictRanges != null && !entityDictRanges.isEmpty()) {
+ String encoded = DimensionFieldSpec.serializeEntityDictRanges(entityDictRanges);
+ if (encoded != null) {
+ properties.setProperty(getKeyFor(column, URN_ENTITY_DICT_RANGES), encoded);
+ }
+ }
+ }
+
// complex field
if (fieldSpec.getFieldType() == FieldType.COMPLEX) {
ComplexFieldSpec complexFieldSpec = (ComplexFieldSpec) fieldSpec;
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentDictionaryCreator.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentDictionaryCreator.java
index 2e4db2a6e6..db7f7f065a 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentDictionaryCreator.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentDictionaryCreator.java
@@ -81,7 +81,7 @@ public SegmentDictionaryCreator(String columnName, DataType storedType, File ind
public SegmentDictionaryCreator(FieldSpec fieldSpec, File indexDir, boolean useVarLengthDictionary) {
_columnName = fieldSpec.getName();
- _storedType = fieldSpec.getDataType().getStoredType();
+ _storedType = fieldSpec.getEffectiveStoredType();
_dictionaryFile = new File(indexDir, _columnName + DictionaryIndexType.getFileExtension());
_useVarLengthDictionary = useVarLengthDictionary;
}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentIndexCreationDriverImpl.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentIndexCreationDriverImpl.java
index 612b696e27..4958812836 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentIndexCreationDriverImpl.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/SegmentIndexCreationDriverImpl.java
@@ -528,7 +528,7 @@ void collectStatsAndIndexCreationInfo()
}
String column = fieldSpec.getName();
- DataType storedType = fieldSpec.getDataType().getStoredType();
+ DataType storedType = fieldSpec.getEffectiveStoredType();
ColumnStatistics columnProfile = _segmentStats.getColumnProfileFor(column);
DictionaryIndexConfig dictionaryIndexConfig = indexConfigsMap.get(column).getConfig(StandardIndexes.dictionary());
boolean createDictionary = dictionaryIndexConfig.isDisabled();
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/stats/SegmentPreIndexStatsCollectorImpl.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/stats/SegmentPreIndexStatsCollectorImpl.java
index 45c75e0c49..acf3dfb09a 100644
--- a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/stats/SegmentPreIndexStatsCollectorImpl.java
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/stats/SegmentPreIndexStatsCollectorImpl.java
@@ -23,6 +23,7 @@
import org.apache.pinot.segment.spi.creator.ColumnStatistics;
import org.apache.pinot.segment.spi.creator.SegmentPreIndexStatsCollector;
import org.apache.pinot.segment.spi.creator.StatsCollectorConfig;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.data.readers.GenericRow;
@@ -48,7 +49,20 @@ public void init() {
Schema dataSchema = _statsCollectorConfig.getSchema();
for (FieldSpec fieldSpec : dataSchema.getAllFieldSpecs()) {
String column = fieldSpec.getName();
- switch (fieldSpec.getDataType().getStoredType()) {
+ // Polymorphic multi-entity URN columns route to the URN collector regardless of stored
+ // type — they track (entityIdx, value) pairs so the polymorphic dictionary creator can
+ // lay out per-entity dict ID ranges with the right sub-dict types. Covers both the
+ // all-numeric (effectiveStoredType=LONG) and mixed-type (effectiveStoredType=STRING)
+ // configurations.
+ if (fieldSpec instanceof DimensionFieldSpec
+ && ((DimensionFieldSpec) fieldSpec).usesPolymorphicUrnDictionary()) {
+ DimensionFieldSpec dim = (DimensionFieldSpec) fieldSpec;
+ _columnStatsCollectorMap.put(column, new UrnMultiEntityPreIndexStatsCollector(column,
+ dim.transientUrnEntityIdxFieldName(), _statsCollectorConfig));
+ continue;
+ }
+ // For typed URN columns the physical stored type may differ from the logical type
+ switch (fieldSpec.getEffectiveStoredType()) {
case INT:
_columnStatsCollectorMap.put(column, new IntColumnPreIndexStatsCollector(column, _statsCollectorConfig));
break;
@@ -98,13 +112,21 @@ public void collectRow(GenericRow row) {
final String columnName = columnNameAndValue.getKey();
final Object value = columnNameAndValue.getValue();
- if (_columnStatsCollectorMap.containsKey(columnName)) {
- try {
- _columnStatsCollectorMap.get(columnName).collect(value);
- } catch (Exception e) {
- LOGGER.error("Exception while collecting stats for column:{} in row:{}", columnName, row);
- throw e;
+ AbstractColumnStatisticsCollector collector = _columnStatsCollectorMap.get(columnName);
+ if (collector == null) {
+ continue;
+ }
+ try {
+ // Multi-entity URN collectors need the row's companion column value alongside the main
+ // column's LONG key to pair (entityIdx, longKey) for the sorted-LONG dictionary.
+ if (collector instanceof UrnMultiEntityPreIndexStatsCollector) {
+ ((UrnMultiEntityPreIndexStatsCollector) collector).collect(value, row);
+ } else {
+ collector.collect(value);
}
+ } catch (Exception e) {
+ LOGGER.error("Exception while collecting stats for column:{} in row:{}", columnName, row);
+ throw e;
}
}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/stats/UrnMultiEntityPreIndexStatsCollector.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/stats/UrnMultiEntityPreIndexStatsCollector.java
new file mode 100644
index 0000000000..9a4c1716ca
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/stats/UrnMultiEntityPreIndexStatsCollector.java
@@ -0,0 +1,149 @@
+/**
+ * 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.segment.local.segment.creator.impl.stats;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.pinot.segment.local.segment.creator.impl.urn.EntityValuePair;
+import org.apache.pinot.segment.spi.creator.StatsCollectorConfig;
+import org.apache.pinot.spi.data.readers.GenericRow;
+
+
+/**
+ * Stats collector for multi-entity URN columns using the polymorphic sub-dictionary layout.
+ *
+ *
Per row, the column carries the per-entity-typed key value ({@link Long} for LONG entities,
+ * {@link Integer} for INT, {@link String} for STRING — set by
+ * {@link org.apache.pinot.segment.local.recordtransformer.UrnKeyEncodingTransformer}) and the
+ * companion transient field carries the 0-based entity index. This collector dedupes by
+ * {@code (entityIdx, value)} so two URNs that share a key but belong to different entity types
+ * remain distinct dictionary entries.
+ *
+ *
At {@link #seal()} the unique pairs are sorted by {@code (entityIdx, value-natural-order)},
+ * which is the order
+ * {@link org.apache.pinot.segment.local.segment.creator.impl.urn.UrnPolymorphicDictionaryCreator}
+ * expects: entities in declared order so each entity's sub-dictionary occupies a contiguous
+ * dict-ID and byte range, and within each entity, values in natural order so the per-entity
+ * sub-dictionary (LongDictionary, StringDictionary, ...) can use its native ordered lookup.
+ */
+public class UrnMultiEntityPreIndexStatsCollector extends AbstractColumnStatisticsCollector {
+ private final String _transientEntityIdxFieldName;
+ private final Set _pairs = new HashSet<>();
+ private EntityValuePair[] _sortedPairs;
+ private int _totalNumberOfEntries;
+ private boolean _sealed = false;
+
+ public UrnMultiEntityPreIndexStatsCollector(String column, String transientEntityIdxFieldName,
+ StatsCollectorConfig statsCollectorConfig) {
+ super(column, statsCollectorConfig);
+ _transientEntityIdxFieldName = transientEntityIdxFieldName;
+ }
+
+ /**
+ * Collects a row's main-column typed key value paired with the entity index stashed by
+ * {@link org.apache.pinot.segment.local.recordtransformer.UrnKeyEncodingTransformer} in the
+ * transient row field.
+ */
+ public void collect(Object value, GenericRow row) {
+ assert !_sealed;
+ if (value == null) {
+ return;
+ }
+ Object entityIdxVal = row.getValue(_transientEntityIdxFieldName);
+ // A null entity index means the row's URN was null and NullValueTransformer filled the
+ // column with the default null sentinel. Associate that sentinel with entity 0 so it gets
+ // a dict ID -- the row is also tracked in the column's NullValueVector, but the forward
+ // index still needs a dict-ID slot per doc.
+ int entityIdx = entityIdxVal == null ? 0 : ((Number) entityIdxVal).intValue();
+ _pairs.add(new EntityValuePair(entityIdx, (Comparable>) value));
+ _totalNumberOfEntries++;
+ }
+
+ /**
+ * Default per-column collector path used by code that doesn't have row context (e.g. realtime
+ * to-immutable conversion). The polymorphic URN column is normally routed through
+ * {@link #collect(Object, GenericRow)} via {@code SegmentPreIndexStatsCollectorImpl.collectRow}
+ * — falling back here would lose the {@code (entityIdx, value)} pairing, so we no-op rather
+ * than silently corrupting the dictionary.
+ */
+ @Override
+ public void collect(Object entry) {
+ assert !_sealed;
+ }
+
+ /**
+ * Returns the sorted unique {@code (entityIdx, value)} pairs after {@link #seal()}. Consumed
+ * by {@link org.apache.pinot.segment.local.segment.creator.impl.urn.UrnPolymorphicDictionaryCreator}.
+ */
+ @Override
+ public Object getUniqueValuesSet() {
+ if (!_sealed) {
+ throw new IllegalStateException("collector must be sealed before unique values are requested");
+ }
+ return _sortedPairs;
+ }
+
+ @Override
+ public int getCardinality() {
+ return _sealed ? _sortedPairs.length : _pairs.size();
+ }
+
+ @Override
+ public int getLengthOfLargestElement() {
+ return 0;
+ }
+
+ @Override
+ public int getTotalNumberOfEntries() {
+ return _totalNumberOfEntries;
+ }
+
+ @Override
+ public Object getMinValue() {
+ return null;
+ }
+
+ @Override
+ public Object getMaxValue() {
+ return null;
+ }
+
+ @Override
+ public void seal() {
+ if (_sealed) {
+ return;
+ }
+ _sortedPairs = _pairs.toArray(new EntityValuePair[0]);
+ Arrays.sort(_sortedPairs);
+ _pairs.clear();
+ _sealed = true;
+ }
+
+ /**
+ * Polymorphic multi-entity URN columns are never considered sorted for forward-index purposes:
+ * the value space is partitioned by entity into typed regions, so consecutive rows may jump
+ * across entity ranges in dict-ID space even when per-entity values would themselves be
+ * monotone.
+ */
+ @Override
+ public boolean isSorted() {
+ return false;
+ }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/urn/EntityKeyPair.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/urn/EntityKeyPair.java
new file mode 100644
index 0000000000..3612f83393
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/urn/EntityKeyPair.java
@@ -0,0 +1,71 @@
+/**
+ * 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.segment.local.segment.creator.impl.urn;
+
+/**
+ * A single dictionary entry for a multi-entity URN column: the entity index (into the declared
+ * {@code urnTypes} list) paired with the numeric key. Naturally ordered by {@code (entityIdx,
+ * longKey)}, which is also the order the on-disk dictionary buffer is written in. This ordering
+ * lets {@code UrnSortedDictionary} use a range-restricted binary search at read time.
+ */
+public final class EntityKeyPair implements Comparable {
+ private final int _entityIdx;
+ private final long _longKey;
+
+ public EntityKeyPair(int entityIdx, long longKey) {
+ _entityIdx = entityIdx;
+ _longKey = longKey;
+ }
+
+ public int getEntityIdx() {
+ return _entityIdx;
+ }
+
+ public long getLongKey() {
+ return _longKey;
+ }
+
+ @Override
+ public int compareTo(EntityKeyPair other) {
+ int byEntity = Integer.compare(_entityIdx, other._entityIdx);
+ return byEntity != 0 ? byEntity : Long.compare(_longKey, other._longKey);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof EntityKeyPair)) {
+ return false;
+ }
+ EntityKeyPair other = (EntityKeyPair) o;
+ return _entityIdx == other._entityIdx && _longKey == other._longKey;
+ }
+
+ @Override
+ public int hashCode() {
+ return _entityIdx * 31 + Long.hashCode(_longKey);
+ }
+
+ @Override
+ public String toString() {
+ return "(" + _entityIdx + "," + _longKey + ")";
+ }
+}
diff --git a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/urn/EntityValuePair.java b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/urn/EntityValuePair.java
new file mode 100644
index 0000000000..443e8e9637
--- /dev/null
+++ b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/creator/impl/urn/EntityValuePair.java
@@ -0,0 +1,79 @@
+/**
+ * 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.segment.local.segment.creator.impl.urn;
+
+/**
+ * A single dictionary entry for a polymorphic multi-entity URN column: the entity index (into
+ * the declared {@code urnTypes} list) paired with the per-entity typed key value. Unlike
+ * {@link EntityKeyPair} (always a {@code long}), the value here may be {@link Long}, {@link
+ * Integer}, {@link String}, etc. — whichever type matches the entity's declared {@code
+ * valueType}.
+ *
+ *
Naturally ordered by {@code (entityIdx, value-in-natural-order)}, which is also the order
+ * the on-disk dictionary buffer is written in: entities appear in declared order, and within an
+ * entity, values are sorted by their type-appropriate {@link Comparable} ordering. This lets
+ * each per-entity sub-dictionary use its native lookup (binary search on LONG for numeric
+ * entities, byte-comparison on STRING for textual entities) without cross-entity confusion.
+ */
+public final class EntityValuePair implements Comparable {
+ private final int _entityIdx;
+ private final Comparable