blob: ea3d94d9bb423dd2cc7ae47dcc6a68f8a9b2c6d8 [file]
################################################################################
# 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 datetime
import json
import unittest
from pathlib import Path
import pyarrow as pa
from pypaimon.common.predicate_json_parser import extract_referenced_fields
from pypaimon.common.arrow_predicate import (
_apply_predicate_transform,
_convert_literal,
_paimon_type_to_arrow,
parse_predicate_to_batch_filter,
)
def _make_leaf(field_name, function, literals=None, field_type="INT"):
d = {
"kind": "LEAF",
"transform": {
"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": field_name, "type": field_type},
},
"function": function,
}
if literals is not None:
d["literals"] = literals
return json.dumps(d)
class TestLeafFunctions(unittest.TestCase):
def setUp(self):
self.batch = pa.RecordBatch.from_pydict({
"id": [1, 2, 3, 4, 5],
"name": ["alice", "bob", "charlie", "alice", "eve"],
"score": [85.5, 92.0, 78.3, 95.1, 60.0],
})
def _filter(self, json_str):
return parse_predicate_to_batch_filter(json_str)(self.batch).to_pylist()
def test_equal(self):
self.assertEqual(
self._filter(_make_leaf("id", "EQUAL", [3])),
[False, False, True, False, False],
)
def test_not_equal(self):
self.assertEqual(
self._filter(_make_leaf("id", "NOT_EQUAL", [3])),
[True, True, False, True, True],
)
def test_less_than(self):
self.assertEqual(
self._filter(_make_leaf("id", "LESS_THAN", [3])),
[True, True, False, False, False],
)
def test_less_or_equal(self):
self.assertEqual(
self._filter(_make_leaf("id", "LESS_OR_EQUAL", [3])),
[True, True, True, False, False],
)
def test_greater_than(self):
self.assertEqual(
self._filter(_make_leaf("id", "GREATER_THAN", [3])),
[False, False, False, True, True],
)
def test_greater_or_equal(self):
self.assertEqual(
self._filter(_make_leaf("id", "GREATER_OR_EQUAL", [3])),
[False, False, True, True, True],
)
def test_is_null(self):
batch = pa.RecordBatch.from_pydict({"val": [1, None, 3, None, 5]})
f = parse_predicate_to_batch_filter(_make_leaf("val", "IS_NULL"))
self.assertEqual(f(batch).to_pylist(), [False, True, False, True, False])
def test_is_not_null(self):
batch = pa.RecordBatch.from_pydict({"val": [1, None, 3, None, 5]})
f = parse_predicate_to_batch_filter(_make_leaf("val", "IS_NOT_NULL"))
self.assertEqual(f(batch).to_pylist(), [True, False, True, False, True])
def test_in(self):
self.assertEqual(
self._filter(_make_leaf("id", "IN", [1, 3, 5])),
[True, False, True, False, True],
)
def test_not_in(self):
self.assertEqual(
self._filter(_make_leaf("id", "NOT_IN", [1, 3, 5])),
[False, True, False, True, False],
)
def test_between(self):
self.assertEqual(
self._filter(_make_leaf("id", "BETWEEN", [2, 4])),
[False, True, True, True, False],
)
def test_not_between(self):
self.assertEqual(
self._filter(_make_leaf("id", "NOT_BETWEEN", [2, 4])),
[True, False, False, False, True],
)
def test_starts_with(self):
self.assertEqual(
self._filter(_make_leaf("name", "STARTS_WITH", ["al"], "STRING")),
[True, False, False, True, False],
)
def test_ends_with(self):
self.assertEqual(
self._filter(_make_leaf("name", "ENDS_WITH", ["e"], "STRING")),
[True, False, True, True, True],
)
def test_contains(self):
self.assertEqual(
self._filter(_make_leaf("name", "CONTAINS", ["li"], "STRING")),
[True, False, True, True, False],
)
def test_like(self):
like_json = json.dumps({
"kind": "LEAF",
"transform": {"name": "FIELD_REF", "fieldRef": {"index": 0, "name": "name", "type": "STRING"}},
"function": "LIKE",
"literals": ["%li%"],
})
self.assertEqual(
parse_predicate_to_batch_filter(like_json)(self.batch).to_pylist(),
[True, False, True, True, False],
)
def test_true(self):
self.assertEqual(
self._filter(_make_leaf("id", "TRUE")),
[True, True, True, True, True],
)
def test_false(self):
self.assertEqual(
self._filter(_make_leaf("id", "FALSE")),
[False, False, False, False, False],
)
class TestCompoundPredicates(unittest.TestCase):
def setUp(self):
self.batch = pa.RecordBatch.from_pydict({
"id": [1, 2, 3, 4, 5],
})
def test_short_circuit_only_evaluates_rows_reaching_each_child(self):
batch = pa.RecordBatch.from_pydict({'id': [1, 2], 's': ['abc', 'xyz'], 'position': [1, 2147483648]})
first = json.loads(_make_leaf('id', 'EQUAL', [1]))
second = {'kind': 'LEAF', 'function': 'EQUAL', 'literals': ['a'], 'transform': {
'name': 'SUBSTRING', 'inputs': [
{'name': 's', 'type': 'STRING'}, {'name': 'position', 'type': 'BIGINT'}, 1]}}
for function, compare, expected in [('AND', 1, [True, False]), ('OR', 2, [True, True])]:
first['literals'] = [compare]
rule = {'kind': 'COMPOUND', 'function': function, 'children': [first, second]}
with self.subTest(function=function):
self.assertEqual(parse_predicate_to_batch_filter(json.dumps(rule))(batch).to_pylist(), expected)
def test_and(self):
pred = json.dumps({
"kind": "COMPOUND",
"function": "AND",
"children": [
{"kind": "LEAF",
"transform": {"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": "id", "type": "INT"}},
"function": "GREATER_THAN", "literals": [2]},
{"kind": "LEAF",
"transform": {"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": "id", "type": "INT"}},
"function": "LESS_THAN", "literals": [5]},
],
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(self.batch).to_pylist(), [False, False, True, True, False])
def test_nested_compounds_keep_original_mixed_row_positions(self):
batch = pa.RecordBatch.from_pydict({
'id': [1, 2, 3, 4], 's': ['x', 'abc', '', None], 'position': [2147483648, 1, 0, 2147483648]})
def guard(id_):
return {'kind': 'LEAF', 'function': 'EQUAL', 'literals': [id_],
'transform': {'name': 'FIELD_REF', 'fieldRef': {'name': 'id', 'type': 'BIGINT'}}}
substring = {'kind': 'LEAF', 'function': 'EQUAL', 'literals': ['a'], 'transform': {
'name': 'SUBSTRING', 'inputs': [
{'name': 's', 'type': 'STRING'}, {'name': 'position', 'type': 'BIGINT'}, 1]}}
rule = {'kind': 'COMPOUND', 'function': 'OR', 'children': [guard(1), {
'kind': 'COMPOUND', 'function': 'AND', 'children': [guard(2), substring]}]}
self.assertEqual(parse_predicate_to_batch_filter(json.dumps(rule))(batch).to_pylist(),
[True, True, False, False])
def test_or(self):
pred = json.dumps({
"kind": "COMPOUND",
"function": "OR",
"children": [
{"kind": "LEAF",
"transform": {"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": "id", "type": "INT"}},
"function": "EQUAL", "literals": [1]},
{"kind": "LEAF",
"transform": {"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": "id", "type": "INT"}},
"function": "EQUAL", "literals": [5]},
],
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(self.batch).to_pylist(), [True, False, False, False, True])
def test_unused_literals_and_unreachable_children_still_validate(self):
batch = pa.RecordBatch.from_pydict({'s': ['abc', None]})
for function in ['TRUE', 'FALSE', 'IS_NULL', 'IS_NOT_NULL', 'EQUAL', 'NOT_EQUAL', 'IN']:
for literals in [['bad'], [None, 'bad']]:
rule = {'kind': 'LEAF', 'transform': {'name': 'LENGTH', 'inputs': [{'name': 's', 'type': 'STRING'}]},
'function': function, 'literals': literals}
unreachable = {'kind': 'COMPOUND', 'function': 'OR', 'children': [
{'kind': 'LEAF', 'transform': {'name': 'NULL'}, 'function': 'TRUE', 'literals': []}, rule]}
for definition in [rule, unreachable]:
with self.subTest(function=function, literals=literals), self.assertRaises((ValueError, TypeError)):
parse_predicate_to_batch_filter(json.dumps(definition))(batch)
def test_unary_and_constant_literals_can_be_null_or_omitted(self):
batch = pa.RecordBatch.from_pydict({'s': ['abc', None]})
for function, expected in [('TRUE', [True, True]), ('FALSE', [False, False]),
('IS_NULL', [False, True]), ('IS_NOT_NULL', [True, False])]:
rule = {'kind': 'LEAF', 'transform': {'name': 'LENGTH', 'inputs': [{'name': 's', 'type': 'STRING'}]},
'function': function, 'literals': None}
self.assertEqual(parse_predicate_to_batch_filter(json.dumps(rule))(batch).to_pylist(), expected)
rule.pop('literals')
self.assertEqual(parse_predicate_to_batch_filter(json.dumps(rule))(batch).to_pylist(), expected)
def test_invalid_compound_never_becomes_allow_all(self):
for function, children in [('AND', []), ('OR', []), ('NOT', [])]:
with self.assertRaises(ValueError):
parse_predicate_to_batch_filter(json.dumps({'kind': 'COMPOUND', 'function': function,
'children': children}))
def test_binary_predicate_literals_follow_java_rejection(self):
batch = pa.RecordBatch.from_pydict({'s': ['abc', None]})
for type_ in ['BINARY(3)', 'VARBINARY(3)']:
transform = {'name': 'CAST', 'fieldRef': {'name': 's', 'type': 'STRING'}, 'type': type_}
rule = {'kind': 'LEAF', 'transform': transform, 'function': 'IS_NULL', 'literals': []}
self.assertEqual(parse_predicate_to_batch_filter(json.dumps(rule))(batch).to_pylist(), [False, True])
rule.update(function='EQUAL', literals=[None])
self.assertEqual(parse_predicate_to_batch_filter(json.dumps(rule))(batch).to_pylist(), [False, False])
for function in ['EQUAL', 'TRUE', 'IS_NULL']:
rule.update(function=function, literals=['YQAA'])
with self.subTest(type=type_, function=function), self.assertRaises(ValueError):
parse_predicate_to_batch_filter(json.dumps(rule))(batch)
class TestPredicateTransforms(unittest.TestCase):
def setUp(self):
self.batch = pa.RecordBatch.from_pydict({
"id": [1, 2, 3, 4, 5],
"name": ["alice", "bob", "charlie", "alice", "eve"],
})
def test_upper_transform(self):
pred = json.dumps({
"kind": "LEAF",
"transform": {"name": "UPPER", "inputs": [{"index": 1, "name": "name", "type": "STRING"}]},
"function": "EQUAL",
"literals": ["ALICE"],
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(self.batch).to_pylist(), [True, False, False, True, False])
def test_lower_transform(self):
pred = json.dumps({
"kind": "LEAF",
"transform": {"name": "LOWER", "inputs": [{"index": 1, "name": "name", "type": "STRING"}]},
"function": "EQUAL",
"literals": ["bob"],
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(self.batch).to_pylist(), [False, True, False, False, False])
def test_cast_transform(self):
pred = json.dumps({
"kind": "LEAF",
"transform": {"name": "CAST", "fieldRef": {"index": 0, "name": "id", "type": "INT"}, "type": "BIGINT"},
"function": "GREATER_THAN",
"literals": [3],
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(self.batch).to_pylist(), [False, False, False, True, True])
def test_null_transform(self):
pred = json.dumps({
"kind": "LEAF",
"transform": {"name": "NULL"},
"function": "IS_NULL",
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(self.batch).to_pylist(), [True, True, True, True, True])
def test_concat_transform_in_predicate(self):
batch = pa.RecordBatch.from_pydict({
"first": ["john", "jane"],
"last": ["doe", "smith"],
})
pred = json.dumps({
"kind": "LEAF",
"transform": {
"name": "CONCAT",
"inputs": [
{"index": 0, "name": "first", "type": "STRING"},
{"index": 1, "name": "last", "type": "STRING"},
],
},
"function": "EQUAL",
"literals": ["johndoe"],
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(batch).to_pylist(), [True, False])
def test_concat_ws_transform_in_predicate(self):
batch = pa.RecordBatch.from_pydict({
"first": ["john", "jane"],
"last": ["doe", "smith"],
})
pred = json.dumps({
"kind": "LEAF",
"transform": {
"name": "CONCAT_WS",
"inputs": [
" ",
{"index": 0, "name": "first", "type": "STRING"},
{"index": 1, "name": "last", "type": "STRING"},
],
},
"function": "EQUAL",
"literals": ["john doe"],
})
f = parse_predicate_to_batch_filter(pred)
self.assertEqual(f(batch).to_pylist(), [True, False])
def test_cast_enforces_java_character_and_byte_widths(self):
batch = pa.RecordBatch.from_pydict({'s': ['🙂中', 'a', None]})
for type_, expected in [
('VARCHAR(1)', ['🙂', 'a', None]), ('CHAR(3)', ['🙂中 ', 'a ', None]),
('BINARY(6)', ['🙂中'.encode('utf8')[:6], b'a' + b'\x00' * 5, None]),
('VARBINARY(1)', [b'\xf0', b'a', None]),
('VARCHAR', ['🙂', 'a', None]), ('CHAR', ['🙂', 'a', None]),
('BINARY', [b'\xf0', b'a', None]), ('VARBINARY', [b'\xf0', b'a', None]),
('CHAR(3) NULL', ['🙂中 ', 'a ', None]), ('CHAR(3)NOT NULL', ['🙂中 ', 'a ', None]),
]:
transform = {'name': 'CAST', 'fieldRef': {'name': 's', 'type': 'STRING'}, 'type': type_}
with self.subTest(type=type_):
self.assertEqual(_apply_predicate_transform(transform, batch).to_pylist(), expected)
def test_rebound_cast_keeps_original_java_runtime_class(self):
transform = {'name': 'CAST', 'fieldRef': {'name': 's', 'type': 'STRING'}, 'type': 'VARCHAR(5)'}
with self.assertRaises(ValueError):
_apply_predicate_transform(transform, pa.RecordBatch.from_pydict({'s': [42]}))
nulls = pa.RecordBatch.from_pydict({'s': pa.array([None], type=pa.int32())})
self.assertEqual(_apply_predicate_transform(transform, nulls).to_pylist(), [None])
# NumericToString accepts Object, including a rebound string column.
transform['fieldRef']['type'] = 'INT'
strings = pa.RecordBatch.from_pydict({'s': ['abc', None]})
self.assertEqual(_apply_predicate_transform(transform, strings).to_pylist(), ['abc', None])
def test_rebound_numeric_to_string_uses_java_internal_temporal_values(self):
transform = {'name': 'CAST', 'fieldRef': {'name': 's', 'type': 'INT'}, 'type': 'STRING'}
for type_, values, expected in [
(pa.date32(), [0, -1, None], ['0', '-1', None]),
(pa.time32('ms'), [0, 3600001, None], ['0', '3600001', None]),
(pa.time64('us'), [0, 3600001123, None], ['0', '3600001', None]),
]:
batch = pa.RecordBatch.from_pydict({'s': pa.array(values, type=type_)})
with self.subTest(type=type_):
self.assertEqual(_apply_predicate_transform(transform, batch).to_pylist(), expected)
def test_rebound_numeric_to_string_uses_decimal_plain_text(self):
from decimal import Decimal
transform = {'name': 'CAST', 'fieldRef': {'name': 's', 'type': 'INT'}, 'type': 'STRING'}
values = ['0.0000000123', '0.0000000000', '-0.0000000123', None]
array = pa.array([Decimal(v) if v is not None else None for v in values], type=pa.decimal128(38, 10))
batch = pa.RecordBatch.from_pydict({'s': array})
self.assertEqual(_apply_predicate_transform(transform, batch).to_pylist(), values)
transform['type'] = 'VARCHAR(3)'
self.assertEqual(_apply_predicate_transform(transform, batch).to_pylist(), ['0.0', '0.0', '-0.', None])
def test_rebound_numeric_to_string_refuses_other_object_formatting(self):
transform = {'name': 'CAST', 'fieldRef': {'name': 's', 'type': 'INT'}, 'type': 'STRING'}
for array in [pa.array([0, None], type=pa.timestamp('ms')), pa.array([b'a', None]),
pa.array([1.0, None], type=pa.float64())]:
with self.subTest(type=array.type), self.assertRaises(ValueError):
_apply_predicate_transform(transform, pa.RecordBatch.from_pydict({'s': array}))
nulls = pa.RecordBatch.from_pydict({'s': pa.nulls(2, type=array.type)})
self.assertEqual(_apply_predicate_transform(transform, nulls).to_pylist(), [None, None])
def test_empty_concat_is_the_empty_string(self):
self.assertEqual(_apply_predicate_transform({'name': 'CONCAT', 'inputs': []}, self.batch).to_pylist(),
[''] * len(self.batch))
class TestPaimonTypeToArrow(unittest.TestCase):
def test_simple_types(self):
self.assertEqual(_paimon_type_to_arrow("INT"), pa.int32())
self.assertEqual(_paimon_type_to_arrow("BIGINT"), pa.int64())
self.assertEqual(_paimon_type_to_arrow("SMALLINT"), pa.int16())
self.assertEqual(_paimon_type_to_arrow("TINYINT"), pa.int8())
self.assertEqual(_paimon_type_to_arrow("FLOAT"), pa.float32())
self.assertEqual(_paimon_type_to_arrow("DOUBLE"), pa.float64())
self.assertEqual(_paimon_type_to_arrow("STRING"), pa.string())
self.assertEqual(_paimon_type_to_arrow("BOOLEAN"), pa.bool_())
self.assertEqual(_paimon_type_to_arrow("BYTES"), pa.binary())
self.assertEqual(_paimon_type_to_arrow("DATE"), pa.date32())
def test_varchar_char(self):
self.assertEqual(_paimon_type_to_arrow("VARCHAR"), pa.string())
self.assertEqual(_paimon_type_to_arrow("VARCHAR(100)"), pa.string())
self.assertEqual(_paimon_type_to_arrow("CHAR(10)"), pa.string())
def test_timestamp(self):
self.assertEqual(_paimon_type_to_arrow("TIMESTAMP(3)"), pa.timestamp("ms"))
self.assertEqual(_paimon_type_to_arrow("TIMESTAMP(6)"), pa.timestamp("us"))
self.assertEqual(_paimon_type_to_arrow("TIMESTAMP(9)"), pa.timestamp("ns"))
self.assertEqual(_paimon_type_to_arrow("TIMESTAMP(0)"), pa.timestamp("s"))
def test_timestamp_ltz(self):
self.assertEqual(
_paimon_type_to_arrow("TIMESTAMP WITH LOCAL TIME ZONE(6)"),
pa.timestamp("us", tz="UTC"),
)
def test_decimal(self):
self.assertEqual(
_paimon_type_to_arrow("DECIMAL(10, 2)"),
pa.decimal128(10, 2),
)
def test_unsupported_type_raises(self):
with self.assertRaises(ValueError):
_paimon_type_to_arrow("ARRAY<INT>")
class TestConvertLiteral(unittest.TestCase):
def test_none_literal(self):
self.assertIsNone(_convert_literal(None, pa.int32()))
def test_int_passthrough(self):
self.assertEqual(_convert_literal(42, pa.int32()), 42)
def test_string_passthrough(self):
self.assertEqual(_convert_literal("hello", pa.string()), "hello")
def test_timestamp_literal(self):
result = _convert_literal("2024-01-15T10:30:00", pa.timestamp("us"))
self.assertIsInstance(result, pa.Scalar)
def test_timestamp_z_suffix(self):
result = _convert_literal("2024-01-15T10:30:00Z", pa.timestamp("us", tz="UTC"))
self.assertIsInstance(result, pa.Scalar)
def test_date_literal(self):
result = _convert_literal("2024-01-15", pa.date32())
self.assertIsInstance(result, pa.Scalar)
def test_decimal_literal(self):
result = _convert_literal(123.45, pa.decimal128(10, 2))
self.assertIsInstance(result, pa.Scalar)
def test_timestamp_ltz_literal_uses_epoch_seconds(self):
# Jackson serializes java.time.Instant as epoch seconds (with an
# optional fractional part for nanos), not epoch milliseconds.
target_type = pa.timestamp("us", tz="UTC")
result = _convert_literal(1720612345.123456, target_type)
self.assertIsInstance(result, pa.Scalar)
expected = pa.scalar(
datetime.datetime.fromtimestamp(1720612345.123456, tz=datetime.timezone.utc),
type=target_type,
)
self.assertEqual(result.value, expected.value)
def test_time_literal_with_nanos(self):
# Jackson serializes java.time.LocalTime as [hour, minute, second,
# nanoOfSecond]; the nanosecond component must survive as
# sub-second precision on the resulting time scalar.
result = _convert_literal([12, 30, 45, 123456000], pa.time64("us"))
self.assertIsInstance(result, pa.Scalar)
expected = pa.scalar(
datetime.time(12, 30, 45, microsecond=123456), type=pa.time64("us"))
self.assertEqual(result.value, expected.value)
def test_time_literal_without_nanos(self):
result = _convert_literal([12, 30, 45], pa.time64("us"))
self.assertIsInstance(result, pa.Scalar)
expected = pa.scalar(datetime.time(12, 30, 45), type=pa.time64("us"))
self.assertEqual(result.value, expected.value)
class TestExtractReferencedFields(unittest.TestCase):
def test_leaf_field_ref(self):
refs = extract_referenced_fields(json.dumps({
"kind": "LEAF",
"transform": {"name": "FIELD_REF", "fieldRef": {"index": 0, "name": "col1", "type": "INT"}},
"function": "EQUAL",
"literals": [1],
}))
self.assertEqual(refs, {"col1"})
def test_leaf_cast(self):
refs = extract_referenced_fields(json.dumps({
"kind": "LEAF",
"transform": {"name": "CAST", "fieldRef": {"index": 0, "name": "col1", "type": "INT"}, "type": "BIGINT"},
"function": "EQUAL",
"literals": [1],
}))
self.assertEqual(refs, {"col1"})
def test_compound_collects_all(self):
refs = extract_referenced_fields(json.dumps({
"kind": "COMPOUND",
"function": "AND",
"children": [
{"kind": "LEAF",
"transform": {"name": "FIELD_REF",
"fieldRef": {"index": 0, "name": "a", "type": "INT"}},
"function": "EQUAL", "literals": [1]},
{"kind": "LEAF",
"transform": {"name": "UPPER",
"inputs": [{"index": 1, "name": "b", "type": "STRING"}]},
"function": "EQUAL", "literals": ["X"]},
],
}))
self.assertEqual(refs, {"a", "b"})
def test_concat_inputs(self):
refs = extract_referenced_fields(json.dumps({
"kind": "LEAF",
"transform": {
"name": "CONCAT",
"inputs": [
{"index": 0, "name": "first", "type": "STRING"},
"literal",
{"index": 1, "name": "last", "type": "STRING"},
],
},
"function": "EQUAL",
"literals": ["x"],
}))
self.assertEqual(refs, {"first", "last"})
class TestLikeEdgeCases(unittest.TestCase):
def test_dot_is_literal(self):
batch = pa.RecordBatch.from_pydict({"v": ["a.b", "axb", "a.bc"]})
f = parse_predicate_to_batch_filter(json.dumps({
"kind": "LEAF",
"transform": {"name": "FIELD_REF", "fieldRef": {"index": 0, "name": "v", "type": "STRING"}},
"function": "LIKE",
"literals": ["a.b"],
}))
self.assertEqual(f(batch).to_pylist(), [True, False, False])
def test_underscore_matches_single_char(self):
batch = pa.RecordBatch.from_pydict({"v": ["abc", "axc", "ac", "abbc"]})
f = parse_predicate_to_batch_filter(json.dumps({
"kind": "LEAF",
"transform": {"name": "FIELD_REF", "fieldRef": {"index": 0, "name": "v", "type": "STRING"}},
"function": "LIKE",
"literals": ["a_c"],
}))
self.assertEqual(f(batch).to_pylist(), [True, True, False, False])
def test_percent_matches_any(self):
batch = pa.RecordBatch.from_pydict({"v": ["abc", "ac", "axyzc", "def"]})
f = parse_predicate_to_batch_filter(json.dumps({
"kind": "LEAF",
"transform": {"name": "FIELD_REF", "fieldRef": {"index": 0, "name": "v", "type": "STRING"}},
"function": "LIKE",
"literals": ["a%c"],
}))
self.assertEqual(f(batch).to_pylist(), [True, True, True, False])
class TestLikeEscapeSemantics(unittest.TestCase):
def _make_like_batch(self, values):
return pa.RecordBatch.from_pydict(
{"name": values}, schema=pa.schema([("name", pa.string())]))
def _make_like_predicate(self, pattern):
return json.dumps({
"kind": "LEAF",
"transform": {"name": "FIELD_REF", "fieldRef": {"name": "name"}},
"function": "LIKE",
"literals": [pattern]
})
def test_like_simple_percent(self):
batch = self._make_like_batch(["admin", "admin_foo", "user"])
fn = parse_predicate_to_batch_filter(self._make_like_predicate("admin%"))
self.assertEqual(fn(batch).to_pylist(), [True, True, False])
def test_like_simple_underscore(self):
batch = self._make_like_batch(["ab", "abc", "a"])
fn = parse_predicate_to_batch_filter(self._make_like_predicate("a_"))
self.assertEqual(fn(batch).to_pylist(), [True, False, False])
def test_like_escaped_underscore(self):
batch = self._make_like_batch(["admin_foo", "adminXfoo", "admin"])
fn = parse_predicate_to_batch_filter(self._make_like_predicate("admin\\_%"))
self.assertEqual(fn(batch).to_pylist(), [True, False, False])
def test_like_escaped_percent(self):
batch = self._make_like_batch(["100%", "100X", "100"])
fn = parse_predicate_to_batch_filter(self._make_like_predicate("100\\%"))
self.assertEqual(fn(batch).to_pylist(), [True, False, False])
def test_like_escaped_backslash(self):
batch = self._make_like_batch(["a\\b", "a/b", "ab"])
fn = parse_predicate_to_batch_filter(self._make_like_predicate("a\\\\b"))
self.assertEqual(fn(batch).to_pylist(), [True, False, False])
def test_like_regex_special_chars(self):
batch = self._make_like_batch(["a.b", "axb", "a..b"])
fn = parse_predicate_to_batch_filter(self._make_like_predicate("a.b"))
self.assertEqual(fn(batch).to_pylist(), [True, False, False])
def test_like_percent_matches_newline(self):
batch = self._make_like_batch(["hello\nworld", "hello"])
fn = parse_predicate_to_batch_filter(self._make_like_predicate("hello%"))
self.assertEqual(fn(batch).to_pylist(), [True, True])
def test_like_invalid_escape_sequence(self):
import pytest
fn = parse_predicate_to_batch_filter(self._make_like_predicate("admin\\x"))
batch = self._make_like_batch(["admin"])
with pytest.raises((RuntimeError, ValueError), match="Invalid escape sequence"):
fn(batch)
class TestNotInNullSemantics(unittest.TestCase):
"""Java NOT_IN: null field -> false; any null literal -> false for all."""
def test_not_in_null_field_returns_false(self):
batch = pa.RecordBatch.from_pydict(
{"dept": ["eng", None, "sales"]},
schema=pa.schema([("dept", pa.string())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("dept", "NOT_IN", ["blocked"], field_type="STRING"))
result = fn(batch).to_pylist()
# null dept must return false, not true
self.assertEqual(result, [True, False, True])
def test_not_in_null_literal_returns_false_for_all(self):
batch = pa.RecordBatch.from_pydict(
{"dept": ["eng", None, "sales"]},
schema=pa.schema([("dept", pa.string())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("dept", "NOT_IN", ["blocked", None],
field_type="STRING"))
result = fn(batch).to_pylist()
# any null literal makes NOT_IN false for all rows
self.assertEqual(result, [False, False, False])
def test_not_in_normal(self):
batch = pa.RecordBatch.from_pydict(
{"id": [1, 2, 3]},
schema=pa.schema([("id", pa.int64())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("id", "NOT_IN", [2, 3]))
result = fn(batch).to_pylist()
self.assertEqual(result, [True, False, False])
class TestIsNaN(unittest.TestCase):
def test_is_nan_float(self):
batch = pa.RecordBatch.from_pydict(
{"val": [1.0, float("nan"), 3.0, None]},
schema=pa.schema([("val", pa.float64())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("val", "IS_NAN", field_type="DOUBLE"))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, True, False, False])
def test_is_nan_no_nans(self):
batch = pa.RecordBatch.from_pydict(
{"val": [1.0, 2.0]},
schema=pa.schema([("val", pa.float64())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("val", "IS_NAN", field_type="DOUBLE"))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False])
class TestNullLiteralDefense(unittest.TestCase):
"""Java LeafBinaryFunction/LeafTernaryFunction: null literal -> false."""
def test_starts_with_null_literal(self):
batch = pa.RecordBatch.from_pydict(
{"name": ["alice", "bob", None]},
schema=pa.schema([("name", pa.string())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("name", "STARTS_WITH", [None], field_type="STRING"))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False, False])
def test_ends_with_null_literal(self):
batch = pa.RecordBatch.from_pydict(
{"name": ["alice", "bob", None]},
schema=pa.schema([("name", pa.string())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("name", "ENDS_WITH", [None], field_type="STRING"))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False, False])
def test_contains_null_literal(self):
batch = pa.RecordBatch.from_pydict(
{"name": ["alice", "bob", None]},
schema=pa.schema([("name", pa.string())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("name", "CONTAINS", [None], field_type="STRING"))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False, False])
def test_like_null_literal(self):
batch = pa.RecordBatch.from_pydict(
{"name": ["alice", "bob", None]},
schema=pa.schema([("name", pa.string())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("name", "LIKE", [None], field_type="STRING"))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False, False])
def test_between_null_lower_literal(self):
batch = pa.RecordBatch.from_pydict(
{"val": [1, 5, 10, None]},
schema=pa.schema([("val", pa.int64())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("val", "BETWEEN", [None, 10]))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False, False, False])
def test_between_null_upper_literal(self):
batch = pa.RecordBatch.from_pydict(
{"val": [1, 5, 10, None]},
schema=pa.schema([("val", pa.int64())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("val", "BETWEEN", [1, None]))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False, False, False])
def test_not_between_null_literal(self):
batch = pa.RecordBatch.from_pydict(
{"val": [1, 5, 10, None]},
schema=pa.schema([("val", pa.int64())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("val", "NOT_BETWEEN", [None, 10]))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, False, False, False])
def test_starts_with_normal(self):
"""Verify normal case still works after adding null guard."""
batch = pa.RecordBatch.from_pydict(
{"name": ["alice", "anna", "bob", None]},
schema=pa.schema([("name", pa.string())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("name", "STARTS_WITH", ["a"], field_type="STRING"))
result = fn(batch).to_pylist()
self.assertEqual(result, [True, True, False, False])
def test_between_normal(self):
"""Verify normal BETWEEN still works."""
batch = pa.RecordBatch.from_pydict(
{"val": [1, 5, 10, None]},
schema=pa.schema([("val", pa.int64())]),
)
fn = parse_predicate_to_batch_filter(
_make_leaf("val", "BETWEEN", [3, 8]))
result = fn(batch).to_pylist()
self.assertEqual(result, [False, True, False, False])
if __name__ == "__main__":
unittest.main()
class TestInNullHandling(unittest.TestCase):
def test_in_with_null_literal_drops_null(self):
batch = pa.RecordBatch.from_pydict(
{"dept": ["eng", "sales", None]},
schema=pa.schema([("dept", pa.string())]),
)
pred = _make_leaf("dept", "IN", ["eng", None], field_type="STRING")
result = parse_predicate_to_batch_filter(pred)(batch).to_pylist()
self.assertEqual(result, [True, False, False])
def test_in_all_null_literals(self):
batch = pa.RecordBatch.from_pydict(
{"dept": ["eng", "sales"]},
schema=pa.schema([("dept", pa.string())]),
)
pred = _make_leaf("dept", "IN", [None, None], field_type="STRING")
result = parse_predicate_to_batch_filter(pred)(batch).to_pylist()
self.assertEqual(result, [False, False])
def test_in_null_field_value_returns_false(self):
batch = pa.RecordBatch.from_pydict(
{"dept": [None, "eng"]},
schema=pa.schema([("dept", pa.string())]),
)
pred = _make_leaf("dept", "IN", ["eng", "sales"], field_type="STRING")
result = parse_predicate_to_batch_filter(pred)(batch).to_pylist()
self.assertEqual(result, [False, True])
class TestComparisonNullLiteral(unittest.TestCase):
def test_equal_null_literal_returns_false(self):
batch = pa.RecordBatch.from_pydict({"id": [1, 2, 3]})
pred = _make_leaf("id", "EQUAL", [None])
result = parse_predicate_to_batch_filter(pred)(batch).to_pylist()
self.assertEqual(result, [False, False, False])
def test_not_equal_null_literal_returns_false(self):
batch = pa.RecordBatch.from_pydict({"id": [1, 2, 3]})
pred = _make_leaf("id", "NOT_EQUAL", [None])
result = parse_predicate_to_batch_filter(pred)(batch).to_pylist()
self.assertEqual(result, [False, False, False])
def test_less_than_null_literal_returns_false(self):
batch = pa.RecordBatch.from_pydict({"id": [1, 2, 3]})
pred = _make_leaf("id", "LESS_THAN", [None])
result = parse_predicate_to_batch_filter(pred)(batch).to_pylist()
self.assertEqual(result, [False, False, False])
def test_greater_than_null_literal_returns_false(self):
batch = pa.RecordBatch.from_pydict({"id": [1, 2, 3]})
pred = _make_leaf("id", "GREATER_THAN", [None])
result = parse_predicate_to_batch_filter(pred)(batch).to_pylist()
self.assertEqual(result, [False, False, False])
class TestConcatWsAllNull(unittest.TestCase):
def test_concat_ws_all_null_values_returns_empty(self):
pred_json = json.dumps({
"kind": "LEAF",
"transform": {
"name": "CONCAT_WS",
"inputs": [",", None, None],
},
"function": "EQUAL",
"literals": [""],
})
batch = pa.RecordBatch.from_pydict(
{"x": [1, 2, 3]},
schema=pa.schema([("x", pa.int64())]),
)
fn = parse_predicate_to_batch_filter(pred_json)
result = fn(batch).to_pylist()
self.assertEqual(result, [True, True, True])
def test_concat_ws_mixed_null_preserves_row_positions(self):
pred_json = json.dumps({
"kind": "LEAF",
"transform": {
"name": "CONCAT_WS",
"inputs": [
"-",
{"index": 0, "name": "a", "type": "STRING"},
{"index": 1, "name": "b", "type": "STRING"},
],
},
"function": "EQUAL",
"literals": ["x-y"],
})
batch = pa.RecordBatch.from_pydict(
{"a": [None, "x", None, "p"], "b": [None, "y", "z", None]},
schema=pa.schema([("a", pa.string()), ("b", pa.string())]),
)
fn = parse_predicate_to_batch_filter(pred_json)
result = fn(batch).to_pylist()
self.assertEqual(result, [False, True, False, False])
class TestDateExtractTransforms(unittest.TestCase):
"""YEAR / MONTH / DAY / HOUR / MINUTE / SECOND / QUARTER / DAY_OF_YEAR /
WEEKDAY / ISO_DAY_OF_WEEK / DAY_OF_WEEK / WEEK / YEAR_OF_WEEK,
mirroring Java's DateExtractTransform subclasses."""
def _ts_batch(self):
# 2024-01-01 00:00:00 and 2024-07-01 02:24:05 (us), plus null.
return pa.RecordBatch.from_pydict({
"t": pa.array([1704067200000000, 1719800645000000, None],
type=pa.timestamp("us")),
})
def _apply(self, name, batch, field="t", ftype="TIMESTAMP(6)"):
transform = {"name": name,
"fieldRef": {"index": 0, "name": field, "type": ftype}}
return _apply_predicate_transform(transform, batch).to_pylist()
def test_timestamp_parts(self):
batch = self._ts_batch()
self.assertEqual(self._apply("YEAR", batch), [2024, 2024, None])
self.assertEqual(self._apply("MONTH", batch), [1, 7, None])
self.assertEqual(self._apply("DAY", batch), [1, 1, None])
self.assertEqual(self._apply("HOUR", batch), [0, 2, None])
self.assertEqual(self._apply("MINUTE", batch), [0, 24, None])
self.assertEqual(self._apply("SECOND", batch), [0, 5, None])
self.assertEqual(self._apply("QUARTER", batch), [1, 3, None])
self.assertEqual(self._apply("DAY_OF_YEAR", batch), [1, 183, None])
def test_week_family_parts(self):
# 2024-01-01 Monday, 2023-01-01 Sunday, 2021-01-01 Friday (ISO week 53
# of the 2020 week-based year), plus null. Numbering matches java.time.
batch = pa.RecordBatch.from_pydict({
"d": pa.array(
[datetime.date(2024, 1, 1), datetime.date(2023, 1, 1),
datetime.date(2021, 1, 1), None],
type=pa.date32()),
})
# WEEKDAY Monday=0..Sunday=6
self.assertEqual(self._apply("WEEKDAY", batch, "d", "DATE"), [0, 6, 4, None])
# ISO_DAY_OF_WEEK Monday=1..Sunday=7
self.assertEqual(self._apply("ISO_DAY_OF_WEEK", batch, "d", "DATE"), [1, 7, 5, None])
# DAY_OF_WEEK Sunday=1..Saturday=7
self.assertEqual(self._apply("DAY_OF_WEEK", batch, "d", "DATE"), [2, 1, 6, None])
# WEEK: ISO week of the week-based year
self.assertEqual(self._apply("WEEK", batch, "d", "DATE"), [1, 52, 53, None])
# YEAR_OF_WEEK: ISO week-based year
self.assertEqual(self._apply("YEAR_OF_WEEK", batch, "d", "DATE"), [2024, 2022, 2020, None])
def test_date_field_starts_at_midnight(self):
# epoch day 19723 == 2024-01-01; a DATE has no time, so HOUR is 0.
batch = pa.RecordBatch.from_pydict(
{"d": pa.array([19723, None], type=pa.date32())})
self.assertEqual(self._apply("YEAR", batch, "d", "DATE"), [2024, None])
self.assertEqual(self._apply("MONTH", batch, "d", "DATE"), [1, None])
self.assertEqual(self._apply("HOUR", batch, "d", "DATE"), [0, None])
def test_calendar_parts_match_java_golden_for_every_storage_unit(self):
parts = ['YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE', 'SECOND', 'QUARTER', 'DAY_OF_YEAR',
'WEEKDAY', 'ISO_DAY_OF_WEEK', 'DAY_OF_WEEK', 'WEEK', 'YEAR_OF_WEEK']
golden = Path(__file__).parent / 'resources' / 'calendar_extract_java.jsonl'
for line in golden.read_text().splitlines():
case = json.loads(line)
unit = case['unit']
type_ = pa.date32() if unit == 'date' else pa.timestamp(unit)
wire = 'DATE' if unit == 'date' else 'TIMESTAMP(9)'
batch = pa.RecordBatch.from_pydict({'t': pa.array([case['value'], None], type=type_)})
for part, expected in zip(parts, case['parts']):
with self.subTest(unit=unit, value=case['value'], part=part):
self.assertEqual(self._apply(part, batch, 't', wire), [expected, None])
def test_empty_and_all_null_calendar_batches(self):
for unit in ['date', 's', 'ms', 'us', 'ns']:
type_ = pa.date32() if unit == 'date' else pa.timestamp(unit)
wire = 'DATE' if unit == 'date' else 'TIMESTAMP(9)'
for values in [[], [None, None]]:
batch = pa.RecordBatch.from_pydict({'t': pa.array(values, type=type_)})
for part in ['YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE', 'SECOND', 'QUARTER', 'DAY_OF_YEAR',
'WEEKDAY', 'ISO_DAY_OF_WEEK', 'DAY_OF_WEEK', 'WEEK', 'YEAR_OF_WEEK']:
with self.subTest(unit=unit, values=values, part=part):
self.assertEqual(self._apply(part, batch, 't', wire), values)
def test_calendar_operand_is_a_dependency_even_if_hidden(self):
for part in ['YEAR', 'MONTH', 'DAY', 'HOUR', 'MINUTE', 'SECOND', 'QUARTER', 'DAY_OF_YEAR',
'WEEKDAY', 'ISO_DAY_OF_WEEK', 'DAY_OF_WEEK', 'WEEK', 'YEAR_OF_WEEK']:
rule = {'name': part, 'fieldRef': {'name': 'hidden', 'index': 99, 'type': 'DATE'}}
predicate = {'kind': 'LEAF', 'function': 'IS_NULL', 'transform': rule}
self.assertEqual(extract_referenced_fields(json.dumps(predicate)), {'hidden'})
def test_stored_and_rebound_calendar_types_are_validated(self):
for stored in ['INT', 'BIGINT', 'TIME(3)', 'TIMESTAMP_LTZ(3)', 'TIMESTAMP(10)', 'DATENULL',
'TIMESTAMP(٣)', 'TIMESTAMP(3)',
'TIMESTAMP(3) WITHOUT TIME ZONENULL',
'TIMESTAMP(3) WITHOUT TIME ZONENOT NULL',
{'type': 'DATE'}, {'type': 'TIMESTAMP(6)'}, None]:
with self.subTest(stored=stored), self.assertRaises(ValueError):
self._apply('YEAR', self._ts_batch(), 't', stored)
for type_, value in [(pa.int32(), 1), (pa.time32('ms'), 1), (pa.timestamp('ms', tz='UTC'), 1)]:
batch = pa.RecordBatch.from_pydict({'t': pa.array([value], type=type_)})
with self.subTest(type=type_), self.assertRaises(ValueError):
self._apply('YEAR', batch, 't', 'DATE')
# As in Java copyWithNewInputs, DATE and local TIMESTAMP may rebind to
# one another; the actual column decides the executed calendar value.
for stored in ['DATE', 'DATE NULL', 'TIMESTAMP(3)NOT NULL', 'TIMESTAMP WITHOUT TIME ZONE',
'TIMESTAMP(3) WITHOUT TIME ZONE',
'TIMESTAMP(3)WITHOUT TIME ZONE']:
with self.subTest(stored=stored):
self.assertEqual(self._apply('YEAR', self._ts_batch(), 't', stored), [2024, 2024, None])
def test_full_date32_domain_does_not_require_a_timestamp_cast(self):
batch = pa.RecordBatch.from_pydict({"d": pa.array([2147483647, -2147483648, None], type=pa.date32())})
self.assertEqual(self._apply("HOUR", batch, "d", "DATE"), [0, 0, None])
def test_timestamp_epoch_day_narrows_as_java_int_before_calendar_extraction(self):
# Java Timestamp narrows its millisecond epoch day to a signed int.
ticks = (2147483648 * 86400000)
batch = pa.RecordBatch.from_pydict({"t": pa.array([ticks, -1, None], type=pa.timestamp("ms"))})
days = pa.RecordBatch.from_pydict({"d": pa.array([-2147483648, -1, None], type=pa.date32())})
self.assertEqual(self._apply("YEAR", batch, "t", "TIMESTAMP(3)"),
self._apply("YEAR", days, "d", "DATE"))
def test_wired_through_a_leaf_filter(self):
pred = json.dumps({
"kind": "LEAF",
"transform": {"name": "MONTH",
"fieldRef": {"index": 0, "name": "t", "type": "TIMESTAMP(6)"}},
"function": "EQUAL",
"literals": [7],
})
self.assertEqual(
parse_predicate_to_batch_filter(pred)(self._ts_batch()).to_pylist(),
[False, True, False])
class TestStringTransforms(unittest.TestCase):
"""LENGTH / BIT_LENGTH / TRANSLATE / OVERLAY / PAD, mirroring the Java
transforms of the same name (see TransformJsonSerdeTest for the wire shape)."""
def _apply(self, transform, column):
batch = pa.RecordBatch.from_pydict({"s": column})
return _apply_predicate_transform(transform, batch).to_pylist()
def _field(self):
return {"index": 0, "name": "s", "type": "STRING"}
def test_length(self):
out = self._apply({"name": "LENGTH", "inputs": [self._field()]},
["hello", "hi", None])
self.assertEqual(out, [5, 2, None])
def test_bit_length(self):
# "é" is two UTF-8 bytes -> 16 bits.
out = self._apply({"name": "BIT_LENGTH", "inputs": [self._field()]},
["hello", "é", None])
self.assertEqual(out, [40, 16, None])
def test_translate_maps_and_deletes(self):
mapped = self._apply(
{"name": "TRANSLATE", "inputs": [self._field(), "el", "ip"]},
["hello", None])
self.assertEqual(mapped, ["hippo", None])
# A shorter replacement deletes the unmatched matching characters.
deleted = self._apply(
{"name": "TRANSLATE", "inputs": [self._field(), "l", ""]},
["hello"])
self.assertEqual(deleted, ["heo"])
def test_overlay_with_and_without_length(self):
four = self._apply(
{"name": "OVERLAY", "inputs": [self._field(), "XX", 2, 1]},
["hello", None])
self.assertEqual(four, ["hXXllo", None])
three = self._apply(
{"name": "OVERLAY", "inputs": [self._field(), "XX", 2]},
["hello"])
self.assertEqual(three, ["hXXlo"])
def test_overlay_position_arithmetic_overflows_like_java(self):
# pos + replaced overflows 32-bit int in Java's OverlayTransform:
# OVERLAY('hello', 'x', 1, 2147483647) is 'xhell' there (the wrapped
# end position lands inside the string), not 'x'. The auth rule must
# admit the same rows in PyPaimon.
out = self._apply(
{"name": "OVERLAY", "inputs": [self._field(), "x", 1, 2147483647]},
["hello"])
self.assertEqual(out, ["xhell"])
def test_pad_left_right_and_truncate(self):
left = self._apply(
{"name": "PAD", "inputs": [self._field(), 5, "ab"], "direction": "LEFT"},
["hi", None])
self.assertEqual(left, ["abahi", None])
right = self._apply(
{"name": "PAD", "inputs": [self._field(), 5, "ab"], "direction": "RIGHT"},
["hi"])
self.assertEqual(right, ["hiaba"])
# A target shorter than the source truncates.
self.assertEqual(
self._apply(
{"name": "PAD", "inputs": [self._field(), 3, "x"], "direction": "LEFT"},
["hello"]),
["hel"])
def test_integer_overflow_is_eager_even_with_null_string_inputs(self):
for rule in [
{"name": "PAD", "direction": "LEFT", "inputs": [None, 2147483648, "x"]},
{"name": "OVERLAY", "inputs": [None, "x", 2147483648, None]},
{"name": "OVERLAY", "inputs": [None, "x", None, 2147483648]},
]:
with self.subTest(rule=rule), self.assertRaises(ValueError):
self._apply(rule, [None])
def test_field_types_are_checked_before_rebinding(self):
batch = pa.RecordBatch.from_pydict({"s": ["abc"], "n": [2]})
for name, inputs in [
("PAD", [self._field(), {"name": "n", "type": "STRING"}, "x"]),
("OVERLAY", [self._field(), "x", {"name": "n", "type": "STRING"}]),
("TRANSLATE", [{"name": "s", "type": "INT"}, "a", "b"]),
("LENGTH", [{"name": "s", "type": "INT"}]),
("BIT_LENGTH", [{"name": "s", "type": "INT"}]),
]:
rule = {"name": name, "inputs": inputs, "direction": "LEFT"}
with self.subTest(rule=rule), self.assertRaises(ValueError):
_apply_predicate_transform(rule, batch)
for value in ["2", True, 1.5]:
with self.subTest(value=value), self.assertRaises(ValueError):
self._apply({"name": "PAD", "inputs": [None, value, "x"], "direction": "LEFT"}, [None])
def test_length_wired_through_a_leaf_filter(self):
pred = json.dumps({
"kind": "LEAF",
"transform": {"name": "LENGTH", "inputs": [self._field()]},
"function": "GREATER_THAN",
"literals": [3],
})
batch = pa.RecordBatch.from_pydict({"s": ["hello", "hi", "abcd"]})
self.assertEqual(
parse_predicate_to_batch_filter(pred)(batch).to_pylist(),
[True, False, True])
def test_unknown_transform_still_raises(self):
with self.assertRaises(ValueError):
self._apply({"name": "NO_SUCH_TRANSFORM", "inputs": [self._field()]},
["hello"])