flagsmith-sql-flag-engine 0.1.3__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flagsmith-sql-flag-engine
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: SQL translator for Flagsmith segment predicates.
5
5
  Author: Flagsmith
6
6
  Author-email: Flagsmith <engineering@flagsmith.com>
@@ -66,6 +66,36 @@ translator can't handle — typically a REGEX pattern the active dialect's
66
66
  regex flavour can't compile. Callers should fall back to
67
67
  `flag_engine.is_context_in_segment` for those segments.
68
68
 
69
+ ## Bound parameters
70
+
71
+ 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.
72
+
73
+ ```python
74
+ from flagsmith_sql_flag_engine import (
75
+ Binder,
76
+ PyformatParamStyle,
77
+ TranslateContext,
78
+ translate_segment,
79
+ )
80
+ from flagsmith_sql_flag_engine.dialects import ClickHouseDialect
81
+
82
+ binder = Binder(PyformatParamStyle())
83
+ ctx = TranslateContext(
84
+ evaluation_context=eval_context,
85
+ dialect=ClickHouseDialect(),
86
+ binder=binder,
87
+ )
88
+ where_expr = translate_segment(segment, ctx)
89
+ ```
90
+
91
+ Hand both to the driver:
92
+
93
+ ```python
94
+ cursor.execute(f"... WHERE ({where_expr})", binder.params)
95
+ ```
96
+
97
+ Currently, `%`-prefixed style `PyformatParamStyle` and ClickHouse-specific `ClickHouseServerParamStyle` are supported.
98
+
69
99
  ## Schema
70
100
 
71
101
  Each dialect publishes the table layout it expects via a `schema_ddl`
@@ -50,6 +50,36 @@ translator can't handle — typically a REGEX pattern the active dialect's
50
50
  regex flavour can't compile. Callers should fall back to
51
51
  `flag_engine.is_context_in_segment` for those segments.
52
52
 
53
+ ## Bound parameters
54
+
55
+ 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.
56
+
57
+ ```python
58
+ from flagsmith_sql_flag_engine import (
59
+ Binder,
60
+ PyformatParamStyle,
61
+ TranslateContext,
62
+ translate_segment,
63
+ )
64
+ from flagsmith_sql_flag_engine.dialects import ClickHouseDialect
65
+
66
+ binder = Binder(PyformatParamStyle())
67
+ ctx = TranslateContext(
68
+ evaluation_context=eval_context,
69
+ dialect=ClickHouseDialect(),
70
+ binder=binder,
71
+ )
72
+ where_expr = translate_segment(segment, ctx)
73
+ ```
74
+
75
+ Hand both to the driver:
76
+
77
+ ```python
78
+ cursor.execute(f"... WHERE ({where_expr})", binder.params)
79
+ ```
80
+
81
+ Currently, `%`-prefixed style `PyformatParamStyle` and ClickHouse-specific `ClickHouseServerParamStyle` are supported.
82
+
53
83
  ## Schema
54
84
 
55
85
  Each dialect publishes the table layout it expects via a `schema_ddl`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flagsmith-sql-flag-engine"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "SQL translator for Flagsmith segment predicates."
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Flagsmith", email = "engineering@flagsmith.com" }]
@@ -4,11 +4,22 @@ Public API:
4
4
  translate_segment(segment, ctx) -> str | None
5
5
  TranslateContext
6
6
 
7
+ By default the translator inlines each segment value as an escaped SQL
8
+ string literal. Pass a `Binder` on the `TranslateContext` to bind
9
+ value-bearing literals as query parameters instead — read its params off
10
+ `Binder.params` after translation. See `flagsmith_sql_flag_engine.binder`.
11
+
7
12
  See README.md for usage. The translator is dialect-aware via the `Dialect`
8
13
  protocol; `flagsmith_sql_flag_engine.dialects.clickhouse.ClickHouseDialect`
9
14
  is the only implementation today.
10
15
  """
11
16
 
17
+ from flagsmith_sql_flag_engine.binder import (
18
+ Binder,
19
+ ClickHouseServerParamStyle,
20
+ ParamStyle,
21
+ PyformatParamStyle,
22
+ )
12
23
  from flagsmith_sql_flag_engine.dialect import Dialect
13
24
  from flagsmith_sql_flag_engine.translator import (
14
25
  TRANSLATABLE_OPERATORS,
@@ -20,7 +31,11 @@ from flagsmith_sql_flag_engine.translator import (
20
31
 
21
32
  __all__ = [
22
33
  "TRANSLATABLE_OPERATORS",
34
+ "Binder",
35
+ "ClickHouseServerParamStyle",
23
36
  "Dialect",
37
+ "ParamStyle",
38
+ "PyformatParamStyle",
24
39
  "TranslateContext",
25
40
  "translate_condition",
26
41
  "translate_rule",
@@ -0,0 +1,50 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class ParamStyle(Protocol):
5
+ """A driver's placeholder syntax for a named bound parameter."""
6
+
7
+ def placeholder(self, name: str) -> str:
8
+ """The placeholder token referencing bound parameter `name`."""
9
+ ...
10
+
11
+
12
+ class PyformatParamStyle:
13
+ """`%(name)s`
14
+
15
+ Used by `clickhouse-driver` which substitutes parameters
16
+ client-side via `query % params`."""
17
+
18
+ def placeholder(self, name: str) -> str:
19
+ return f"%({name})s"
20
+
21
+
22
+ class ClickHouseServerParamStyle:
23
+ """`{name:String}`
24
+
25
+ ClickHouse's native server-side parameter syntax,
26
+ used by `clickhouse-connect`."""
27
+
28
+ def placeholder(self, name: str) -> str:
29
+ return "{" + name + ":String}"
30
+
31
+
32
+ class Binder:
33
+ """Collects bound parameter values and mints their placeholders.
34
+
35
+ Not thread-safe; use one `Binder` per predicate translation.
36
+ """
37
+
38
+ def __init__(self, style: ParamStyle, prefix: str = "") -> None:
39
+ self.params: dict[str, str] = {}
40
+ self._style = style
41
+ self._prefix = prefix
42
+ self._count = 0
43
+
44
+ def add(self, value: str) -> str:
45
+ """Record `value` under a fresh namespaced name and return its
46
+ placeholder token for the active paramstyle."""
47
+ name = f"{self._prefix}p{self._count}"
48
+ self._count += 1
49
+ self.params[name] = value
50
+ return self._style.placeholder(name)
@@ -3,6 +3,8 @@ regex, padded-version comparison, type-aware trait predicates, regex flavour."""
3
3
 
4
4
  from typing import Protocol
5
5
 
6
+ from flagsmith_sql_flag_engine.binder import Binder
7
+
6
8
 
7
9
  class Dialect(Protocol):
8
10
  """Per-dialect SQL fragments.
@@ -10,6 +12,10 @@ class Dialect(Protocol):
10
12
  Methods return SQL string fragments. Inputs are already-formatted SQL
11
13
  strings (column refs, string literals); the dialect only chooses the
12
14
  right syntax for the operation.
15
+
16
+ Methods that embed a segment- or context-derived value take an
17
+ optional `binder`: when provided, the value is emitted as a bound
18
+ query parameter rather than an inline literal.
13
19
  """
14
20
 
15
21
  name: str # human-readable, used in test ids and error messages
@@ -35,7 +41,14 @@ class Dialect(Protocol):
35
41
  """
36
42
  ...
37
43
 
38
- def trait_eq(self, alias: str, trait_key: str, value: object, negate: bool) -> str:
44
+ def trait_eq(
45
+ self,
46
+ alias: str,
47
+ trait_key: str,
48
+ value: object,
49
+ negate: bool,
50
+ binder: Binder | None = None,
51
+ ) -> str:
39
52
  """Type-aware EQUAL / NOT_EQUAL predicate on a trait, mirroring
40
53
  `flag_engine`'s per-type coercion: the segment value is cast to
41
54
  the trait's runtime type before compare, and a cast failure
@@ -45,7 +58,13 @@ class Dialect(Protocol):
45
58
  """
46
59
  ...
47
60
 
48
- def trait_in(self, alias: str, trait_key: str, items: list[str]) -> str:
61
+ def trait_in(
62
+ self,
63
+ alias: str,
64
+ trait_key: str,
65
+ items: list[str],
66
+ binder: Binder | None = None,
67
+ ) -> str:
49
68
  """Type-aware IN predicate on a trait, mirroring engine semantics:
50
69
  string trait does direct lookup; integer trait stringifies and
51
70
  looks up; other trait types never match. `items` is the parsed
@@ -77,14 +96,19 @@ class Dialect(Protocol):
77
96
  to `flag_engine`."""
78
97
  ...
79
98
 
80
- def regexp_anchored_match(self, value_expr: str, pattern: str) -> str:
99
+ def regexp_anchored_match(
100
+ self,
101
+ value_expr: str,
102
+ pattern: str,
103
+ binder: Binder | None = None,
104
+ ) -> str:
81
105
  """Boolean: equivalent to Python `re.match(pattern, value)` —
82
106
  anchored at position 0, may be a prefix of the value, not a
83
107
  full-match.
84
108
 
85
- `pattern` is the raw Python regex string; the dialect handles
86
- its own escaping into a SQL literal, since regex flavours
87
- differ in how backslashes are treated."""
109
+ `pattern` is the raw Python regex string. With no `binder`, the
110
+ dialect handles its own escaping into a SQL literal, since regex
111
+ flavours differ in how backslashes are treated."""
88
112
  ...
89
113
 
90
114
  def regexp_nth_digit_run(self, value_expr: str, n: int) -> str:
@@ -76,7 +76,8 @@ typical Flagsmith trait vocabularies; we should monitor.
76
76
  ClickHouse Cloud as of 25.12 (no longer experimental on OSS 25.x).
77
77
  Callers should apply this setting at session creation."""
78
78
 
79
- from flagsmith_sql_flag_engine.utils import re2_safe, string_literal
79
+ from flagsmith_sql_flag_engine.binder import Binder
80
+ from flagsmith_sql_flag_engine.utils import bind_or_inline, re2_safe
80
81
 
81
82
  SCHEMA_DDL = """\
82
83
  CREATE TABLE IF NOT EXISTS IDENTITIES (
@@ -154,10 +155,17 @@ class ClickHouseDialect:
154
155
  sub = self._sub(alias, trait_key)
155
156
  return f"if({sub} IS NULL, NULL, toString({sub}))"
156
157
 
157
- def trait_eq(self, alias: str, trait_key: str, value: object, negate: bool) -> str:
158
+ def trait_eq(
159
+ self,
160
+ alias: str,
161
+ trait_key: str,
162
+ value: object,
163
+ negate: bool,
164
+ binder: Binder | None = None,
165
+ ) -> str:
158
166
  sub = self._sub(alias, trait_key)
159
167
  str_value = str(value)
160
- str_lit = string_literal(str_value)
168
+ str_lit = bind_or_inline(binder, str_value)
161
169
  # Engine bool cast: `v not in ("False", "false")`. A JSON true matches
162
170
  # every segment value except literal "False" / "false"; those two coerce
163
171
  # to False and match a JSON false.
@@ -223,7 +231,13 @@ class ClickHouseDialect:
223
231
  f"(({str_sub} IS NOT NULL AND {str_sub} <> {str_lit}) OR {bool_branch} OR {num_branch})"
224
232
  )
225
233
 
226
- def trait_in(self, alias: str, trait_key: str, items: list[str]) -> str:
234
+ def trait_in(
235
+ self,
236
+ alias: str,
237
+ trait_key: str,
238
+ items: list[str],
239
+ binder: Binder | None = None,
240
+ ) -> str:
227
241
  # `toString(<sub>)` returns the canonical string form for any JSON
228
242
  # value type in a single subcolumn read. Engine semantics only
229
243
  # match String and integer trait types — bool / float / array
@@ -235,7 +249,7 @@ class ClickHouseDialect:
235
249
  bool_sub = f"{sub}.:Bool"
236
250
  float_sub = f"{sub}.:Float64"
237
251
  str_path = f"toString({sub})"
238
- item_lits = ",".join(string_literal(v) for v in items)
252
+ item_lits = ",".join(bind_or_inline(binder, v) for v in items)
239
253
  return f"({bool_sub} IS NULL AND {float_sub} IS NULL AND {str_path} IN ({item_lits}))"
240
254
 
241
255
  # ----- string operations -----
@@ -267,13 +281,21 @@ class ClickHouseDialect:
267
281
  doubled = pattern.replace("\\", "\\\\").replace("'", "''")
268
282
  return f"'{doubled}'"
269
283
 
270
- def regexp_anchored_match(self, value_expr: str, pattern: str) -> str:
284
+ def regexp_anchored_match(
285
+ self, value_expr: str, pattern: str, binder: Binder | None = None
286
+ ) -> str:
271
287
  # `match` is RE2 but unanchored — equivalent to `re.search`. Prepend
272
288
  # `^` to get `re.match` semantics (start-anchored, prefix-allowed).
273
289
  # Wrapping in `(...)` keeps the user's top-level alternation from
274
290
  # binding tighter than the anchor.
275
291
  anchored = "^(" + pattern + ")"
276
- return f"match({_non_null(value_expr)}, {self._regex_literal(anchored)})"
292
+ # Bind the raw pattern when a binder is active: the driver escapes
293
+ # it, and — crucially — no `%` from a character class like
294
+ # `[a-z%]` lands in the query text to trip a `%`-substituting
295
+ # driver. Inline, `_regex_literal` doubles backslashes so RE2 sees
296
+ # the pattern the segment author wrote.
297
+ pattern_lit = binder.add(anchored) if binder is not None else self._regex_literal(anchored)
298
+ return f"match({_non_null(value_expr)}, {pattern_lit})"
277
299
 
278
300
  def regexp_nth_digit_run(self, value_expr: str, n: int) -> str:
279
301
  # `extractAll` returns the matches array; subscript is 1-indexed
@@ -22,12 +22,12 @@ from flag_engine.context.types import (
22
22
  from flag_engine.segments.evaluator import is_context_in_segment
23
23
  from flag_engine.segments.types import ConditionOperator
24
24
 
25
+ from flagsmith_sql_flag_engine.binder import Binder
25
26
  from flagsmith_sql_flag_engine.dialect import Dialect
26
27
  from flagsmith_sql_flag_engine.utils import (
27
- escape_string,
28
+ bind_or_inline,
28
29
  modulo_literal,
29
30
  numeric_literal,
30
- string_literal,
31
31
  )
32
32
 
33
33
  TRANSLATABLE_OPERATORS: frozenset[ConditionOperator] = frozenset(
@@ -75,7 +75,10 @@ class TranslateContext:
75
75
  being configured here. `identities_alias` is the table alias for
76
76
  `IDENTITIES` in the surrounding query — defaults to `i`.
77
77
  `segment_key` salts `PERCENTAGE_SPLIT` and is auto-injected from
78
- the segment's `key` field by `translate_segment`.
78
+ the segment's `key` field by `translate_segment`. `binder`, when
79
+ provided, promotes value-bearing literals to bound query parameters
80
+ instead of inlining them — see `flagsmith_sql_flag_engine.binder`;
81
+ the default `None` inlines them as escaped SQL string literals.
79
82
  """
80
83
 
81
84
  def __init__(
@@ -84,11 +87,13 @@ class TranslateContext:
84
87
  dialect: Dialect,
85
88
  identities_alias: str = "i",
86
89
  segment_key: str | None = None,
90
+ binder: Binder | None = None,
87
91
  ) -> None:
88
92
  self.evaluation_context = evaluation_context
89
93
  self.dialect = dialect
90
94
  self.identities_alias = identities_alias
91
95
  self.segment_key = segment_key
96
+ self.binder = binder
92
97
 
93
98
  @property
94
99
  def identity_key_expr(self) -> str:
@@ -114,6 +119,7 @@ class TranslateContext:
114
119
  dialect=self.dialect,
115
120
  identities_alias=self.identities_alias,
116
121
  segment_key=key,
122
+ binder=self.binder,
117
123
  )
118
124
 
119
125
 
@@ -133,7 +139,7 @@ def _percentage_split_expr(
133
139
  the engine recurses with doubled input; we don't.
134
140
  """
135
141
  d = ctx.dialect
136
- seg_lit = string_literal(seg_key)
142
+ seg_lit = bind_or_inline(ctx.binder, seg_key)
137
143
  hash_subject = f"{seg_lit} || ',' || ({ctx_value_sql})"
138
144
  h = d.md5_hex(hash_subject)
139
145
  s1 = d.parse_hex_chunk(h, 1)
@@ -289,18 +295,19 @@ def _comparison(
289
295
  if value is None:
290
296
  return "FALSE"
291
297
  d = ctx.dialect
292
- lit = string_literal(str(value))
293
298
  str_expr = expr if is_jsonpath else d.cast_string(expr)
299
+ # Bind the operand lazily
294
300
  if op == "EQUAL":
295
- return f"{str_expr} = {lit}"
301
+ return f"{str_expr} = {bind_or_inline(ctx.binder, str(value))}"
296
302
  if op == "NOT_EQUAL":
297
- return f"{str_expr} <> {lit}"
303
+ return f"{str_expr} <> {bind_or_inline(ctx.binder, str(value))}"
298
304
  if op == "IN":
299
- items = "','".join(escape_string(v.strip()) for v in str(value).split(","))
300
- return f"{str_expr} IN ('{items}')"
305
+ items = ",".join(bind_or_inline(ctx.binder, v.strip()) for v in str(value).split(","))
306
+ return f"{str_expr} IN ({items})"
301
307
  if op == "CONTAINS":
302
- return d.position(lit, str_expr)
308
+ return d.position(bind_or_inline(ctx.binder, str(value)), str_expr)
303
309
  if op == "NOT_CONTAINS":
310
+ lit = bind_or_inline(ctx.binder, str(value))
304
311
  return f"({expr} IS NOT NULL AND NOT ({d.position(lit, str_expr)}))"
305
312
  if op in {"GREATER_THAN", "LESS_THAN", "GREATER_THAN_INCLUSIVE", "LESS_THAN_INCLUSIVE"}:
306
313
  numeric_lit = numeric_literal(value)
@@ -327,7 +334,7 @@ def _comparison(
327
334
  pattern = str(value)
328
335
  if not d.regex_supports(pattern):
329
336
  return None
330
- return f"({expr} IS NOT NULL AND {d.regexp_anchored_match(str_expr, pattern)})"
337
+ return f"({expr} IS NOT NULL AND {d.regexp_anchored_match(str_expr, pattern, ctx.binder)})"
331
338
  raise AssertionError( # pragma: no cover - all TRANSLATABLE_OPERATORS handled above
332
339
  f"unhandled translatable operator in _comparison: {op}"
333
340
  )
@@ -369,7 +376,7 @@ def _translate_trait_op(
369
376
  # content, which is what the fall-through handlers already do.
370
377
  if isinstance(val, str) and val.endswith(":semver") and op in _SEMVER_OPS:
371
378
  bare = val[:-7]
372
- bare_lit = string_literal(bare)
379
+ bare_lit = bind_or_inline(ctx.binder, bare)
373
380
  col_str = ctx.dialect.cast_string(path)
374
381
  return (
375
382
  f"({path} IS NOT NULL AND "
@@ -382,7 +389,13 @@ def _translate_trait_op(
382
389
  # casts, and short-circuit pitfalls are all engine-specific.
383
390
  if op in {"EQUAL", "NOT_EQUAL"} and val is not None:
384
391
  negate = op == "NOT_EQUAL"
385
- eq_pred = ctx.dialect.trait_eq(ctx.identities_alias, trait_key, val, negate=negate)
392
+ eq_pred = ctx.dialect.trait_eq(
393
+ ctx.identities_alias,
394
+ trait_key,
395
+ val,
396
+ negate=negate,
397
+ binder=ctx.binder,
398
+ )
386
399
  return f"({path} IS NOT NULL AND {eq_pred})"
387
400
  if op == "IN":
388
401
  items = _engine_in_values(val)
@@ -390,7 +403,12 @@ def _translate_trait_op(
390
403
  # Bad IN value — neither a string nor a list. Engine returns
391
404
  # False.
392
405
  return "FALSE"
393
- in_pred = ctx.dialect.trait_in(ctx.identities_alias, trait_key, items)
406
+ in_pred = ctx.dialect.trait_in(
407
+ ctx.identities_alias,
408
+ trait_key,
409
+ items,
410
+ binder=ctx.binder,
411
+ )
394
412
  return f"({path} IS NOT NULL AND {in_pred})"
395
413
 
396
414
  return _comparison(ctx, op, path, val, is_jsonpath=False)
@@ -21,6 +21,21 @@ on the `Dialect` protocol instead.
21
21
  """
22
22
 
23
23
  import re
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from flagsmith_sql_flag_engine.binder import Binder
28
+
29
+
30
+ def bind_or_inline(binder: "Binder | None", value: str) -> str:
31
+ """Return a bound-parameter placeholder for `value` when a `binder` is
32
+ active, else a single-quoted SQL string literal.
33
+
34
+ The single seam that lets a value-bearing literal either be inlined
35
+ (the default) or promoted to a query parameter, chosen per call site
36
+ by whether the caller threaded a `Binder` onto the context.
37
+ """
38
+ return binder.add(value) if binder is not None else string_literal(value)
24
39
 
25
40
 
26
41
  def escape_string(value: str) -> str: