Skip to content

Add support for unary +/- operators in SQL queries#18444

Open
jineshparakh wants to merge 2 commits into
apache:masterfrom
jineshparakh:unary-operator-plus-minus
Open

Add support for unary +/- operators in SQL queries#18444
jineshparakh wants to merge 2 commits into
apache:masterfrom
jineshparakh:unary-operator-plus-minus

Conversation

@jineshparakh
Copy link
Copy Markdown
Contributor

Description

Queries like SELECT -price FROM table or SELECT +col FROM table previously failed at execution time in both the V1 (single stage) and V2 (multi stage) engines. This was caused by two separate bugs working together.

Bug 1: Operator table collision

PinotOperatorTable stored operators in a flat Map<String, SqlOperator>, meaning only one operator could exist per canonical name. Binary minus (PINOT_MINUS) and unary minus (UNARY_MINUS) both canonicalize to "-", so only the binary variant survived registration.
When Calcite tried to validate -col, it could not find a prefix operator for "-", fell into handleUnresolvedFunction, retrieved PINOT_MINUS (a SqlMonotonicBinaryOperator), and threw a ClassCastException trying to cast it to SqlFunction.

Fix: Changed the backing store from Map<String, SqlOperator> to Map<String, List<SqlOperator>>:

The new register() method allows multiple operators with the same canonical name as long as they have different SqlSyntax values (PREFIX vs BINARY, for example). lookupOperatorOverloads() now returns all matching operators and lets Calcite disambiguate by syntax and operand count, which is exactly how SqlOperatorTable was designed to work. The published map is deeply immutable (List.copyOf on each value, then Map.copyOfon the outer map) so the singleton remains safe for concurrent reads.

A separate registerOverride() method handles the IS_NULL / IS_NOT_NULL replacement case, where Pinot intentionally force replaces a standard operator. This keeps the intent explicit and avoids conflating "add an overload" with "replace the existing operator."

Bug 2: Function name mismatch

Even after validation succeeds, the downstream code produces the function name "MINUS_PREFIX" (V2 via getFunctionName()) or "minusprefix" (V1 via canonicalizeFunctionNamePreservingSpecialKey(SqlKind.MINUS_PREFIX.name())). The existing NegateScalarFunction registers itself as "negate". Nothing bridged this gap, so the function lookup failed.

Fix: Added explicit MINUS_PREFIX cases in both engine paths:

In CalciteSqlParser.compileFunctionExpression() (V1): maps MINUS_PREFIX to NegateScalarFunction.FUNCTION_NAME and unwraps PLUS_PREFIX to return the operand directly since unary plus is the identity operation.

In RexExpressionUtils.fromRexCall() (V2): maps MINUS_PREFIX to NegateScalarFunction.FUNCTION_NAME. PLUS_PREFIX is intentionally not handled here because Calcite's StandardConvertletTable strips unary plus during the SqlNode to RexNode conversion phase, so it never reaches this switch. PinotConvertletTable delegates to StandardConvertletTable for any operator it does not override, and PLUS_PREFIX is not overridden.

A FUNCTION_NAME constant was added to NegateScalarFunction to eliminate the hardcoded "negate" string across the codebase.

Test coverage

V1 parser tests (CalciteSqlCompilerTest): 14 new test methods covering unary minus in SELECT, WHERE, ORDER BY, GROUP BY, HAVING, aggregations, CAST, CASE, DISTINCT, JOIN conditions, IN lists, aliases, binary/unary mixed expressions, and negative literals. Also verifies that unary plus is stripped to a bare identifier with no function wrapper.

V2 planner tests (QueryCompilationTest): around 50 data provider entries in provideUnaryOperatorQueries() covering all numeric column types (INT, LONG, BIG_DECIMAL), GROUP BY, aggregations, HAVING, aliases, CAST, JOIN, window PARTITION BY, DISTINCT, IN/BETWEEN, and subqueries.

V2 runtime tests (MathFuncs.json): 5 new test sections with roughly 30 queries verified against H2 reference results. Covers basic negation, unary plus identity, aggregations with unary minus, GROUP BY with unary minus, and miscellaneous cases like double negation, ORDER BY, and WHERE clauses.

Regression validation

Full test suite passed across all affected modules with 0 failures:

Module Tests
pinot-common 2652
pinot-query-planner 697
pinot-query-runtime 3307
pinot-core 311
pinot-broker 164
Total 7131

Wire compatibility

The function name "negate" is what gets sent over the wire. Older servers already have NegateScalarFunction registered in the FunctionRegistry, so queries from a new broker to an old server work without issues. Old broker to new server is also fine since those brokers never produced unary operator queries in the first place.

Signed-off-by: Jinesh Parakh <jineshparakh@hotmail.com>
@xiangfu0 xiangfu0 requested a review from Copilot May 8, 2026 07:34
@xiangfu0 xiangfu0 added sql-compliance Related to SQL standard compliance functions Related to scalar or aggregation functions labels May 8, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds end-to-end support for unary prefix - and + in Pinot SQL across both the V1 (single-stage) and V2 (multi-stage) engines by fixing Calcite operator registration/lookup and ensuring unary - maps to Pinot’s negate scalar function during planning/compilation.

Changes:

  • Fix Calcite operator-table name collisions by allowing multiple SqlOperators per canonical name (e.g., binary - and unary -) in PinotOperatorTable.
  • Map SqlKind.MINUS_PREFIX to NegateScalarFunction in both V1 (CalciteSqlParser) and V2 (RexExpressionUtils); unwrap unary + as identity in V1.
  • Add/extend planner, compiler, and runtime query test coverage for unary operators.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pinot-query-runtime/src/test/resources/queries/MathFuncs.json Adds runtime query-suite sections covering unary minus/plus behavior across types and query clauses.
pinot-query-planner/src/test/java/org/apache/pinot/query/QueryCompilationTest.java Adds planner test cases to ensure unary prefix operators plan successfully across many query shapes.
pinot-query-planner/src/main/java/org/apache/pinot/query/planner/logical/RexExpressionUtils.java Ensures V2 RexCall translation maps MINUS_PREFIX to negate.
pinot-query-planner/src/main/java/org/apache/pinot/calcite/sql/fun/PinotOperatorTable.java Allows multiple operators per canonical name and registers UNARY_MINUS/UNARY_PLUS.
pinot-common/src/test/java/org/apache/pinot/sql/parsers/CalciteSqlCompilerTest.java Adds V1 compilation assertions for unary minus/plus across SELECT/WHERE/GROUP BY/HAVING/etc.
pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java Maps MINUS_PREFIX to negate and unwraps PLUS_PREFIX during V1 compilation.
pinot-common/src/main/java/org/apache/pinot/common/function/scalar/arithmetic/NegateScalarFunction.java Introduces FUNCTION_NAME constant and reuses it in getName().

Comment thread pinot-query-runtime/src/test/resources/queries/MathFuncs.json Outdated
Signed-off-by: Jinesh Parakh <jineshparakh@hotmail.com>
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 8, 2026

Codecov Report

❌ Patch coverage is 92.30769% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.69%. Comparing base (642f07d) to head (e79e2ee).
⚠️ Report is 15 commits behind head on master.

Files with missing lines Patch % Lines
...ache/pinot/calcite/sql/fun/PinotOperatorTable.java 90.90% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #18444      +/-   ##
============================================
+ Coverage     63.55%   63.69%   +0.14%     
- Complexity     1717     1735      +18     
============================================
  Files          3252     3254       +2     
  Lines        199051   199472     +421     
  Branches      30838    30981     +143     
============================================
+ Hits         126503   127050     +547     
+ Misses        62471    62270     -201     
- Partials      10077    10152      +75     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-21 63.69% <92.30%> (+0.14%) ⬆️
temurin 63.69% <92.30%> (+0.14%) ⬆️
unittests 63.69% <92.30%> (+0.14%) ⬆️
unittests1 55.70% <92.30%> (+0.07%) ⬆️
unittests2 35.01% <69.23%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@Jackie-Jiang Jackie-Jiang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice

Copy link
Copy Markdown
Contributor

@yashmayya yashmayya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jineshparakh, this is a nice improvement!

Comment on lines +1106 to +1110
// -col should produce negate(col), not a binary subtraction
PinotQuery pinotQuery = compileToPinotQuery("SELECT -col1 FROM myTable");
Expression expr = pinotQuery.getSelectList().get(0);
Assert.assertEquals(expr.getFunctionCall().getOperator(), "negate");
Assert.assertEquals(expr.getFunctionCall().getOperands().get(0).getIdentifier().getName(), "col1");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe we can also assert that the operand list size is equal to 1?

}
]
},
"unary_minus": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a case for INT_MIN / LONG_MIN overflow?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also maybe some null input cases

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will add

@yashmayya
Copy link
Copy Markdown
Contributor

Re-reviewed and just two small follow-ups that elaborate on my earlier unresolved comments:

1. Null input case (elaborates on this comment) — In MathFuncs.json unary_minus, add a column with a null row and verify -nullCol propagates the null (with null handling enabled). This is the most common path users will actually hit and isn't covered by the current numeric-only inputs.

2. INT_MIN / LONG_MIN overflow (elaborates on this comment) — intNegate/longNegate use Math.negateExact, which throws ArithmeticException on Integer.MIN_VALUE / Long.MIN_VALUE. Worth a row in the test data with those values to either pin down the failure mode or document the behavior; this is the first time -col syntax actually reaches NegateScalarFunction.

Also — your SqlSyntax analysis is correct, addAll is the right call given Calcite's downstream filtering in SqlUtil.lookupSubjectRoutinesByName and the fact that PINOT_PLUS/PINOT_MINUS are registered as SqlBinaryOperator under add/sub function aliases. Resolving that thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Related to scalar or aggregation functions sql-compliance Related to SQL standard compliance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants