blob: 1ba78986a5660c5d7abf46219239e1e9340efdbd [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.
"""
SQL WHERE clause parser for PyPaimon.
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 datetime
import decimal
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, Optional[str]]:
"""Build a mapping from field name to its base type string.
Only AtomicType fields are supported for WHERE filtering.
Non-atomic types (ARRAY, MAP, ROW, etc.) are mapped to None.
"""
result = {}
for field in fields:
if isinstance(field.type, AtomicType):
result[field.name] = field.type.type.upper()
else:
result[field.name] = None
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()
if base_type in integer_types:
return int(value_str)
if base_type in float_types:
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
_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}'")
if field_name not in type_map:
raise ValueError(
f"Unknown field '{field_name}'. "
f"Available fields: {sorted(type_map.keys())}"
)
field_type = type_map[field_name]
if field_type is None:
raise ValueError(
f"Field '{field_name}' has a non-atomic type (e.g., ARRAY, MAP, ROW) "
f"which is not supported in WHERE clauses. "
f"Only atomic type fields (INT, STRING, DOUBLE, etc.) can be used for filtering."
)
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 / NOT BETWEEN
if operator_token == 'NOT':
tokens = tokens[1:] # consume 'NOT'
if not tokens:
raise ValueError(f"Expected 'IN' or 'BETWEEN' after 'NOT' for field '{field_name}'")
next_keyword = tokens[0].upper()
if next_keyword == 'IN':
tokens = tokens[1:] # consume 'IN'
values, tokens = _parse_in_list(tokens, field_type)
return builder.is_not_in(field_name, values), tokens
elif next_keyword == '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 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
else:
raise ValueError(f"Expected 'IN' or 'BETWEEN' after 'NOT' for field '{field_name}'")
# 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
# LIKE 'pattern'
if operator_token == 'LIKE':
tokens = tokens[1:] # consume 'LIKE'
pattern_str, tokens = _consume_literal(tokens)
return builder.like(field_name, pattern_str), tokens
# Comparison operators: =, !=, <>, <, <=, >, >=
comparison_operators = {'=', '!=', '<>', '<', '<=', '>', '>='}
if operator_token in comparison_operators:
tokens = tokens[1:] # consume operator
value_str, tokens = _consume_literal(tokens)
value = _cast_literal(value_str, field_type)
predicate = _build_comparison(builder, field_name, operator_token, value)
return predicate, tokens
raise ValueError(
f"Unsupported operator '{tokens[0]}' for field '{field_name}'. "
f"Supported: =, !=, <>, <, <=, >, >=, IS NULL, IS NOT NULL, IN, NOT IN, BETWEEN, LIKE"
)
def _build_comparison(
builder: PredicateBuilder,
field_name: str,
operator: str,
value: Any,
) -> Predicate:
"""Build a comparison predicate."""
if operator == '=':
return builder.equal(field_name, value)
elif operator in ('!=', '<>'):
return builder.not_equal(field_name, value)
elif operator == '<':
return builder.less_than(field_name, value)
elif operator == '<=':
return builder.less_or_equal(field_name, value)
elif operator == '>':
return builder.greater_than(field_name, value)
elif operator == '>=':
return builder.greater_or_equal(field_name, value)
else:
raise ValueError(f"Unknown comparison operator: {operator}")
def _parse_in_list(tokens: List[str], field_type: str) -> (List[Any], List[str]):
"""Parse an IN list: (val1, val2, ...)."""
if not tokens or tokens[0] != '(':
raise ValueError("Expected '(' after IN")
tokens = tokens[1:] # consume '('
values = []
while tokens:
if tokens[0] == ')':
tokens = tokens[1:] # consume ')'
return values, tokens
if tokens[0] == ',':
tokens = tokens[1:] # consume ','
continue
value_str, tokens = _consume_literal(tokens)
values.append(_cast_literal(value_str, field_type))
raise ValueError("Missing closing ')' in IN list")
def _consume_literal(tokens: List[str]) -> (str, List[str]):
"""Consume a single literal value from the token stream.
Handles quoted strings (strips quotes) and unquoted values.
"""
if not tokens:
raise ValueError("Expected a literal value but reached end of expression")
token = tokens[0]
tokens = tokens[1:]
# Strip surrounding quotes from string literals
if (token.startswith("'") and token.endswith("'")) or \
(token.startswith('"') and token.endswith('"')):
return token[1:-1], tokens
return token, tokens