Copilot commented on code in PR #7389:
URL: https://github.com/apache/paimon/pull/7389#discussion_r2909420254


##########
paimon-python/pypaimon/cli/where_parser.py:
##########
@@ -0,0 +1,357 @@
+#  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.
+
+"""
+SQL WHERE clause parser for Paimon CLI.
+
+Parses simple SQL-like WHERE expressions into Predicate objects.
+
+Supported operators:
+  =, !=, <>, <, <=, >, >=,
+  IS NULL, IS NOT NULL,
+  IN (...), NOT IN (...),
+  BETWEEN ... AND ...,
+  LIKE '...'
+
+Supported connectors: AND, OR (AND has higher precedence than OR).
+Parenthesized grouping is supported.
+
+Examples:
+  "age > 18"
+  "name = 'Alice' AND age >= 20"
+  "status IN ('active', 'pending')"
+  "score BETWEEN 60 AND 100"
+  "name LIKE 'A%'"
+  "deleted_at IS NULL"
+  "age > 18 OR (name = 'Bob' AND status = 'active')"
+"""
+
+import re
+from typing import Any, Dict, List, Optional
+
+from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.schema.data_types import AtomicType, DataField
+
+
+def extract_fields_from_where(where_string: str, available_fields: set) -> set:
+    """Extract all field names referenced in a WHERE clause.
+
+    Args:
+        where_string: The WHERE clause string.
+        available_fields: Set of valid field names from the table schema.
+
+    Returns:
+        A set of field names referenced in the WHERE clause.
+    """
+    if not where_string or not where_string.strip():
+        return set()
+
+    tokens = _tokenize(where_string.strip())
+    referenced_fields = set()
+    for token in tokens:
+        if token in available_fields:
+            referenced_fields.add(token)
+    return referenced_fields
+
+
+def parse_where_clause(where_string: str, fields: List[DataField]) -> 
Optional[Predicate]:
+    """Parse a SQL-like WHERE clause string into a Predicate.
+
+    Args:
+        where_string: The WHERE clause string (without the 'WHERE' keyword).
+        fields: The table schema fields for type resolution.
+
+    Returns:
+        A Predicate object, or None if the string is empty.
+
+    Raises:
+        ValueError: If the WHERE clause cannot be parsed.
+    """
+    where_string = where_string.strip()
+    if not where_string:
+        return None
+
+    field_type_map = _build_field_type_map(fields)
+    predicate_builder = PredicateBuilder(fields)
+    tokens = _tokenize(where_string)
+    predicate, remaining = _parse_or_expression(tokens, predicate_builder, 
field_type_map)
+
+    if remaining:
+        raise ValueError(
+            f"Unexpected tokens after parsing: {' '.join(remaining)}"
+        )
+
+    return predicate
+
+
+def _build_field_type_map(fields: List[DataField]) -> Dict[str, str]:
+    """Build a mapping from field name to its base type string."""
+    result = {}
+    for field in fields:
+        if isinstance(field.type, AtomicType):
+            result[field.name] = field.type.type.upper()
+        else:
+            result[field.name] = str(field.type).upper()
+    return result
+
+
+def _cast_literal(value_str: str, type_name: str) -> Any:
+    """Cast a literal string to the appropriate Python type based on the field 
type."""
+    integer_types = {'TINYINT', 'SMALLINT', 'INT', 'INTEGER', 'BIGINT'}
+    float_types = {'FLOAT', 'DOUBLE'}
+
+    base_type = type_name.split('(')[0].strip()
+
+    if base_type in integer_types:
+        return int(value_str)
+    if base_type in float_types:
+        return float(value_str)
+    if base_type.startswith('DECIMAL') or base_type in ('DECIMAL', 'NUMERIC', 
'DEC'):
+        return float(value_str)
+    if base_type == 'BOOLEAN':
+        return value_str.lower() in ('true', '1', 'yes')
+    return value_str
+
+
+_TOKEN_PATTERN = re.compile(
+    r"""
+      '(?:[^'\\]|\\.)*'       # single-quoted string
+    | "(?:[^"\\]|\\.)*"        # double-quoted string
+    | <=                       # <=
+    | >=                       # >=
+    | <>                       # <>
+    | !=                       # !=
+    | [=<>]                    # single-char operators
+    | [(),]                    # punctuation
+    | [^\s,()=<>!'"]+          # unquoted word / number
+    """,
+    re.VERBOSE,
+)
+
+
+def _tokenize(expression: str) -> List[str]:
+    """Tokenize a WHERE clause string."""
+    return _TOKEN_PATTERN.findall(expression)
+
+
+def _parse_or_expression(
+    tokens: List[str],
+    builder: PredicateBuilder,
+    type_map: Dict[str, str],
+) -> (Predicate, List[str]):
+    """Parse an OR expression (lowest precedence)."""
+    left, tokens = _parse_and_expression(tokens, builder, type_map)
+    or_operands = [left]
+
+    while tokens and tokens[0].upper() == 'OR':
+        tokens = tokens[1:]  # consume 'OR'
+        right, tokens = _parse_and_expression(tokens, builder, type_map)
+        or_operands.append(right)
+
+    if len(or_operands) == 1:
+        return or_operands[0], tokens
+    return PredicateBuilder.or_predicates(or_operands), tokens
+
+
+def _parse_and_expression(
+    tokens: List[str],
+    builder: PredicateBuilder,
+    type_map: Dict[str, str],
+) -> (Predicate, List[str]):
+    """Parse an AND expression."""
+    left, tokens = _parse_primary(tokens, builder, type_map)
+    and_operands = [left]
+
+    while tokens and tokens[0].upper() == 'AND':
+        # Distinguish 'AND' as connector vs. 'AND' in 'BETWEEN ... AND ...'
+        # BETWEEN's AND is consumed inside _parse_primary, so here it's always 
a connector.
+        tokens = tokens[1:]  # consume 'AND'
+        right, tokens = _parse_primary(tokens, builder, type_map)
+        and_operands.append(right)
+
+    if len(and_operands) == 1:
+        return and_operands[0], tokens
+    return PredicateBuilder.and_predicates(and_operands), tokens
+
+
+def _parse_primary(
+    tokens: List[str],
+    builder: PredicateBuilder,
+    type_map: Dict[str, str],
+) -> (Predicate, List[str]):
+    """Parse a primary expression: a single condition or a parenthesized 
group."""
+    if not tokens:
+        raise ValueError("Unexpected end of WHERE clause")
+
+    # Parenthesized group
+    if tokens[0] == '(':
+        tokens = tokens[1:]  # consume '('
+        predicate, tokens = _parse_or_expression(tokens, builder, type_map)
+        if not tokens or tokens[0] != ')':
+            raise ValueError("Missing closing parenthesis ')'")
+        tokens = tokens[1:]  # consume ')'
+        return predicate, tokens
+
+    # Must be a condition starting with a field name
+    field_name = tokens[0]
+    tokens = tokens[1:]
+
+    if not tokens:
+        raise ValueError(f"Unexpected end after field name '{field_name}'")
+
+    field_type = type_map.get(field_name, 'STRING')
+    operator_token = tokens[0].upper()
+
+    # IS NULL / IS NOT NULL
+    if operator_token == 'IS':
+        tokens = tokens[1:]  # consume 'IS'
+        if not tokens:
+            raise ValueError(f"Unexpected end after 'IS' for field 
'{field_name}'")
+        next_token = tokens[0].upper()
+        if next_token == 'NULL':
+            tokens = tokens[1:]
+            return builder.is_null(field_name), tokens
+        elif next_token == 'NOT':
+            tokens = tokens[1:]  # consume 'NOT'
+            if not tokens or tokens[0].upper() != 'NULL':
+                raise ValueError(f"Expected 'NULL' after 'IS NOT' for field 
'{field_name}'")
+            tokens = tokens[1:]  # consume 'NULL'
+            return builder.is_not_null(field_name), tokens
+        else:
+            raise ValueError(f"Expected 'NULL' or 'NOT NULL' after 'IS' for 
field '{field_name}'")
+
+    # NOT IN
+    if operator_token == 'NOT':
+        tokens = tokens[1:]  # consume 'NOT'
+        if not tokens or tokens[0].upper() != 'IN':
+            raise ValueError(f"Expected 'IN' after 'NOT' for field 
'{field_name}'")
+        tokens = tokens[1:]  # consume 'IN'
+        values, tokens = _parse_in_list(tokens, field_type)
+        return builder.is_not_in(field_name, values), tokens
+
+    # IN (...)
+    if operator_token == 'IN':
+        tokens = tokens[1:]  # consume 'IN'
+        values, tokens = _parse_in_list(tokens, field_type)
+        return builder.is_in(field_name, values), tokens
+
+    # BETWEEN ... AND ...
+    if operator_token == 'BETWEEN':
+        tokens = tokens[1:]  # consume 'BETWEEN'
+        lower_str, tokens = _consume_literal(tokens)
+        lower_value = _cast_literal(lower_str, field_type)
+        if not tokens or tokens[0].upper() != 'AND':
+            raise ValueError(f"Expected 'AND' in BETWEEN expression for field 
'{field_name}'")
+        tokens = tokens[1:]  # consume 'AND'
+        upper_str, tokens = _consume_literal(tokens)
+        upper_value = _cast_literal(upper_str, field_type)
+        return builder.between(field_name, lower_value, upper_value), tokens
+
+    # NOT BETWEEN ... AND ...
+    if operator_token == 'NOT' and len(tokens) > 1 and tokens[1].upper() == 
'BETWEEN':
+        tokens = tokens[2:]  # consume 'NOT BETWEEN'
+        lower_str, tokens = _consume_literal(tokens)
+        lower_value = _cast_literal(lower_str, field_type)
+        if not tokens or tokens[0].upper() != 'AND':
+            raise ValueError(f"Expected 'AND' in NOT BETWEEN expression for 
field '{field_name}'")
+        tokens = tokens[1:]  # consume 'AND'
+        upper_str, tokens = _consume_literal(tokens)
+        upper_value = _cast_literal(upper_str, field_type)
+        return builder.not_between(field_name, lower_value, upper_value), 
tokens

Review Comment:
   The `NOT BETWEEN` handler (lines 265-275) is unreachable dead code. Since 
the `NOT IN` block (lines 239-245) handles every case where `operator_token == 
'NOT'` — and raises `ValueError("Expected 'IN' after 'NOT' ...")` if the next 
token is not `IN` — execution can never reach line 266.
   
   As a result, any `NOT BETWEEN` expression (e.g., `age NOT BETWEEN 20 AND 
30`) will produce the misleading error `"Expected 'IN' after 'NOT' for field 
'age'"` instead of being handled. The `NOT IN` block must be changed to inspect 
whether the following token is `IN` or `BETWEEN` and dispatch accordingly, 
before falling back to an error for unsupported combinations.



##########
paimon-python/pypaimon/tests/where_parser_test.py:
##########
@@ -0,0 +1,352 @@
+#  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.
+
+import unittest
+
+from pypaimon.cli.where_parser import parse_where_clause, _tokenize, 
_cast_literal
+from pypaimon.schema.data_types import AtomicType, DataField
+
+
+class WhereParserTokenizeTest(unittest.TestCase):
+    """Tests for the tokenizer."""
+
+    def test_simple_comparison(self):
+        tokens = _tokenize("age > 18")
+        self.assertEqual(tokens, ['age', '>', '18'])
+
+    def test_string_literal(self):
+        tokens = _tokenize("name = 'Alice'")
+        self.assertEqual(tokens, ['name', '=', "'Alice'"])
+
+    def test_double_quoted_string(self):
+        tokens = _tokenize('city = "Beijing"')
+        self.assertEqual(tokens, ['city', '=', '"Beijing"'])
+
+    def test_multi_char_operators(self):
+        tokens = _tokenize("age >= 18 AND age <= 30")
+        self.assertEqual(tokens, ['age', '>=', '18', 'AND', 'age', '<=', '30'])
+
+    def test_not_equal_operators(self):
+        tokens = _tokenize("status != 'active'")
+        self.assertEqual(tokens, ['status', '!=', "'active'"])
+
+        tokens = _tokenize("status <> 'active'")
+        self.assertEqual(tokens, ['status', '<>', "'active'"])
+
+    def test_in_list(self):
+        tokens = _tokenize("id IN (1, 2, 3)")
+        self.assertEqual(tokens, ['id', 'IN', '(', '1', ',', '2', ',', '3', 
')'])
+
+    def test_parenthesized_group(self):
+        tokens = _tokenize("(age > 18 OR name = 'Bob')")
+        self.assertEqual(tokens, ['(', 'age', '>', '18', 'OR', 'name', '=', 
"'Bob'", ')'])
+
+    def test_between(self):
+        tokens = _tokenize("score BETWEEN 60 AND 100")
+        self.assertEqual(tokens, ['score', 'BETWEEN', '60', 'AND', '100'])
+
+    def test_is_null(self):
+        tokens = _tokenize("deleted_at IS NULL")
+        self.assertEqual(tokens, ['deleted_at', 'IS', 'NULL'])
+
+    def test_is_not_null(self):
+        tokens = _tokenize("name IS NOT NULL")
+        self.assertEqual(tokens, ['name', 'IS', 'NOT', 'NULL'])
+
+
+class WhereParserCastLiteralTest(unittest.TestCase):
+    """Tests for literal type casting."""
+
+    def test_cast_int(self):
+        self.assertEqual(_cast_literal('42', 'INT'), 42)
+        self.assertEqual(_cast_literal('42', 'INTEGER'), 42)
+        self.assertEqual(_cast_literal('42', 'BIGINT'), 42)
+        self.assertEqual(_cast_literal('42', 'TINYINT'), 42)
+        self.assertEqual(_cast_literal('42', 'SMALLINT'), 42)
+
+    def test_cast_float(self):
+        self.assertAlmostEqual(_cast_literal('3.14', 'FLOAT'), 3.14)
+        self.assertAlmostEqual(_cast_literal('3.14', 'DOUBLE'), 3.14)
+
+    def test_cast_decimal(self):
+        self.assertAlmostEqual(_cast_literal('99.99', 'DECIMAL(10,2)'), 99.99)
+
+    def test_cast_boolean(self):
+        self.assertTrue(_cast_literal('true', 'BOOLEAN'))
+        self.assertTrue(_cast_literal('True', 'BOOLEAN'))
+        self.assertTrue(_cast_literal('1', 'BOOLEAN'))
+        self.assertFalse(_cast_literal('false', 'BOOLEAN'))
+        self.assertFalse(_cast_literal('0', 'BOOLEAN'))
+
+    def test_cast_string(self):
+        self.assertEqual(_cast_literal('hello', 'STRING'), 'hello')
+        self.assertEqual(_cast_literal('hello', 'VARCHAR(100)'), 'hello')
+
+
+class WhereParserParseTest(unittest.TestCase):
+    """Tests for the full WHERE clause parser."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.fields = [
+            DataField(0, 'id', AtomicType('INT')),
+            DataField(1, 'name', AtomicType('STRING')),
+            DataField(2, 'age', AtomicType('INT')),
+            DataField(3, 'score', AtomicType('DOUBLE')),
+            DataField(4, 'city', AtomicType('STRING')),
+            DataField(5, 'active', AtomicType('BOOLEAN')),
+        ]
+
+    def test_empty_string(self):
+        result = parse_where_clause('', self.fields)
+        self.assertIsNone(result)
+
+    def test_whitespace_only(self):
+        result = parse_where_clause('   ', self.fields)
+        self.assertIsNone(result)
+
+    def test_equal_int(self):
+        predicate = parse_where_clause("id = 1", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'equal')
+        self.assertEqual(predicate.field, 'id')
+        self.assertEqual(predicate.literals, [1])
+
+    def test_equal_string(self):
+        predicate = parse_where_clause("name = 'Alice'", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'equal')
+        self.assertEqual(predicate.field, 'name')
+        self.assertEqual(predicate.literals, ['Alice'])
+
+    def test_not_equal(self):
+        predicate = parse_where_clause("id != 5", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'notEqual')
+        self.assertEqual(predicate.field, 'id')
+        self.assertEqual(predicate.literals, [5])
+
+    def test_not_equal_diamond(self):
+        predicate = parse_where_clause("id <> 5", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'notEqual')
+        self.assertEqual(predicate.literals, [5])
+
+    def test_less_than(self):
+        predicate = parse_where_clause("age < 30", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'lessThan')
+        self.assertEqual(predicate.field, 'age')
+        self.assertEqual(predicate.literals, [30])
+
+    def test_less_or_equal(self):
+        predicate = parse_where_clause("age <= 30", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'lessOrEqual')
+        self.assertEqual(predicate.literals, [30])
+
+    def test_greater_than(self):
+        predicate = parse_where_clause("age > 18", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'greaterThan')
+        self.assertEqual(predicate.field, 'age')
+        self.assertEqual(predicate.literals, [18])
+
+    def test_greater_or_equal(self):
+        predicate = parse_where_clause("age >= 18", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'greaterOrEqual')
+        self.assertEqual(predicate.literals, [18])
+
+    def test_is_null(self):
+        predicate = parse_where_clause("city IS NULL", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'isNull')
+        self.assertEqual(predicate.field, 'city')
+
+    def test_is_not_null(self):
+        predicate = parse_where_clause("name IS NOT NULL", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'isNotNull')
+        self.assertEqual(predicate.field, 'name')
+
+    def test_in_int_list(self):
+        predicate = parse_where_clause("id IN (1, 2, 3)", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'in')
+        self.assertEqual(predicate.field, 'id')
+        self.assertEqual(predicate.literals, [1, 2, 3])
+
+    def test_in_string_list(self):
+        predicate = parse_where_clause("city IN ('Beijing', 'Shanghai')", 
self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'in')
+        self.assertEqual(predicate.field, 'city')
+        self.assertEqual(predicate.literals, ['Beijing', 'Shanghai'])
+
+    def test_not_in(self):
+        predicate = parse_where_clause("id NOT IN (4, 5)", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'notIn')
+        self.assertEqual(predicate.field, 'id')
+        self.assertEqual(predicate.literals, [4, 5])
+
+    def test_between(self):
+        predicate = parse_where_clause("age BETWEEN 20 AND 30", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'between')
+        self.assertEqual(predicate.field, 'age')
+        self.assertEqual(predicate.literals, [20, 30])
+
+    def test_between_float(self):
+        predicate = parse_where_clause("score BETWEEN 60.0 AND 100.0", 
self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'between')
+        self.assertEqual(predicate.field, 'score')
+        self.assertAlmostEqual(predicate.literals[0], 60.0)
+        self.assertAlmostEqual(predicate.literals[1], 100.0)
+
+    def test_like(self):
+        predicate = parse_where_clause("name LIKE 'A%'", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'like')
+        self.assertEqual(predicate.field, 'name')
+        self.assertEqual(predicate.literals, ['A%'])
+
+    def test_and_connector(self):
+        predicate = parse_where_clause("age > 18 AND name = 'Alice'", 
self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'and')
+        self.assertEqual(len(predicate.literals), 2)
+        self.assertEqual(predicate.literals[0].method, 'greaterThan')
+        self.assertEqual(predicate.literals[0].field, 'age')
+        self.assertEqual(predicate.literals[1].method, 'equal')
+        self.assertEqual(predicate.literals[1].field, 'name')
+
+    def test_or_connector(self):
+        predicate = parse_where_clause("age < 20 OR age > 30", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'or')
+        self.assertEqual(len(predicate.literals), 2)
+        self.assertEqual(predicate.literals[0].method, 'lessThan')
+        self.assertEqual(predicate.literals[1].method, 'greaterThan')
+
+    def test_and_has_higher_precedence_than_or(self):
+        # "a = 1 OR b = 2 AND c = 3" should be parsed as "a = 1 OR (b = 2 AND 
c = 3)"
+        predicate = parse_where_clause("id = 1 OR age = 25 AND name = 
'Alice'", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'or')
+        self.assertEqual(len(predicate.literals), 2)
+        self.assertEqual(predicate.literals[0].method, 'equal')
+        self.assertEqual(predicate.literals[0].field, 'id')
+        self.assertEqual(predicate.literals[1].method, 'and')
+        self.assertEqual(len(predicate.literals[1].literals), 2)
+
+    def test_parenthesized_group(self):
+        predicate = parse_where_clause("(id = 1 OR id = 2) AND age > 18", 
self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'and')
+        self.assertEqual(len(predicate.literals), 2)
+        self.assertEqual(predicate.literals[0].method, 'or')
+        self.assertEqual(predicate.literals[1].method, 'greaterThan')
+
+    def test_nested_parentheses(self):
+        predicate = parse_where_clause(
+            "(age > 18 AND (name = 'Alice' OR name = 'Bob'))", self.fields
+        )
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'and')
+        self.assertEqual(predicate.literals[0].method, 'greaterThan')
+        self.assertEqual(predicate.literals[1].method, 'or')
+
+    def test_multiple_and_conditions(self):
+        predicate = parse_where_clause(
+            "id > 0 AND age >= 20 AND name IS NOT NULL", self.fields
+        )
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'and')
+        self.assertEqual(len(predicate.literals), 3)
+
+    def test_complex_expression(self):
+        predicate = parse_where_clause(
+            "age > 18 OR (name = 'Bob' AND city IN ('Beijing', 'Shanghai'))",
+            self.fields
+        )
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'or')
+        self.assertEqual(predicate.literals[0].method, 'greaterThan')
+        and_part = predicate.literals[1]
+        self.assertEqual(and_part.method, 'and')
+        self.assertEqual(and_part.literals[0].method, 'equal')
+        self.assertEqual(and_part.literals[1].method, 'in')
+
+    def test_case_insensitive_keywords(self):
+        predicate = parse_where_clause("age between 20 and 30", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'between')
+
+        predicate = parse_where_clause("name is null", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'isNull')
+
+        predicate = parse_where_clause("name is not null", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'isNotNull')
+
+        predicate = parse_where_clause("id in (1, 2)", self.fields)
+        self.assertIsNotNone(predicate)
+        self.assertEqual(predicate.method, 'in')
+
+    def test_type_casting_int_field(self):
+        predicate = parse_where_clause("age = 25", self.fields)
+        self.assertIsInstance(predicate.literals[0], int)
+
+    def test_type_casting_double_field(self):
+        predicate = parse_where_clause("score > 3.14", self.fields)
+        self.assertIsInstance(predicate.literals[0], float)
+
+    def test_type_casting_string_field(self):
+        predicate = parse_where_clause("name = 'Alice'", self.fields)
+        self.assertIsInstance(predicate.literals[0], str)
+
+    def test_error_missing_closing_paren(self):
+        with self.assertRaises(ValueError):
+            parse_where_clause("(age > 18", self.fields)
+
+    def test_error_unexpected_end(self):
+        with self.assertRaises(ValueError):
+            parse_where_clause("age", self.fields)
+
+    def test_error_missing_in_paren(self):
+        with self.assertRaises(ValueError):
+            parse_where_clause("id IN 1, 2, 3", self.fields)
+
+    def test_error_between_missing_and(self):
+        with self.assertRaises(ValueError):
+            parse_where_clause("age BETWEEN 20 30", self.fields)
+
+    def test_error_unexpected_trailing_tokens(self):
+        with self.assertRaises(ValueError):
+            parse_where_clause("age > 18 extra_token", self.fields)
+
+    def test_error_is_without_null(self):
+        with self.assertRaises(ValueError):
+            parse_where_clause("age IS SOMETHING", self.fields)
+
+
+if __name__ == '__main__':
+    unittest.main()

Review Comment:
   There is no test case for `NOT BETWEEN`. Given that `NOT BETWEEN` is 
documented as supported in the module docstring and `not_between` is a method 
on `PredicateBuilder`, the functionality needs a test. However, as noted in a 
separate comment, the current implementation has a bug where `NOT BETWEEN` is 
actually unreachable. A test for this case should be added once the bug is 
fixed.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to