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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 4687799928 [python] Cast WHERE literals for DECIMAL, DATE and TIME 
columns (#10119)
4687799928 is described below

commit 4687799928fdad925ba98eac417e204e20642167
Author: jackylee <[email protected]>
AuthorDate: Thu Sep 24 17:39:32 2026 +0800

    [python] Cast WHERE literals for DECIMAL, DATE and TIME columns (#10119)
---
 paimon-python/pypaimon/common/where_parser.py      |  83 ++++++++++++-
 .../pypaimon/tests/py36/where_literal_cast_test.py |  63 ++++++++++
 paimon-python/pypaimon/tests/where_parser_test.py  | 130 ++++++++++++++++++++-
 3 files changed, 273 insertions(+), 3 deletions(-)

diff --git a/paimon-python/pypaimon/common/where_parser.py 
b/paimon-python/pypaimon/common/where_parser.py
index 3286c81a5c..1ba78986a5 100644
--- a/paimon-python/pypaimon/common/where_parser.py
+++ b/paimon-python/pypaimon/common/where_parser.py
@@ -40,6 +40,8 @@ Examples:
   "age > 18 OR (name = 'Bob' AND status = 'active')"
 """
 
+import datetime
+import decimal
 import re
 from typing import Any, Dict, List, Optional
 
@@ -114,10 +116,79 @@ def _build_field_type_map(fields: List[DataField]) -> 
Dict[str, Optional[str]]:
     return result
 
 
+def _cast_decimal(value_str: str, type_name: str) -> decimal.Decimal:
+    """Cast a DECIMAL literal, rescaled to the column's scale.
+
+    A DECIMAL column reads back as decimal.Decimal, and the pushed-down Arrow
+    filter binds the literal's own scale rather than the column's, so a literal
+    written at a different scale (e.g. ``50`` against a ``DECIMAL(10, 2)``
+    ``50.00``) would silently match nothing. Rescale to the column scale when 
the
+    value is exact there; otherwise keep it as written (it then correctly 
matches
+    no row). A malformed literal is re-raised as ValueError, which
+    parse_where_clause documents and the CLI relies on.
+    """
+    try:
+        value = decimal.Decimal(value_str)
+    except decimal.InvalidOperation:
+        raise ValueError(f"Invalid DECIMAL literal: {value_str!r}") from None
+
+    match = re.search(r'\(\s*(\d+)\s*,\s*(\d+)\s*\)', type_name)
+    precision = int(match.group(1)) if match else 38
+    scale = int(match.group(2)) if match else 0
+    # Quantize in a context wide enough for the column precision. The default
+    # context caps precision at 28, so a DECIMAL(38, 2) literal in integer form
+    # would raise InvalidOperation here, fall through unscaled, and then match
+    # nothing once the Arrow filter binds it at scale 0.
+    context = decimal.Context(prec=max(precision, 1))
+    try:
+        rescaled = value.quantize(decimal.Decimal(1).scaleb(-scale), 
context=context)
+    except decimal.InvalidOperation:
+        return value
+    return rescaled if rescaled == value else value
+
+
+def _cast_date(value_str: str) -> datetime.date:
+    """Parse a DATE literal as ``YYYY-MM-DD``.
+
+    ``datetime.date.fromisoformat`` only exists on Python 3.7+, while pypaimon
+    still declares ``python_requires >= 3.6`` and runs a 3.6 test lane, so 
parse
+    through ``strptime`` instead.
+    """
+    try:
+        return datetime.datetime.strptime(value_str.strip(), '%Y-%m-%d').date()
+    except ValueError:
+        raise ValueError(f"Invalid DATE literal: {value_str!r}") from None
+
+
+_TIME_PATTERN = re.compile(r'(\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,6}))?)?')
+
+
+def _cast_time(value_str: str) -> datetime.time:
+    """Parse a TIME literal as a wall-clock ``HH:MM[:SS[.ffffff]]``.
+
+    ``datetime.time.fromisoformat`` only exists on Python 3.7+, and it also
+    accepts a UTC offset (``12:30:00+01:00``) that a Paimon TIME -- which has 
no
+    time zone -- cannot represent; Arrow would then compare only the wall-clock
+    part and match a value the user did not ask for. Parse a plain time here 
and
+    reject any offset.
+    """
+    match = _TIME_PATTERN.fullmatch(value_str.strip())
+    if match is None:
+        raise ValueError(f"Invalid TIME literal: {value_str!r}")
+    hour, minute, second, fraction = match.groups()
+    microsecond = int(fraction.ljust(6, '0')) if fraction else 0
+    try:
+        return datetime.time(int(hour), int(minute),
+                             int(second) if second else 0, microsecond)
+    except ValueError:
+        raise ValueError(f"Invalid TIME literal: {value_str!r}") from None
+
+
 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'}
+    decimal_types = {'DECIMAL', 'NUMERIC', 'DEC'}
 
     base_type = type_name.split('(')[0].strip()
 
@@ -125,10 +196,18 @@ def _cast_literal(value_str: str, type_name: str) -> Any:
         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 in decimal_types:
+        return _cast_decimal(value_str, type_name)
     if base_type == 'BOOLEAN':
         return value_str.lower() in ('true', '1', 'yes')
+    if base_type == 'DATE':
+        # DATE/TIME columns read back as datetime.date / datetime.time; 
leaving the
+        # literal a string makes the arrow comparison kernel raise instead of
+        # filtering. TIMESTAMP is intentionally left out: its LOCAL TIME ZONE 
form
+        # reads back tz-aware and needs dedicated normalization.
+        return _cast_date(value_str)
+    if base_type == 'TIME':
+        return _cast_time(value_str)
     return value_str
 
 
diff --git a/paimon-python/pypaimon/tests/py36/where_literal_cast_test.py 
b/paimon-python/pypaimon/tests/py36/where_literal_cast_test.py
new file mode 100644
index 0000000000..98daef9bb3
--- /dev/null
+++ b/paimon-python/pypaimon/tests/py36/where_literal_cast_test.py
@@ -0,0 +1,63 @@
+# 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.
+
+"""WHERE literal casting on the Python 3.6/3.7 lane.
+
+The full ``where_parser_test`` is excluded from the 3.6/3.7 subset, yet DATE 
and
+TIME casting must stay off ``date.fromisoformat`` / ``time.fromisoformat`` 
(both
+3.7+). These pure-casting checks run on that lane to guard the floor.
+"""
+
+import datetime
+import decimal
+import unittest
+
+from pypaimon.common.where_parser import _cast_literal
+
+
+class WhereLiteralCastPy36Test(unittest.TestCase):
+
+    def test_cast_date(self):
+        self.assertEqual(_cast_literal('2024-01-01', 'DATE'),
+                         datetime.date(2024, 1, 1))
+
+    def test_cast_time(self):
+        self.assertEqual(_cast_literal('12:30:00', 'TIME(0)'),
+                         datetime.time(12, 30, 0))
+
+    def test_cast_time_with_fraction(self):
+        self.assertEqual(_cast_literal('12:30:00.5', 'TIME(3)'),
+                         datetime.time(12, 30, 0, 500000))
+
+    def test_cast_time_rejects_offset(self):
+        with self.assertRaises(ValueError):
+            _cast_literal('12:30:00+01:00', 'TIME(0)')
+
+    def test_cast_date_rejects_malformed(self):
+        with self.assertRaises(ValueError):
+            _cast_literal('not-a-date', 'DATE')
+
+    def test_cast_high_precision_decimal_rescales(self):
+        value = _cast_literal('123456789012345678901234567890123456',
+                              'DECIMAL(38, 2)')
+        self.assertEqual(value.as_tuple().exponent, -2)
+        self.assertEqual(
+            value, decimal.Decimal('123456789012345678901234567890123456.00'))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/where_parser_test.py 
b/paimon-python/pypaimon/tests/where_parser_test.py
index c5ba552ac7..4f38d71a91 100644
--- a/paimon-python/pypaimon/tests/where_parser_test.py
+++ b/paimon-python/pypaimon/tests/where_parser_test.py
@@ -15,8 +15,16 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import datetime
+import decimal
+import os
+import shutil
+import tempfile
 import unittest
 
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
 from pypaimon.common.where_parser import parse_where_clause, _tokenize, 
_cast_literal
 from pypaimon.schema.data_types import ArrayType, AtomicType, DataField
 
@@ -83,7 +91,43 @@ class WhereParserCastLiteralTest(unittest.TestCase):
         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)
+        # A DECIMAL literal must stay exact (decimal.Decimal); a float would 
never
+        # compare equal to the Decimal a DECIMAL column reads back as.
+        value = _cast_literal('99.99', 'DECIMAL(10,2)')
+        self.assertIsInstance(value, decimal.Decimal)
+        self.assertEqual(value, decimal.Decimal('99.99'))
+
+    def test_cast_decimal_high_precision_rescales(self):
+        # A DECIMAL(38, 2) literal in integer form exceeds the default decimal
+        # context (precision 28); without a column-wide context the rescale 
would
+        # be dropped and the literal would bind at scale 0.
+        value = _cast_literal('123456789012345678901234567890123456',
+                              'DECIMAL(38, 2)')
+        self.assertEqual(value.as_tuple().exponent, -2)
+        self.assertEqual(
+            value, decimal.Decimal('123456789012345678901234567890123456.00'))
+
+    def test_cast_date(self):
+        value = _cast_literal('2024-01-01', 'DATE')
+        self.assertEqual(value, datetime.date(2024, 1, 1))
+
+    def test_cast_time(self):
+        value = _cast_literal('12:30:00', 'TIME(0)')
+        self.assertEqual(value, datetime.time(12, 30, 0))
+
+    def test_cast_time_with_fraction(self):
+        value = _cast_literal('12:30:00.5', 'TIME(3)')
+        self.assertEqual(value, datetime.time(12, 30, 0, 500000))
+
+    def test_cast_time_rejects_offset(self):
+        # A Paimon TIME has no time zone; an offset-bearing literal must not
+        # silently drop the offset and match the wall-clock value.
+        with self.assertRaises(ValueError):
+            _cast_literal('12:30:00+01:00', 'TIME(0)')
+
+    def test_cast_time_rejects_malformed(self):
+        with self.assertRaises(ValueError):
+            _cast_literal('25:00:00', 'TIME(0)')
 
     def test_cast_boolean(self):
         self.assertTrue(_cast_literal('true', 'BOOLEAN'))
@@ -400,5 +444,89 @@ class WhereParserParseTest(unittest.TestCase):
         self.assertIn("tags", str(context.exception))
 
 
+class WhereParserScanTest(unittest.TestCase):
+    """End-to-end: a WHERE clause on DECIMAL/DATE/TIME columns returns the 
row."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        cls.catalog = CatalogFactory.create(
+            {'warehouse': os.path.join(cls.tempdir, 'warehouse')})
+        cls.catalog.create_database('default', False)
+        pa_schema = pa.schema([
+            ('id', pa.int32()),
+            ('price', pa.decimal128(10, 2)),
+            ('big', pa.decimal128(38, 2)),
+            ('d', pa.date32()),
+            ('t', pa.time32('ms')),
+        ])
+        cls.catalog.create_table(
+            'default.where_literal_types', 
Schema.from_pyarrow_schema(pa_schema), False)
+        cls.table = cls.catalog.get_table('default.where_literal_types')
+        data = pa.table({
+            'id': pa.array([1, 2], pa.int32()),
+            'price': pa.array(
+                [decimal.Decimal('99.99'), decimal.Decimal('50.00')], 
pa.decimal128(10, 2)),
+            'big': pa.array(
+                [decimal.Decimal('123456789012345678901234567890123456.00'),
+                 decimal.Decimal('1.00')], pa.decimal128(38, 2)),
+            'd': pa.array(
+                [datetime.date(2024, 1, 1), datetime.date(2020, 1, 1)], 
pa.date32()),
+            't': pa.array(
+                [datetime.time(12, 30, 0), datetime.time(1, 0, 0)], 
pa.time32('ms')),
+        })
+        wb = cls.table.new_batch_write_builder()
+        writer = wb.new_write()
+        commit = wb.new_commit()
+        writer.write_arrow(data)
+        commit.commit(writer.prepare_commit())
+        writer.close()
+        commit.close()
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _scan_ids(self, where):
+        predicate = parse_where_clause(where, self.table.table_schema.fields)
+        read_builder = self.table.new_read_builder().with_filter(predicate)
+        splits = read_builder.new_scan().plan().splits()
+        return 
read_builder.new_read().to_arrow(splits).column('id').to_pylist()
+
+    def test_decimal_equality_returns_matching_row(self):
+        # DECIMAL(10,2) reads back as Decimal; the old float literal dropped 
the row.
+        self.assertEqual(self._scan_ids("price = 99.99"), [1])
+
+    def test_decimal_literal_rescaled_to_column_scale(self):
+        # The pushed-down filter binds the literal's own scale, so an integer 
or
+        # trailing-zero literal must be rescaled to the column scale to match.
+        self.assertEqual(self._scan_ids("price = 50"), [2])
+        self.assertEqual(self._scan_ids("price = 99.990"), [1])
+
+    def test_decimal_literal_finer_than_column_matches_nothing(self):
+        # A literal too precise for the column (99.999 on DECIMAL(10,2)) 
cannot equal
+        # any stored value; it must return no rows rather than round into a 
match.
+        self.assertEqual(self._scan_ids("price = 99.999"), [])
+
+    def test_high_precision_decimal_integer_literal_matches(self):
+        # A DECIMAL(38, 2) integer-form literal exceeds the default decimal
+        # context; it must still rescale and match the stored scale-2 value.
+        self.assertEqual(
+            self._scan_ids("big = 123456789012345678901234567890123456"), [1])
+
+    def test_malformed_decimal_raises_value_error(self):
+        # parse_where_clause documents ValueError; a bad decimal must not leak
+        # decimal.InvalidOperation past the CLI's `except ValueError`.
+        with self.assertRaises(ValueError):
+            parse_where_clause("price = abc", self.table.table_schema.fields)
+
+    def test_date_equality_returns_matching_row(self):
+        # DATE literal left as str raised in the arrow comparison kernel.
+        self.assertEqual(self._scan_ids("d = '2024-01-01'"), [1])
+
+    def test_time_equality_returns_matching_row(self):
+        self.assertEqual(self._scan_ids("t = '12:30:00'"), [1])
+
+
 if __name__ == '__main__':
     unittest.main()

Reply via email to