dotted-notation 0.43.3__tar.gz → 0.43.5__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.
Files changed (79) hide show
  1. {dotted_notation-0.43.3/dotted_notation.egg-info → dotted_notation-0.43.5}/PKG-INFO +33 -7
  2. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/README.md +32 -6
  3. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/__init__.py +6 -2
  4. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/access.py +16 -0
  5. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/api.py +88 -0
  6. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/base.py +6 -0
  7. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/filters.py +89 -0
  8. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/groups.py +10 -0
  9. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/matchers.py +5 -1
  10. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/sqlize.py +52 -59
  11. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/wrappers.py +50 -0
  12. {dotted_notation-0.43.3 → dotted_notation-0.43.5/dotted_notation.egg-info}/PKG-INFO +33 -7
  13. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/setup.py +1 -1
  14. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_api.py +109 -0
  15. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_named_subst.py +74 -0
  16. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_sqlize.py +33 -31
  17. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/LICENSE +0 -0
  18. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/__main__.py +0 -0
  19. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/__init__.py +0 -0
  20. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/_compat.py +0 -0
  21. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/formats.py +0 -0
  22. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/main.py +0 -0
  23. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/containers.py +0 -0
  24. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/engine.py +0 -0
  25. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/grammar.py +0 -0
  26. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/predicates.py +0 -0
  27. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/recursive.py +0 -0
  28. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/results.py +0 -0
  29. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/transforms.py +0 -0
  30. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/utils.py +0 -0
  31. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/utypes.py +0 -0
  32. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/SOURCES.txt +0 -0
  33. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/dependency_links.txt +0 -0
  34. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/entry_points.txt +0 -0
  35. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/requires.txt +0 -0
  36. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/top_level.txt +0 -0
  37. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/setup.cfg +0 -0
  38. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/__init__.py +0 -0
  39. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_appender.py +0 -0
  40. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_assemble.py +0 -0
  41. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_attrs.py +0 -0
  42. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_bindings.py +0 -0
  43. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_cli.py +0 -0
  44. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_concat.py +0 -0
  45. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_container_filter.py +0 -0
  46. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_cut.py +0 -0
  47. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_empty.py +0 -0
  48. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_filter_keyvalue.py +0 -0
  49. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_get.py +0 -0
  50. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_guard_transforms.py +0 -0
  51. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_invert.py +0 -0
  52. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_json_sentinels.py +0 -0
  53. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_keys_values.py +0 -0
  54. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_match.py +0 -0
  55. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_matchable.py +0 -0
  56. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_negation.py +0 -0
  57. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_nop.py +0 -0
  58. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_numeric.py +0 -0
  59. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_opgroup.py +0 -0
  60. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_pluck.py +0 -0
  61. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_predicates.py +0 -0
  62. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_quote_idempotent.py +0 -0
  63. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_recursive.py +0 -0
  64. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_reference.py +0 -0
  65. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_replace.py +0 -0
  66. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_slice.py +0 -0
  67. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_softcut.py +0 -0
  68. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_strict.py +0 -0
  69. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_string_glob.py +0 -0
  70. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_subst_escape.py +0 -0
  71. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_subst_transforms.py +0 -0
  72. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_threading.py +0 -0
  73. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_transforms.py +0 -0
  74. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_translate.py +0 -0
  75. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_type_restriction.py +0 -0
  76. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_unpack.py +0 -0
  77. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_update.py +0 -0
  78. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_update_if.py +0 -0
  79. {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_value_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.43.3
3
+ Version: 0.43.5
4
4
  Summary: Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
5
5
  Home-page: https://github.com/freywaid/dotted
6
6
  Author: Frey Waid
@@ -244,6 +244,14 @@ Several Python libraries handle nested data access. Here's how dotted compares:
244
244
  <a id="breaking-changes"></a>
245
245
  ## Breaking Changes
246
246
 
247
+ ### v0.43.4
248
+ - **`is_pattern` no longer returns `True` for substitutions**. `Subst` previously
249
+ inherited from `Pattern` in the class hierarchy, so any path containing a
250
+ substitution in an access position (e.g. `a.$(x)`, `a.$0`) reported as a
251
+ pattern. Substitutions resolve to a single key, not a set — that was a
252
+ misclassification. Use `is_template(path)` to check "contains a
253
+ substitution."
254
+
247
255
  ### v0.40.0
248
256
  - **`unpack()` now returns a `dict`**: Previously returned a tuple of
249
257
  `(path, value)` pairs. Now returns `{path: value, ...}` directly.
@@ -2863,18 +2871,36 @@ time:
2863
2871
 
2864
2872
  >>> r = dotted.sqlize("age >= $(min_age)")
2865
2873
  >>> r
2866
- {'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
2874
+ {'select': 'age', 'where': 'age >= :min_age', 'params': {},
2875
+ 'unbound': {'min_age': 'min_age'}}
2876
+
2877
+ `unbound` is a dict keyed by the **SQL bind parameter name** — the name
2878
+ that appears in the SQL `:placeholder` and as a key in `params`. Each
2879
+ value is the **original substitution name** (what was inside `$(...)`),
2880
+ kept as provenance so callers can trace a bind back to its source.
2867
2881
 
2868
- `missing` lists names that still need a value. Fill them in before passing
2869
- `params` to a driver:
2882
+ `unbound.keys()` is the list of bind slots the caller needs to fill.
2883
+ Most often you just fill `params` by bind name directly:
2870
2884
 
2871
2885
  >>> r['params']['min_age'] = 30
2872
2886
  >>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
2873
2887
 
2874
- Repeated uses of the same name share one placeholder automatically:
2888
+ Non-identifier names (dotted paths, quoted keys, spaces, etc.) hash to a
2889
+ deterministic `_s_<hex>` bind name — the bind-side name is different
2890
+ from what appeared in source, but the original is preserved in the
2891
+ `unbound` value:
2892
+
2893
+ >>> r = dotted.sqlize('age >= $(user.min_age)')
2894
+ >>> r['where'] # doctest: +ELLIPSIS
2895
+ 'age >= :_s_...'
2896
+ >>> list(r['unbound'].values())
2897
+ ['user.min_age']
2898
+
2899
+ Repeated uses of the same name share one entry automatically:
2875
2900
 
2876
2901
  >>> dotted.sqlize('(age >= $(x) & weight = $(x))')
2877
- {'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
2902
+ {'where': '(age >= :x) AND (weight = :x)', 'params': {},
2903
+ 'unbound': {'x': 'x'}}
2878
2904
 
2879
2905
  If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
2880
2906
  like any other literal:
@@ -2902,7 +2928,7 @@ Returned dict may contain any subset of:
2902
2928
  | `select` | `str` | Expression yielding the value dotted would return |
2903
2929
  | `where` | `str` | Predicate from guards / filters / boolean groups |
2904
2930
  | `params` | `dict` | Hoisted values keyed by name |
2905
- | `missing` | `list[str]` | Substitution names still awaiting a value |
2931
+ | `unbound` | `dict` | Deferred substitutions: `{bind_param_name: original_name}` |
2906
2932
 
2907
2933
  `select` is omitted when combining predicates across different columns
2908
2934
  (e.g. top-level groups), since there's no single meaningful expression.
@@ -207,6 +207,14 @@ Several Python libraries handle nested data access. Here's how dotted compares:
207
207
  <a id="breaking-changes"></a>
208
208
  ## Breaking Changes
209
209
 
210
+ ### v0.43.4
211
+ - **`is_pattern` no longer returns `True` for substitutions**. `Subst` previously
212
+ inherited from `Pattern` in the class hierarchy, so any path containing a
213
+ substitution in an access position (e.g. `a.$(x)`, `a.$0`) reported as a
214
+ pattern. Substitutions resolve to a single key, not a set — that was a
215
+ misclassification. Use `is_template(path)` to check "contains a
216
+ substitution."
217
+
210
218
  ### v0.40.0
211
219
  - **`unpack()` now returns a `dict`**: Previously returned a tuple of
212
220
  `(path, value)` pairs. Now returns `{path: value, ...}` directly.
@@ -2826,18 +2834,36 @@ time:
2826
2834
 
2827
2835
  >>> r = dotted.sqlize("age >= $(min_age)")
2828
2836
  >>> r
2829
- {'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
2837
+ {'select': 'age', 'where': 'age >= :min_age', 'params': {},
2838
+ 'unbound': {'min_age': 'min_age'}}
2839
+
2840
+ `unbound` is a dict keyed by the **SQL bind parameter name** — the name
2841
+ that appears in the SQL `:placeholder` and as a key in `params`. Each
2842
+ value is the **original substitution name** (what was inside `$(...)`),
2843
+ kept as provenance so callers can trace a bind back to its source.
2830
2844
 
2831
- `missing` lists names that still need a value. Fill them in before passing
2832
- `params` to a driver:
2845
+ `unbound.keys()` is the list of bind slots the caller needs to fill.
2846
+ Most often you just fill `params` by bind name directly:
2833
2847
 
2834
2848
  >>> r['params']['min_age'] = 30
2835
2849
  >>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
2836
2850
 
2837
- Repeated uses of the same name share one placeholder automatically:
2851
+ Non-identifier names (dotted paths, quoted keys, spaces, etc.) hash to a
2852
+ deterministic `_s_<hex>` bind name — the bind-side name is different
2853
+ from what appeared in source, but the original is preserved in the
2854
+ `unbound` value:
2855
+
2856
+ >>> r = dotted.sqlize('age >= $(user.min_age)')
2857
+ >>> r['where'] # doctest: +ELLIPSIS
2858
+ 'age >= :_s_...'
2859
+ >>> list(r['unbound'].values())
2860
+ ['user.min_age']
2861
+
2862
+ Repeated uses of the same name share one entry automatically:
2838
2863
 
2839
2864
  >>> dotted.sqlize('(age >= $(x) & weight = $(x))')
2840
- {'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
2865
+ {'where': '(age >= :x) AND (weight = :x)', 'params': {},
2866
+ 'unbound': {'x': 'x'}}
2841
2867
 
2842
2868
  If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
2843
2869
  like any other literal:
@@ -2865,7 +2891,7 @@ Returned dict may contain any subset of:
2865
2891
  | `select` | `str` | Expression yielding the value dotted would return |
2866
2892
  | `where` | `str` | Predicate from guards / filters / boolean groups |
2867
2893
  | `params` | `dict` | Hoisted values keyed by name |
2868
- | `missing` | `list[str]` | Substitution names still awaiting a value |
2894
+ | `unbound` | `dict` | Deferred substitutions: `{bind_param_name: original_name}` |
2869
2895
 
2870
2896
  `select` is omitted when combining predicates across different columns
2871
2897
  (e.g. top-level groups), since there's no single meaningful expression.
@@ -56,7 +56,8 @@ For full documentation including all options and flags:
56
56
  or see README.md
57
57
  """
58
58
  from .api import \
59
- parse, is_pattern, is_template, is_inverted, mutable, quote, ANY, AUTO, Attrs, GroupMode, \
59
+ parse, is_pattern, is_template, is_reference, is_indeterminate, is_simple, \
60
+ is_inverted, mutable, quote, ANY, AUTO, Attrs, GroupMode, \
60
61
  register, transform, \
61
62
  assemble, assemble_multi, \
62
63
  build, build_multi, \
@@ -82,7 +83,10 @@ __all__ = [
82
83
  # Transform
83
84
  'apply', 'apply_multi', 'register', 'transform',
84
85
  # Utility
85
- 'parse', 'assemble', 'assemble_multi', 'quote', 'is_pattern', 'is_template', 'is_inverted', 'mutable',
86
+ 'parse', 'assemble', 'assemble_multi', 'quote',
87
+ 'is_pattern', 'is_template', 'is_reference',
88
+ 'is_indeterminate', 'is_simple',
89
+ 'is_inverted', 'mutable',
86
90
  # SQL
87
91
  'sqlize', 'TranslationError',
88
92
  # Constants
@@ -862,6 +862,22 @@ class SliceFilter(BaseOp):
862
862
  def is_pattern(self):
863
863
  return False
864
864
 
865
+ def is_template(self):
866
+ """
867
+ True if any attached filter contains a substitution reference.
868
+ """
869
+ return any(
870
+ hasattr(f, 'is_template') and f.is_template()
871
+ for f in self.filters)
872
+
873
+ def is_reference(self):
874
+ """
875
+ True if any attached filter contains an internal reference.
876
+ """
877
+ return any(
878
+ hasattr(f, 'is_reference') and f.is_reference()
879
+ for f in self.filters)
880
+
865
881
  def is_empty(self, node):
866
882
  return not node
867
883
 
@@ -188,6 +188,94 @@ def is_template(key):
188
188
  return _is_template(parse(key))
189
189
 
190
190
 
191
+ @functools.lru_cache(CACHE_SIZE)
192
+ def _is_reference(ops):
193
+ if not ops:
194
+ return False
195
+ if ops[0].is_reference():
196
+ return True
197
+ return _is_reference(ops[1:])
198
+
199
+
200
+ def is_reference(key):
201
+ """
202
+ True if dotted path contains internal references ($$(...)).
203
+ Walks into wraps, filters, and group branches.
204
+
205
+ >>> is_reference('a.$$(b)')
206
+ True
207
+ >>> is_reference('a.b')
208
+ False
209
+ >>> is_reference('a.$(x)')
210
+ False
211
+ """
212
+ if isinstance(key, results.Dotted):
213
+ return _is_reference(key)
214
+ return _is_reference(parse(key))
215
+
216
+
217
+ def is_indeterminate(key):
218
+ """
219
+ True if the path contains substitutions or references — anything
220
+ whose access target isn't pinned down by the source alone and
221
+ requires bindings or data to resolve. Patterns are not indeterminate:
222
+ they describe a set inherent to the query.
223
+
224
+ >>> is_indeterminate('a.$(x)')
225
+ True
226
+ >>> is_indeterminate('a.$$(b)')
227
+ True
228
+ >>> is_indeterminate('a.b')
229
+ False
230
+ >>> is_indeterminate('a.*.b')
231
+ False
232
+ """
233
+ parsed = key if isinstance(key, results.Dotted) else parse(key)
234
+ return _is_template(parsed) or _is_reference(parsed)
235
+
236
+
237
+ @functools.lru_cache(CACHE_SIZE)
238
+ def _is_simple(ops):
239
+ for op in ops:
240
+ if not isinstance(op, (access.Key, access.Attr, access.Slot)):
241
+ return False
242
+ if not isinstance(op.op, matchers.Const):
243
+ return False
244
+ return True
245
+
246
+
247
+ def is_simple(key):
248
+ """
249
+ True if the path is a plain chain of access ops (Key, Attr, Slot)
250
+ with literal matchers — no patterns, substitutions, references,
251
+ guards, transforms, filters, groups, or other decorations.
252
+
253
+ This is the strictest classification: paths that spell out one
254
+ specific access target character-for-character.
255
+
256
+ >>> is_simple('a.b.c')
257
+ True
258
+ >>> is_simple('a[0].b')
259
+ True
260
+ >>> is_simple('a@attr')
261
+ True
262
+ >>> is_simple('a.*.b')
263
+ False
264
+ >>> is_simple('a=30')
265
+ False
266
+ >>> is_simple('a|int')
267
+ False
268
+ >>> is_simple('a.$(x)')
269
+ False
270
+ >>> is_simple('a.$$(b)')
271
+ False
272
+ """
273
+ parsed = key if isinstance(key, results.Dotted) else parse(key)
274
+ if parsed.transforms:
275
+ return False
276
+ return _is_simple(parsed)
277
+
278
+
191
279
  def is_inverted(key):
192
280
  """
193
281
  True if an inverted style pattern
@@ -179,6 +179,12 @@ class TraversalOp(Op):
179
179
  """
180
180
  return False
181
181
 
182
+ def is_reference(self):
183
+ """
184
+ True if this op contains an internal reference.
185
+ """
186
+ return False
187
+
182
188
 
183
189
  class MatchOp(Op):
184
190
  """
@@ -6,6 +6,20 @@ from . import predicates
6
6
  from .access import Slot, Slice
7
7
 
8
8
 
9
+ def _any_template(items):
10
+ """
11
+ True if any item in an iterable has is_template() returning True.
12
+ """
13
+ return any(hasattr(x, 'is_template') and x.is_template() for x in items)
14
+
15
+
16
+ def _any_reference(items):
17
+ """
18
+ True if any item in an iterable has is_reference() returning True.
19
+ """
20
+ return any(hasattr(x, 'is_reference') and x.is_reference() for x in items)
21
+
22
+
9
23
  class FilterOp(base.MatchOp):
10
24
  def is_pattern(self):
11
25
  return False
@@ -50,6 +64,18 @@ class FilterKey(base.MatchOp):
50
64
  def is_dotted(self):
51
65
  return len(self.parts) > 1
52
66
 
67
+ def is_template(self):
68
+ """
69
+ True if any part of the filter key contains a substitution reference.
70
+ """
71
+ return _any_template(self.parts)
72
+
73
+ def is_reference(self):
74
+ """
75
+ True if any part of the filter key contains an internal reference.
76
+ """
77
+ return _any_reference(self.parts)
78
+
53
79
  def get_values(self, node):
54
80
  """
55
81
  Get all values from node matching this key, traversing dotted path if needed.
@@ -188,6 +214,27 @@ class FilterKeyValue(FilterOp):
188
214
  t_str = ''.join('|' + t.operator() for t in self.transforms) if self.transforms else ''
189
215
  return f'{self.key}{t_str}{self._eq_str}{self.val}'
190
216
 
217
+ def is_template(self):
218
+ """
219
+ True if the key, value, or any transform contains a substitution.
220
+ """
221
+ if hasattr(self.key, 'is_template') and self.key.is_template():
222
+ return True
223
+ if hasattr(self.val, 'is_template') and self.val.is_template():
224
+ return True
225
+ return _any_template(self.transforms)
226
+
227
+ def is_reference(self):
228
+ """
229
+ True if the key, value, or any transform contains an internal
230
+ reference.
231
+ """
232
+ if hasattr(self.key, 'is_reference') and self.key.is_reference():
233
+ return True
234
+ if hasattr(self.val, 'is_reference') and self.val.is_reference():
235
+ return True
236
+ return _any_reference(self.transforms)
237
+
191
238
  def _eq_match(self, node):
192
239
  """
193
240
  True if any value from self.key in node matches self.val (after transforms).
@@ -351,6 +398,16 @@ class FilterGroup(FilterOp):
351
398
  return None
352
399
  return self.inner.match(op.inner)
353
400
 
401
+ def is_template(self):
402
+ return (self.inner is not None
403
+ and hasattr(self.inner, 'is_template')
404
+ and self.inner.is_template())
405
+
406
+ def is_reference(self):
407
+ return (self.inner is not None
408
+ and hasattr(self.inner, 'is_reference')
409
+ and self.inner.is_reference())
410
+
354
411
  def resolve(self, bindings, partial=False):
355
412
  """
356
413
  Resolve $N in inner filter.
@@ -403,6 +460,12 @@ class FilterAnd(FilterOp):
403
460
  results.append(m)
404
461
  return FilterAnd(*results)
405
462
 
463
+ def is_template(self):
464
+ return _any_template(self.filters)
465
+
466
+ def is_reference(self):
467
+ return _any_reference(self.filters)
468
+
406
469
  def resolve(self, bindings, partial=False):
407
470
  """
408
471
  Resolve $N in each filter.
@@ -443,6 +506,12 @@ class FilterOr(FilterOp):
443
506
  seen.add(item_id)
444
507
  yield item
445
508
 
509
+ def is_template(self):
510
+ return _any_template(self.filters)
511
+
512
+ def is_reference(self):
513
+ return _any_reference(self.filters)
514
+
446
515
  def resolve(self, bindings, partial=False):
447
516
  """
448
517
  Resolve $N in each filter.
@@ -488,6 +557,16 @@ class FilterKeyValueFirst(FilterOp):
488
557
  return self.inner.match(op.inner)
489
558
  return None
490
559
 
560
+ def is_template(self):
561
+ return (self.inner is not None
562
+ and hasattr(self.inner, 'is_template')
563
+ and self.inner.is_template())
564
+
565
+ def is_reference(self):
566
+ return (self.inner is not None
567
+ and hasattr(self.inner, 'is_reference')
568
+ and self.inner.is_reference())
569
+
491
570
  def resolve(self, bindings, partial=False):
492
571
  """
493
572
  Resolve $N in inner filter.
@@ -541,6 +620,16 @@ class FilterNot(FilterOp):
541
620
  return None
542
621
  return self.inner.match(op.inner)
543
622
 
623
+ def is_template(self):
624
+ return (self.inner is not None
625
+ and hasattr(self.inner, 'is_template')
626
+ and self.inner.is_template())
627
+
628
+ def is_reference(self):
629
+ return (self.inner is not None
630
+ and hasattr(self.inner, 'is_reference')
631
+ and self.inner.is_reference())
632
+
544
633
  def resolve(self, bindings, partial=False):
545
634
  """
546
635
  Resolve $N in inner filter.
@@ -42,6 +42,16 @@ class OpGroup(base.TraversalOp):
42
42
  return True
43
43
  return False
44
44
 
45
+ def is_reference(self):
46
+ """
47
+ True if any branch contains an internal reference.
48
+ """
49
+ for branch in base.branches_only(self.branches):
50
+ for op in branch:
51
+ if hasattr(op, 'is_reference') and op.is_reference():
52
+ return True
53
+ return False
54
+
45
55
  def default(self):
46
56
  """
47
57
  Derive default from the first branch's first op, so auto-creation works
@@ -150,12 +150,16 @@ class Pattern(MatchOp):
150
150
  return True
151
151
 
152
152
 
153
- class Subst(Pattern):
153
+ class Subst(MatchOp):
154
154
  """
155
155
  Substitution op: $0, $(name), $(0|transform), $(name|int), etc.
156
156
  Resolves against bindings via __getitem__: list for positional,
157
157
  dict for named or numeric keys. Optional transforms applied
158
158
  after lookup.
159
+
160
+ Not a Pattern subclass — substitutions resolve to a single key, not
161
+ a set of keys. is_template() is the right check for "contains a
162
+ substitution".
159
163
  """
160
164
  def __init__(self, *args, transforms=(), **kwargs):
161
165
  super().__init__(*args, **kwargs)
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Translate a dotted path into SQL clause components.
3
3
 
4
- Returns a dict with keys select, where, from, params, missing. Only keys that
4
+ Returns a dict with keys select, where, from, params, unbound. Only keys that
5
5
  apply to the given path are included. Callers stitch the fragments into a
6
6
  full statement, e.g.:
7
7
 
@@ -13,10 +13,12 @@ navigation inside it.
13
13
 
14
14
  Placeholders are emitted in SQLAlchemy named style (`:name`). Literals on the
15
15
  RHS of guards are hoisted into `params` under generated names (`_p1`, `_p2`,
16
- …). Unresolved substitutions keep their declared names and appear in
17
- `missing`; callers fill them in by name before executing.
16
+ …). Unresolved substitutions appear in `unbound` as a dict mapping
17
+ `bind_param_name → original_name`; callers fill the params dict by the bind
18
+ name before executing.
18
19
  """
19
20
  import decimal
21
+ import hashlib
20
22
  import re
21
23
 
22
24
  from . import access
@@ -46,44 +48,29 @@ def _quote_ident(name):
46
48
  return '"' + name.replace('"', '""') + '"'
47
49
 
48
50
 
49
- # Subst names may themselves be dotted paths. To survive as SQL bind
50
- # parameter names (which must be plain identifiers), the access operators
51
- # are encoded with mnemonic tokens. This preserves the distinction
52
- # between `a.b`, `a@b`, and `a[0]` in the placeholder name.
53
- _OP_ENCODE = {
54
- '.': '_dot_',
55
- '@': '_at_',
56
- '[': '_br_',
57
- ']': '',
58
- }
51
+ # Substitution names that aren't already valid SQL identifiers are mapped
52
+ # to a hash-based name for use as a SQL bind parameter. Deterministic —
53
+ # the same subst name always produces the same hashed param.
54
+ _HASH_PREFIX = '_s_'
55
+ _GEN_PREFIX = '_p'
56
+ _RESERVED_PREFIXES = (_HASH_PREFIX, _GEN_PREFIX)
59
57
 
60
58
 
61
- def _encode_subst_name(name):
59
+ def _param_name_for_subst(name):
62
60
  """
63
- Encode a subst name (a dotted path) into a plain SQL identifier.
64
- Recognised access ops become mnemonic tokens; unsupported characters
65
- raise TranslationError.
61
+ Return a SQL identifier-safe bind parameter name for a substitution.
62
+
63
+ Plain identifiers pass through unchanged. Anything else — dotted
64
+ paths, quoted keys, special characters — hashes to a deterministic
65
+ `_s_<hex>` name. Names that collide with our reserved generated-name
66
+ prefixes (`_p<N>`, `_s_`) are also hashed so they can never clobber
67
+ hoisted literals.
66
68
  """
67
- if _IDENT_RE.fullmatch(name):
69
+ if _IDENT_RE.fullmatch(name) and not any(
70
+ name.startswith(p) for p in _RESERVED_PREFIXES):
68
71
  return name
69
- out = []
70
- for c in name:
71
- if c in _OP_ENCODE:
72
- out.append(_OP_ENCODE[c])
73
- elif c.isalnum() or c == '_':
74
- out.append(c)
75
- else:
76
- raise TranslationError(
77
- f'cannot encode character {c!r} in substitution name {name!r}; '
78
- 'resolve via bindings= at sqlize time'
79
- )
80
- encoded = ''.join(out)
81
- if not _IDENT_RE.fullmatch(encoded):
82
- raise TranslationError(
83
- f'substitution name {name!r} does not encode to a valid '
84
- 'SQL identifier'
85
- )
86
- return encoded
72
+ h = hashlib.sha256(name.encode('utf-8')).hexdigest()[:12]
73
+ return f'{_HASH_PREFIX}{h}'
87
74
 
88
75
 
89
76
  def _pg_path_segment(seg):
@@ -112,38 +99,41 @@ def _pg_string_literal(s):
112
99
  class _ParamState:
113
100
  """
114
101
  Shared state for hoisted params across a translate call and its
115
- sub-translators. Generated param names use the `_p{n}` convention;
116
- substitution param names come from the substitution itself.
102
+ sub-translators. Generated literal names use `_p{N}`; deferred
103
+ substitutions use their name when it's already a valid identifier,
104
+ or a `_s_<hex>` hash otherwise.
117
105
  """
118
106
 
119
107
  def __init__(self):
120
- self.params = {} # name → resolved value
121
- self.missing = [] # list of substitution names awaiting a value
108
+ self.params = {} # bind name → resolved value
109
+ self.unbound = {} # bind name original subst name
122
110
  self._gen_counter = 0
111
+ self._by_orig = {} # original subst name → bind name (dedup)
123
112
 
124
113
  def hoist_literal(self, value):
125
114
  """
126
115
  Hoist a concrete literal into a generated-name slot.
127
116
  """
128
117
  self._gen_counter += 1
129
- name = f'_p{self._gen_counter}'
118
+ name = f'{_GEN_PREFIX}{self._gen_counter}'
130
119
  self.params[name] = value
131
120
  return name
132
121
 
133
122
  def hoist_named(self, name):
134
123
  """
135
- Hoist a named substitution. If a value is supplied later it lands
136
- under the encoded name; until then it's recorded in `missing`.
137
-
138
- Names that already are plain identifiers are used as-is. Dotted
139
- paths (`$(user.age)`, `$(users[0].name)`, `$(obj@attr)`) are
140
- encoded into plain identifiers using mnemonic tokens for access
141
- ops so they can serve as SQL bind-parameter names.
124
+ Hoist a named substitution. If the name is already a valid SQL
125
+ identifier it's used directly as the bind parameter; otherwise a
126
+ deterministic `_s_<hash>` name is generated. The bind parameter
127
+ name keys the mapping in `unbound`, with the original source
128
+ name as the value. Repeated uses share one entry.
142
129
  """
143
- encoded = _encode_subst_name(str(name))
144
- if encoded not in self.params and encoded not in self.missing:
145
- self.missing.append(encoded)
146
- return encoded
130
+ name = str(name)
131
+ if name in self._by_orig:
132
+ return self._by_orig[name]
133
+ param = _param_name_for_subst(name)
134
+ self._by_orig[name] = param
135
+ self.unbound[param] = name
136
+ return param
147
137
 
148
138
 
149
139
  class _Translator:
@@ -196,8 +186,8 @@ class _Translator:
196
186
  def _result(self, **kw):
197
187
  d = {k: v for k, v in kw.items() if v is not None}
198
188
  d['params'] = dict(self.state.params)
199
- if self.state.missing:
200
- d['missing'] = list(self.state.missing)
189
+ if self.state.unbound:
190
+ d['unbound'] = dict(self.state.unbound)
201
191
  return d
202
192
 
203
193
  def _column_name(self, op):
@@ -490,20 +480,23 @@ def sqlize(path, *, bindings=None, flavor='postgres', format='sqlalchemy'):
490
480
  Translate a dotted path into SQL clause components.
491
481
 
492
482
  Returns a dict with some subset of keys select, where, from, params,
493
- missing.
483
+ unbound.
494
484
 
495
485
  path — dotted path string or pre-parsed Dotted result.
496
486
  bindings — optional mapping/list used to resolve substitutions before
497
487
  translation. Path-position substitutions must be resolved or a
498
488
  TranslationError is raised. Unresolved value-position substitutions
499
- keep their declared names and appear in 'missing'.
489
+ appear in 'unbound'.
500
490
  flavor — SQL flavor for JSONB operators; only 'postgres' is implemented.
501
491
  format — placeholder style. Only 'sqlalchemy' (`:name`) is supported in v1.
502
492
 
503
493
  Literals on the RHS of guards are hoisted into params under generated
504
- names (`_p1`, `_p2`, …). Named substitutions keep their original names.
505
- Callers fill any names still in 'missing' before handing params to a
506
- driver.
494
+ names (`_p1`, `_p2`, …). Substitutions are hoisted under their own
495
+ name when it's a valid SQL identifier; otherwise a deterministic
496
+ `_s_<hash>` bind name is generated. Unresolved substitutions appear
497
+ in 'unbound' as a dict mapping bind_param_name → original_name so
498
+ callers can fill params by bind name and recover the source name
499
+ when needed.
507
500
 
508
501
  >>> sqlize("status = 'active'")
509
502
  {'select': 'status', 'where': 'status = :_p1', 'params': {'_p1': 'active'}}