Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,36 @@ translator can't handle — typically a REGEX pattern the active dialect's
regex flavour can't compile. Callers should fall back to
`flag_engine.is_context_in_segment` for those segments.

## Bound parameters

By default the translator inlines each segment value as an escaped SQL string literal. Pass a `Binder` on the `TranslateContext` to bind value-bearing literals as query parameters instead.

```python
from flagsmith_sql_flag_engine import (
Binder,
PyformatParamStyle,
TranslateContext,
translate_segment,
)
from flagsmith_sql_flag_engine.dialects import ClickHouseDialect

binder = Binder(PyformatParamStyle())
ctx = TranslateContext(
evaluation_context=eval_context,
dialect=ClickHouseDialect(),
binder=binder,
)
where_expr = translate_segment(segment, ctx)
```

Hand both to the driver:

```python
cursor.execute(f"... WHERE ({where_expr})", binder.params)
```

Currently, `%`-prefixed style `PyformatParamStyle` and ClickHouse-specific `ClickHouseServerParamStyle` are supported.

## Schema

Each dialect publishes the table layout it expects via a `schema_ddl`
Expand Down
15 changes: 15 additions & 0 deletions src/flagsmith_sql_flag_engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,22 @@
translate_segment(segment, ctx) -> str | None
TranslateContext

By default the translator inlines each segment value as an escaped SQL
string literal. Pass a `Binder` on the `TranslateContext` to bind
value-bearing literals as query parameters instead — read its params off
`Binder.params` after translation. See `flagsmith_sql_flag_engine.binder`.

See README.md for usage. The translator is dialect-aware via the `Dialect`
protocol; `flagsmith_sql_flag_engine.dialects.clickhouse.ClickHouseDialect`
is the only implementation today.
"""

from flagsmith_sql_flag_engine.binder import (
Binder,
ClickHouseServerParamStyle,
ParamStyle,
PyformatParamStyle,
)
from flagsmith_sql_flag_engine.dialect import Dialect
from flagsmith_sql_flag_engine.translator import (
TRANSLATABLE_OPERATORS,
Expand All @@ -20,7 +31,11 @@

__all__ = [
"TRANSLATABLE_OPERATORS",
"Binder",
"ClickHouseServerParamStyle",
"Dialect",
"ParamStyle",
"PyformatParamStyle",
"TranslateContext",
"translate_condition",
"translate_rule",
Expand Down
50 changes: 50 additions & 0 deletions src/flagsmith_sql_flag_engine/binder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from typing import Protocol


class ParamStyle(Protocol):
"""A driver's placeholder syntax for a named bound parameter."""

def placeholder(self, name: str) -> str:
"""The placeholder token referencing bound parameter `name`."""
...


class PyformatParamStyle:
"""`%(name)s`

Used by `clickhouse-driver` which substitutes parameters
client-side via `query % params`."""

def placeholder(self, name: str) -> str:
return f"%({name})s"


class ClickHouseServerParamStyle:
"""`{name:String}`

ClickHouse's native server-side parameter syntax,
used by `clickhouse-connect`."""

def placeholder(self, name: str) -> str:
return "{" + name + ":String}"


class Binder:
"""Collects bound parameter values and mints their placeholders.

Not thread-safe; use one `Binder` per predicate translation.
"""

def __init__(self, style: ParamStyle, prefix: str = "") -> None:
self.params: dict[str, str] = {}
self._style = style
self._prefix = prefix
self._count = 0

def add(self, value: str) -> str:
"""Record `value` under a fresh namespaced name and return its
placeholder token for the active paramstyle."""
name = f"{self._prefix}p{self._count}"
self._count += 1
self.params[name] = value
return self._style.placeholder(name)
36 changes: 30 additions & 6 deletions src/flagsmith_sql_flag_engine/dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@

from typing import Protocol

from flagsmith_sql_flag_engine.binder import Binder


class Dialect(Protocol):
"""Per-dialect SQL fragments.

Methods return SQL string fragments. Inputs are already-formatted SQL
strings (column refs, string literals); the dialect only chooses the
right syntax for the operation.

Methods that embed a segment- or context-derived value take an
optional `binder`: when provided, the value is emitted as a bound
query parameter rather than an inline literal.
"""

name: str # human-readable, used in test ids and error messages
Expand All @@ -35,7 +41,14 @@ def trait_path(self, alias: str, trait_key: str) -> str:
"""
...

def trait_eq(self, alias: str, trait_key: str, value: object, negate: bool) -> str:
def trait_eq(
self,
alias: str,
trait_key: str,
value: object,
negate: bool,
binder: Binder | None = None,
) -> str:
"""Type-aware EQUAL / NOT_EQUAL predicate on a trait, mirroring
`flag_engine`'s per-type coercion: the segment value is cast to
the trait's runtime type before compare, and a cast failure
Expand All @@ -45,7 +58,13 @@ def trait_eq(self, alias: str, trait_key: str, value: object, negate: bool) -> s
"""
...

def trait_in(self, alias: str, trait_key: str, items: list[str]) -> str:
def trait_in(
self,
alias: str,
trait_key: str,
items: list[str],
binder: Binder | None = None,
) -> str:
"""Type-aware IN predicate on a trait, mirroring engine semantics:
string trait does direct lookup; integer trait stringifies and
looks up; other trait types never match. `items` is the parsed
Expand Down Expand Up @@ -77,14 +96,19 @@ def regex_supports(self, pattern: str) -> bool:
to `flag_engine`."""
...

def regexp_anchored_match(self, value_expr: str, pattern: str) -> str:
def regexp_anchored_match(
self,
value_expr: str,
pattern: str,
binder: Binder | None = None,
) -> str:
"""Boolean: equivalent to Python `re.match(pattern, value)` —
anchored at position 0, may be a prefix of the value, not a
full-match.

`pattern` is the raw Python regex string; the dialect handles
its own escaping into a SQL literal, since regex flavours
differ in how backslashes are treated."""
`pattern` is the raw Python regex string. With no `binder`, the
dialect handles its own escaping into a SQL literal, since regex
flavours differ in how backslashes are treated."""
...

def regexp_nth_digit_run(self, value_expr: str, n: int) -> str:
Expand Down
36 changes: 29 additions & 7 deletions src/flagsmith_sql_flag_engine/dialects/clickhouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@
ClickHouse Cloud as of 25.12 (no longer experimental on OSS 25.x).
Callers should apply this setting at session creation."""

from flagsmith_sql_flag_engine.utils import re2_safe, string_literal
from flagsmith_sql_flag_engine.binder import Binder
from flagsmith_sql_flag_engine.utils import bind_or_inline, re2_safe

SCHEMA_DDL = """\
CREATE TABLE IF NOT EXISTS IDENTITIES (
Expand Down Expand Up @@ -154,10 +155,17 @@ def trait_path(self, alias: str, trait_key: str) -> str:
sub = self._sub(alias, trait_key)
return f"if({sub} IS NULL, NULL, toString({sub}))"

def trait_eq(self, alias: str, trait_key: str, value: object, negate: bool) -> str:
def trait_eq(
self,
alias: str,
trait_key: str,
value: object,
negate: bool,
binder: Binder | None = None,
) -> str:
sub = self._sub(alias, trait_key)
str_value = str(value)
str_lit = string_literal(str_value)
str_lit = bind_or_inline(binder, str_value)
# Engine bool cast: `v not in ("False", "false")`. A JSON true matches
# every segment value except literal "False" / "false"; those two coerce
# to False and match a JSON false.
Expand Down Expand Up @@ -223,7 +231,13 @@ def trait_eq(self, alias: str, trait_key: str, value: object, negate: bool) -> s
f"(({str_sub} IS NOT NULL AND {str_sub} <> {str_lit}) OR {bool_branch} OR {num_branch})"
)

def trait_in(self, alias: str, trait_key: str, items: list[str]) -> str:
def trait_in(
self,
alias: str,
trait_key: str,
items: list[str],
binder: Binder | None = None,
) -> str:
# `toString(<sub>)` returns the canonical string form for any JSON
# value type in a single subcolumn read. Engine semantics only
# match String and integer trait types — bool / float / array
Expand All @@ -235,7 +249,7 @@ def trait_in(self, alias: str, trait_key: str, items: list[str]) -> str:
bool_sub = f"{sub}.:Bool"
float_sub = f"{sub}.:Float64"
str_path = f"toString({sub})"
item_lits = ",".join(string_literal(v) for v in items)
item_lits = ",".join(bind_or_inline(binder, v) for v in items)
return f"({bool_sub} IS NULL AND {float_sub} IS NULL AND {str_path} IN ({item_lits}))"

# ----- string operations -----
Expand Down Expand Up @@ -267,13 +281,21 @@ def _regex_literal(pattern: str) -> str:
doubled = pattern.replace("\\", "\\\\").replace("'", "''")
return f"'{doubled}'"

def regexp_anchored_match(self, value_expr: str, pattern: str) -> str:
def regexp_anchored_match(
self, value_expr: str, pattern: str, binder: Binder | None = None
) -> str:
# `match` is RE2 but unanchored — equivalent to `re.search`. Prepend
# `^` to get `re.match` semantics (start-anchored, prefix-allowed).
# Wrapping in `(...)` keeps the user's top-level alternation from
# binding tighter than the anchor.
anchored = "^(" + pattern + ")"
return f"match({_non_null(value_expr)}, {self._regex_literal(anchored)})"
# Bind the raw pattern when a binder is active: the driver escapes
# it, and — crucially — no `%` from a character class like
# `[a-z%]` lands in the query text to trip a `%`-substituting
# driver. Inline, `_regex_literal` doubles backslashes so RE2 sees
# the pattern the segment author wrote.
pattern_lit = binder.add(anchored) if binder is not None else self._regex_literal(anchored)
return f"match({_non_null(value_expr)}, {pattern_lit})"

def regexp_nth_digit_run(self, value_expr: str, n: int) -> str:
# `extractAll` returns the matches array; subscript is 1-indexed
Expand Down
Loading
Loading