You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by fo...@apache.org on 2025/08/18 17:10:55 UTC

(iceberg-python) branch main updated: Add `between` operator (#2335)

This is an automated email from the ASF dual-hosted git repository.

fokko pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git


The following commit(s) were added to refs/heads/main by this push:
     new 8b43eb88 Add `between` operator (#2335)
8b43eb88 is described below

commit 8b43eb88fcc80b4980ce16b71852d211d7e93b13
Author: jtuglu1 <jt...@netflix.com>
AuthorDate: Mon Aug 18 10:10:49 2025 -0700

    Add `between` operator (#2335)
    
    <!--
    Thanks for opening a pull request!
    -->
    
    <!-- In the case this PR will resolve an issue, please replace
    ${GITHUB_ISSUE_ID} below with the actual Github issue id. -->
    <!-- Closes #${GITHUB_ISSUE_ID} -->
    
    # Rationale for this change
    
    Fixes https://github.com/apache/iceberg-python/issues/792
    
    # Are these changes tested?
    
    # Are there any user-facing changes?
    
    Add `BETWEEN` operator
    
    <!-- In the case of user-facing changes, please add the changelog label.
    -->
---
 mkdocs/docs/expression-dsl.md    |  2 ++
 mkdocs/docs/row-filter-syntax.md |  9 +++++++++
 pyiceberg/expressions/parser.py  | 10 +++++++++-
 tests/expressions/test_parser.py | 31 ++++++++++++++++++++++++++++++-
 4 files changed, 50 insertions(+), 2 deletions(-)

diff --git a/mkdocs/docs/expression-dsl.md b/mkdocs/docs/expression-dsl.md
index cf5784dd..420043bd 100644
--- a/mkdocs/docs/expression-dsl.md
+++ b/mkdocs/docs/expression-dsl.md
@@ -60,6 +60,8 @@ age_greater_than_18 = GreaterThan("age", 18)
 
 # Greater than or equal to
 age_greater_than_or_equal_18 = GreaterThanOrEqual("age", 18)
+
+
 ```
 
 #### Set Predicates
diff --git a/mkdocs/docs/row-filter-syntax.md b/mkdocs/docs/row-filter-syntax.md
index 2191b9fd..ce3b46c0 100644
--- a/mkdocs/docs/row-filter-syntax.md
+++ b/mkdocs/docs/row-filter-syntax.md
@@ -100,6 +100,15 @@ column NOT LIKE 'prefix%'
 !!! important
     The `%` wildcard is only supported at the end of the pattern. Using it in the middle or beginning of the pattern will raise an error.
 
+## BETWEEN
+
+The BETWEEN operator filters a numeric value against an inclusive range, e.g. `a between 1 and 2` is equivalent to `a >= 1 and a <= 2`.
+
+```sql
+column BETWEEN 1 AND 2
+column BETWEEN 1.0 AND 2.0
+```
+
 ## Logical Operations
 
 Combine multiple conditions using logical operators:
diff --git a/pyiceberg/expressions/parser.py b/pyiceberg/expressions/parser.py
index b9b6f9ab..19663638 100644
--- a/pyiceberg/expressions/parser.py
+++ b/pyiceberg/expressions/parser.py
@@ -79,6 +79,7 @@ IN = CaselessKeyword("in")
 NULL = CaselessKeyword("null")
 NAN = CaselessKeyword("nan")
 LIKE = CaselessKeyword("like")
+BETWEEN = CaselessKeyword("between")
 
 unquoted_identifier = Word(alphas + "_", alphanums + "_$")
 quoted_identifier = QuotedString('"', escChar="\\", unquoteResults=True)
@@ -106,6 +107,7 @@ boolean = one_of(["true", "false"], caseless=True).set_results_name("boolean")
 string = sgl_quoted_string.set_results_name("raw_quoted_string")
 decimal = common.real().set_results_name("decimal")
 integer = common.signed_integer().set_results_name("integer")
+number = common.number().set_results_name("number")
 literal = Group(string | decimal | integer | boolean).set_results_name("literal")
 literal_set = Group(
     DelimitedList(string) | DelimitedList(decimal) | DelimitedList(integer) | DelimitedList(boolean)
@@ -149,6 +151,12 @@ comparison_op = one_of(["<", "<=", ">", ">=", "=", "==", "!=", "<>"], caseless=T
 left_ref = column + comparison_op + literal
 right_ref = literal + comparison_op + column
 comparison = left_ref | right_ref
+between = column + BETWEEN + number + AND + number
+
+
+@between.set_parse_action
+def _(result: ParseResults) -> BooleanExpression:
+    return And(GreaterThanOrEqual(result.column, result[2]), LessThanOrEqual(result.column, result[4]))
 
 
 @left_ref.set_parse_action
@@ -258,7 +266,7 @@ def _evaluate_like_statement(result: ParseResults) -> BooleanExpression:
         return EqualTo(result.column, StringLiteral(literal_like.value.replace("\\%", "%")))
 
 
-predicate = (comparison | in_check | null_check | nan_check | starts_check | boolean).set_results_name("predicate")
+predicate = (between | comparison | in_check | null_check | nan_check | starts_check | boolean).set_results_name("predicate")
 
 
 def handle_not(result: ParseResults) -> Not:
diff --git a/tests/expressions/test_parser.py b/tests/expressions/test_parser.py
index 064fdb8f..1988c340 100644
--- a/tests/expressions/test_parser.py
+++ b/tests/expressions/test_parser.py
@@ -39,9 +39,10 @@ from pyiceberg.expressions import (
     NotNull,
     NotStartsWith,
     Or,
+    Reference,
     StartsWith,
 )
-from pyiceberg.expressions.literals import DecimalLiteral
+from pyiceberg.expressions.literals import DecimalLiteral, LongLiteral
 
 
 def test_always_true() -> None:
@@ -238,3 +239,31 @@ def test_quoted_column_with_dots() -> None:
 
 def test_quoted_column_with_spaces() -> None:
     assert EqualTo("Foo Bar", "data") == parser.parse("\"Foo Bar\" = 'data'")
+
+
+def test_valid_between() -> None:
+    assert And(
+        left=GreaterThanOrEqual(Reference(name="foo"), LongLiteral(1)),
+        right=LessThanOrEqual(Reference(name="foo"), LongLiteral(3)),
+    ) == parser.parse("foo between 1 and 3")
+    assert And(
+        left=GreaterThanOrEqual(Reference(name="foo"), LongLiteral(1)),
+        right=LessThanOrEqual(Reference(name="foo"), LongLiteral(1)),
+    ) == parser.parse("foo between 1 and 1")
+    assert And(
+        left=GreaterThanOrEqual(Reference(name="foo"), DecimalLiteral(Decimal(1.0))),
+        right=LessThanOrEqual(Reference(name="foo"), DecimalLiteral(Decimal(4.0))),
+    ) == parser.parse("foo between 1.0 and 4.0")
+    assert parser.parse("foo between 1 and 3") == parser.parse("1 <= foo and foo <= 3")
+
+
+def test_invalid_between() -> None:
+    # boolean
+    with pytest.raises(ParseException) as exc_info:
+        parser.parse("foo between true and false")
+    assert "Expected number, found 'true'" in str(exc_info)
+
+    # string
+    with pytest.raises(ParseException) as exc_info:
+        parser.parse("foo between 'a' and 'b'")
+    assert 'Expected number, found "\'"' in str(exc_info)