Source code for neosqlite.collection.expr_evaluator.sql_converters.comparison

"""SQL converters for comparison operators."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    pass


from .base import BaseSqlMixin


[docs] class ComparisonMixin(BaseSqlMixin): """$eq / $gt / $lt / $gte / $lte / $ne / $cmp → SQL."""
[docs] def _convert_comparison_operator( self, operator: str, operands: list[Any] ) -> tuple[str, list[Any]]: """Convert comparison operators to SQL.""" if len(operands) != 2: raise ValueError(f"{operator} requires exactly 2 operands") left_sql, left_params = self._convert_operand_to_sql(operands[0]) right_sql, right_params = self._convert_operand_to_sql(operands[1]) # $eq between two FIELD references: missing-vs-missing counts as # equal (Python tier semantics, #121); null-vs-null handled by the # literal branch below. if ( operator == "$eq" and isinstance(operands[0], str) and operands[0].startswith("$") and not operands[0].startswith("$$") and isinstance(operands[1], str) and operands[1].startswith("$") and not operands[1].startswith("$$") ): from ...json_path_utils import parse_json_path p1 = "'" + parse_json_path(operands[0][1:]) + "'" p2 = "'" + parse_json_path(operands[1][1:]) + "'" d = self.data_column sql = ( f"(CASE " f"WHEN json_type({d}, {p1}) IS NULL AND " f"json_type({d}, {p2}) IS NULL THEN 1 " f"WHEN {left_sql} IS {right_sql} THEN 1 ELSE 0 END)" ) return sql, [] if ( operator == "$ne" and isinstance(operands[0], str) and operands[0].startswith("$") and not operands[0].startswith("$$") and not (operands[1] is None) and not ( isinstance(operands[1], str) and operands[1].startswith("$") ) ): # $ne field-vs-scalar: missing fields DO match ($ne semantics, # #121); null-vs-missing does not. Use the two-arg json_type to # separate absent keys from explicit nulls. from ...json_path_utils import parse_json_path p1 = "'" + parse_json_path(operands[0][1:]) + "'" d = self.data_column lit_sql, lit_params = self._convert_operand_to_sql(operands[1]) sql = ( f"(CASE WHEN json_type({d}, {p1}) IS NULL THEN 1" f" WHEN {left_sql} IS NULL THEN 0" f" ELSE ({left_sql} IS NOT {lit_sql}) END)" ) return sql, lit_params # $eq/$ne with a None literal: use IS / IS NOT. # Also handle missing-field-vs-null distinction via json_type: # a missing JSON key (json_type IS NULL) is NOT equal to null. if operator in ("$eq", "$ne") and ( operands[0] is None or operands[1] is None ): field_op = operands[1] if operands[0] is None else operands[0] field_sql = right_sql if operands[0] is None else left_sql field_params = right_params if operands[0] is None else left_params # Use json_type to distinguish missing key (returns SQL NULL) # from present null value (returns 'null'). # Only do this for simple $field references where the SQL # is a json/jsonb_extract call. both_fields = ( isinstance(operands[0], str) and operands[0].startswith("$") and isinstance(field_op, str) and field_op.startswith("$") ) if both_fields and operator == "$eq": # Both operands are fields: missing-vs-missing compares as # equal (both absent) — Python tier semantics (#121). Escape # the path via parse_json_path instead of raw concatenation. from ...json_path_utils import parse_json_path p1 = "'" + parse_json_path(field_op[1:]) + "'" p2 = "'" + parse_json_path(operands[0][1:]) + "'" t1 = f"json_type({self.data_column}, {p1})" t2 = f"json_type({self.data_column}, {p2})" sql = ( f"(CASE WHEN {t1} IS NULL AND {t2} IS NULL THEN 1" f" WHEN {t1} IS NOT NULL AND {t2} IS NOT NULL" f" THEN CASE WHEN {field_sql} IS {right_sql} THEN 1 ELSE 0 END" f" ELSE 0 END)" ) return sql, field_params if ( isinstance(field_op, str) and field_op.startswith("$") and ( "json_extract" in field_sql or "jsonb_extract" in field_sql ) ): from ...json_path_utils import parse_json_path safe_path = "'" + parse_json_path(field_op[1:]) + "'" # $eq: missing->0, null->1, else->0 # $ne: missing->1, null->0, else->1 m_val = "0" if operator == "$eq" else "1" n_val = "1" if operator == "$eq" else "0" e_val = "0" if operator == "$eq" else "1" sql = ( f"(CASE WHEN json_type({self.data_column}," f"{safe_path}) IS NULL THEN {m_val}" f" WHEN {field_sql} IS NULL THEN {n_val}" f" ELSE {e_val} END)" ) return sql, field_params # Fallback: simple IS / IS NOT (can't distinguish missing from null) sql_operator = "IS" if operator == "$eq" else "IS NOT" return ( f"{left_sql} {sql_operator} {right_sql}", left_params + right_params, ) sql_operator = self._map_comparison_operator(operator) return ( f"{left_sql} {sql_operator} {right_sql}", left_params + right_params, )
[docs] def _convert_cmp_operator( self, operands: list[Any] ) -> tuple[str, list[Any]]: """Convert $cmp operator to SQL CASE statement.""" if len(operands) != 2: raise ValueError("$cmp requires exactly 2 operands") left_sql, left_params = self._convert_operand_to_sql(operands[0]) right_sql, right_params = self._convert_operand_to_sql(operands[1]) sql = f"(CASE WHEN {left_sql} < {right_sql} THEN -1 WHEN {left_sql} > {right_sql} THEN 1 ELSE 0 END)" # Both fragments appear twice; duplicate params in SQL order: # left, right, left, right. return sql, left_params + right_params + left_params + right_params