dotted-notation 0.43.4__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.4/dotted_notation.egg-info → dotted_notation-0.43.5}/PKG-INFO +25 -7
  2. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/README.md +24 -6
  3. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/__init__.py +6 -2
  4. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/access.py +8 -0
  5. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/api.py +88 -0
  6. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/base.py +6 -0
  7. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/filters.py +45 -0
  8. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/groups.py +10 -0
  9. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/sqlize.py +52 -59
  10. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/wrappers.py +25 -0
  11. {dotted_notation-0.43.4 → dotted_notation-0.43.5/dotted_notation.egg-info}/PKG-INFO +25 -7
  12. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/setup.py +1 -1
  13. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_api.py +109 -0
  14. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_sqlize.py +33 -31
  15. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/LICENSE +0 -0
  16. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/__main__.py +0 -0
  17. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/cli/__init__.py +0 -0
  18. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/cli/_compat.py +0 -0
  19. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/cli/formats.py +0 -0
  20. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/cli/main.py +0 -0
  21. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/containers.py +0 -0
  22. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/engine.py +0 -0
  23. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/grammar.py +0 -0
  24. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/matchers.py +0 -0
  25. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/predicates.py +0 -0
  26. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/recursive.py +0 -0
  27. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/results.py +0 -0
  28. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/transforms.py +0 -0
  29. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/utils.py +0 -0
  30. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted/utypes.py +0 -0
  31. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted_notation.egg-info/SOURCES.txt +0 -0
  32. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted_notation.egg-info/dependency_links.txt +0 -0
  33. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted_notation.egg-info/entry_points.txt +0 -0
  34. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted_notation.egg-info/requires.txt +0 -0
  35. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/dotted_notation.egg-info/top_level.txt +0 -0
  36. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/setup.cfg +0 -0
  37. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/__init__.py +0 -0
  38. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_appender.py +0 -0
  39. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_assemble.py +0 -0
  40. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_attrs.py +0 -0
  41. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_bindings.py +0 -0
  42. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_cli.py +0 -0
  43. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_concat.py +0 -0
  44. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_container_filter.py +0 -0
  45. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_cut.py +0 -0
  46. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_empty.py +0 -0
  47. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_filter_keyvalue.py +0 -0
  48. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_get.py +0 -0
  49. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_guard_transforms.py +0 -0
  50. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_invert.py +0 -0
  51. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_json_sentinels.py +0 -0
  52. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_keys_values.py +0 -0
  53. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_match.py +0 -0
  54. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_matchable.py +0 -0
  55. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_named_subst.py +0 -0
  56. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_negation.py +0 -0
  57. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_nop.py +0 -0
  58. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_numeric.py +0 -0
  59. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_opgroup.py +0 -0
  60. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_pluck.py +0 -0
  61. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_predicates.py +0 -0
  62. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_quote_idempotent.py +0 -0
  63. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_recursive.py +0 -0
  64. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_reference.py +0 -0
  65. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_replace.py +0 -0
  66. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_slice.py +0 -0
  67. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_softcut.py +0 -0
  68. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_strict.py +0 -0
  69. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_string_glob.py +0 -0
  70. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_subst_escape.py +0 -0
  71. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_subst_transforms.py +0 -0
  72. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_threading.py +0 -0
  73. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_transforms.py +0 -0
  74. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_translate.py +0 -0
  75. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_type_restriction.py +0 -0
  76. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_unpack.py +0 -0
  77. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_update.py +0 -0
  78. {dotted_notation-0.43.4 → dotted_notation-0.43.5}/tests/test_update_if.py +0 -0
  79. {dotted_notation-0.43.4 → 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.4
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
@@ -2871,18 +2871,36 @@ time:
2871
2871
 
2872
2872
  >>> r = dotted.sqlize("age >= $(min_age)")
2873
2873
  >>> r
2874
- {'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
2874
+ {'select': 'age', 'where': 'age >= :min_age', 'params': {},
2875
+ 'unbound': {'min_age': 'min_age'}}
2875
2876
 
2876
- `missing` lists names that still need a value. Fill them in before passing
2877
- `params` to a driver:
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.
2881
+
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:
2878
2884
 
2879
2885
  >>> r['params']['min_age'] = 30
2880
2886
  >>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
2881
2887
 
2882
- 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:
2883
2900
 
2884
2901
  >>> dotted.sqlize('(age >= $(x) & weight = $(x))')
2885
- {'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
2902
+ {'where': '(age >= :x) AND (weight = :x)', 'params': {},
2903
+ 'unbound': {'x': 'x'}}
2886
2904
 
2887
2905
  If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
2888
2906
  like any other literal:
@@ -2910,7 +2928,7 @@ Returned dict may contain any subset of:
2910
2928
  | `select` | `str` | Expression yielding the value dotted would return |
2911
2929
  | `where` | `str` | Predicate from guards / filters / boolean groups |
2912
2930
  | `params` | `dict` | Hoisted values keyed by name |
2913
- | `missing` | `list[str]` | Substitution names still awaiting a value |
2931
+ | `unbound` | `dict` | Deferred substitutions: `{bind_param_name: original_name}` |
2914
2932
 
2915
2933
  `select` is omitted when combining predicates across different columns
2916
2934
  (e.g. top-level groups), since there's no single meaningful expression.
@@ -2834,18 +2834,36 @@ time:
2834
2834
 
2835
2835
  >>> r = dotted.sqlize("age >= $(min_age)")
2836
2836
  >>> r
2837
- {'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
2837
+ {'select': 'age', 'where': 'age >= :min_age', 'params': {},
2838
+ 'unbound': {'min_age': 'min_age'}}
2838
2839
 
2839
- `missing` lists names that still need a value. Fill them in before passing
2840
- `params` to a driver:
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.
2844
+
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:
2841
2847
 
2842
2848
  >>> r['params']['min_age'] = 30
2843
2849
  >>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
2844
2850
 
2845
- 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:
2846
2863
 
2847
2864
  >>> dotted.sqlize('(age >= $(x) & weight = $(x))')
2848
- {'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
2865
+ {'where': '(age >= :x) AND (weight = :x)', 'params': {},
2866
+ 'unbound': {'x': 'x'}}
2849
2867
 
2850
2868
  If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
2851
2869
  like any other literal:
@@ -2873,7 +2891,7 @@ Returned dict may contain any subset of:
2873
2891
  | `select` | `str` | Expression yielding the value dotted would return |
2874
2892
  | `where` | `str` | Predicate from guards / filters / boolean groups |
2875
2893
  | `params` | `dict` | Hoisted values keyed by name |
2876
- | `missing` | `list[str]` | Substitution names still awaiting a value |
2894
+ | `unbound` | `dict` | Deferred substitutions: `{bind_param_name: original_name}` |
2877
2895
 
2878
2896
  `select` is omitted when combining predicates across different columns
2879
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
@@ -870,6 +870,14 @@ class SliceFilter(BaseOp):
870
870
  hasattr(f, 'is_template') and f.is_template()
871
871
  for f in self.filters)
872
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
+
873
881
  def is_empty(self, node):
874
882
  return not node
875
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
  """
@@ -13,6 +13,13 @@ def _any_template(items):
13
13
  return any(hasattr(x, 'is_template') and x.is_template() for x in items)
14
14
 
15
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
+
16
23
  class FilterOp(base.MatchOp):
17
24
  def is_pattern(self):
18
25
  return False
@@ -63,6 +70,12 @@ class FilterKey(base.MatchOp):
63
70
  """
64
71
  return _any_template(self.parts)
65
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
+
66
79
  def get_values(self, node):
67
80
  """
68
81
  Get all values from node matching this key, traversing dotted path if needed.
@@ -211,6 +224,17 @@ class FilterKeyValue(FilterOp):
211
224
  return True
212
225
  return _any_template(self.transforms)
213
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
+
214
238
  def _eq_match(self, node):
215
239
  """
216
240
  True if any value from self.key in node matches self.val (after transforms).
@@ -379,6 +403,11 @@ class FilterGroup(FilterOp):
379
403
  and hasattr(self.inner, 'is_template')
380
404
  and self.inner.is_template())
381
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
+
382
411
  def resolve(self, bindings, partial=False):
383
412
  """
384
413
  Resolve $N in inner filter.
@@ -434,6 +463,9 @@ class FilterAnd(FilterOp):
434
463
  def is_template(self):
435
464
  return _any_template(self.filters)
436
465
 
466
+ def is_reference(self):
467
+ return _any_reference(self.filters)
468
+
437
469
  def resolve(self, bindings, partial=False):
438
470
  """
439
471
  Resolve $N in each filter.
@@ -477,6 +509,9 @@ class FilterOr(FilterOp):
477
509
  def is_template(self):
478
510
  return _any_template(self.filters)
479
511
 
512
+ def is_reference(self):
513
+ return _any_reference(self.filters)
514
+
480
515
  def resolve(self, bindings, partial=False):
481
516
  """
482
517
  Resolve $N in each filter.
@@ -527,6 +562,11 @@ class FilterKeyValueFirst(FilterOp):
527
562
  and hasattr(self.inner, 'is_template')
528
563
  and self.inner.is_template())
529
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
+
530
570
  def resolve(self, bindings, partial=False):
531
571
  """
532
572
  Resolve $N in inner filter.
@@ -585,6 +625,11 @@ class FilterNot(FilterOp):
585
625
  and hasattr(self.inner, 'is_template')
586
626
  and self.inner.is_template())
587
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
+
588
633
  def resolve(self, bindings, partial=False):
589
634
  """
590
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
@@ -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'}}
@@ -216,6 +216,20 @@ class ValueGuard(Wrap):
216
216
  return True
217
217
  return False
218
218
 
219
+ def is_reference(self):
220
+ """
221
+ True if the inner op, the guard value, or any transform contains
222
+ an internal reference.
223
+ """
224
+ if self.inner.is_reference():
225
+ return True
226
+ if hasattr(self.guard, 'is_reference') and self.guard.is_reference():
227
+ return True
228
+ for t in self.transforms:
229
+ if hasattr(t, 'is_reference') and t.is_reference():
230
+ return True
231
+ return False
232
+
219
233
  def is_recursive(self):
220
234
  return self.inner.is_recursive()
221
235
 
@@ -444,6 +458,17 @@ class FilterWrap(Wrap):
444
458
  hasattr(f, 'is_template') and f.is_template()
445
459
  for f in self.filters)
446
460
 
461
+ def is_reference(self):
462
+ """
463
+ True if the inner op or any attached filter contains an internal
464
+ reference.
465
+ """
466
+ if self.inner.is_reference():
467
+ return True
468
+ return any(
469
+ hasattr(f, 'is_reference') and f.is_reference()
470
+ for f in self.filters)
471
+
447
472
  def default(self):
448
473
  """
449
474
  Filters imply the value has structure to check against,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.43.4
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
@@ -2871,18 +2871,36 @@ time:
2871
2871
 
2872
2872
  >>> r = dotted.sqlize("age >= $(min_age)")
2873
2873
  >>> r
2874
- {'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
2874
+ {'select': 'age', 'where': 'age >= :min_age', 'params': {},
2875
+ 'unbound': {'min_age': 'min_age'}}
2875
2876
 
2876
- `missing` lists names that still need a value. Fill them in before passing
2877
- `params` to a driver:
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.
2881
+
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:
2878
2884
 
2879
2885
  >>> r['params']['min_age'] = 30
2880
2886
  >>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
2881
2887
 
2882
- 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:
2883
2900
 
2884
2901
  >>> dotted.sqlize('(age >= $(x) & weight = $(x))')
2885
- {'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
2902
+ {'where': '(age >= :x) AND (weight = :x)', 'params': {},
2903
+ 'unbound': {'x': 'x'}}
2886
2904
 
2887
2905
  If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
2888
2906
  like any other literal:
@@ -2910,7 +2928,7 @@ Returned dict may contain any subset of:
2910
2928
  | `select` | `str` | Expression yielding the value dotted would return |
2911
2929
  | `where` | `str` | Predicate from guards / filters / boolean groups |
2912
2930
  | `params` | `dict` | Hoisted values keyed by name |
2913
- | `missing` | `list[str]` | Substitution names still awaiting a value |
2931
+ | `unbound` | `dict` | Deferred substitutions: `{bind_param_name: original_name}` |
2914
2932
 
2915
2933
  `select` is omitted when combining predicates across different columns
2916
2934
  (e.g. top-level groups), since there's no single meaningful expression.
@@ -5,7 +5,7 @@ with open("README.md", "rt") as f:
5
5
 
6
6
  setuptools.setup(
7
7
  name="dotted_notation",
8
- version="0.43.4",
8
+ version="0.43.5",
9
9
  author="Frey Waid",
10
10
  author_email="logophage1@gmail.com",
11
11
  description="Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms",
@@ -65,6 +65,115 @@ def test_is_inverted_false():
65
65
  assert dotted.is_inverted('*') is False
66
66
 
67
67
 
68
+ # ---- is_reference ----
69
+
70
+ def test_is_reference_top_level():
71
+ assert dotted.is_reference('a.$$(b)') is True
72
+ assert dotted.is_reference('$$(x.y)') is True
73
+
74
+
75
+ def test_is_reference_in_guard():
76
+ # Ref in guard position (previously missed)
77
+ assert dotted.is_reference('a=$$(b)') is True
78
+ assert dotted.is_reference('a>=$$(b)') is True
79
+
80
+
81
+ def test_is_reference_in_filter():
82
+ assert dotted.is_reference('a[*&x=$$(b)]') is True
83
+ assert dotted.is_reference('a[$$(k)=1]') is True
84
+ assert dotted.is_reference('a[!x=$$(b)]') is True
85
+
86
+
87
+ def test_is_reference_in_group():
88
+ assert dotted.is_reference('(a=$$(b) & c=1)') is True
89
+
90
+
91
+ def test_is_reference_false_for_plain():
92
+ assert dotted.is_reference('a.b') is False
93
+ assert dotted.is_reference('a.*.b') is False
94
+
95
+
96
+ def test_is_reference_false_for_template():
97
+ # Substitutions are not references
98
+ assert dotted.is_reference('a.$(x)') is False
99
+ assert dotted.is_reference('a=$(x)') is False
100
+
101
+
102
+ # ---- is_indeterminate ----
103
+
104
+ def test_is_indeterminate_templates():
105
+ assert dotted.is_indeterminate('a.$(x)') is True
106
+ assert dotted.is_indeterminate('a=$(x)') is True
107
+
108
+
109
+ def test_is_indeterminate_references():
110
+ assert dotted.is_indeterminate('a.$$(b)') is True
111
+ assert dotted.is_indeterminate('a=$$(b)') is True
112
+
113
+
114
+ def test_is_indeterminate_false_for_plain():
115
+ assert dotted.is_indeterminate('a.b.c') is False
116
+ assert dotted.is_indeterminate('a[0]') is False
117
+ assert dotted.is_indeterminate('a=30') is False
118
+
119
+
120
+ def test_is_indeterminate_false_for_patterns():
121
+ # Patterns are not indeterminate — they describe a set inherent to
122
+ # the query, not a placeholder awaiting info.
123
+ assert dotted.is_indeterminate('a.*.b') is False
124
+ assert dotted.is_indeterminate('a.**') is False
125
+
126
+
127
+ # ---- is_simple ----
128
+
129
+ def test_is_simple_true_for_plain_paths():
130
+ assert dotted.is_simple('a') is True
131
+ assert dotted.is_simple('a.b.c') is True
132
+ assert dotted.is_simple('a[0].b') is True
133
+ assert dotted.is_simple('a@attr') is True
134
+ assert dotted.is_simple('a.b[0].c') is True
135
+
136
+
137
+ def test_is_simple_false_for_patterns():
138
+ assert dotted.is_simple('a.*.b') is False
139
+ assert dotted.is_simple('a[*]') is False
140
+ assert dotted.is_simple('a.**') is False
141
+ assert dotted.is_simple('a=/re/') is False
142
+
143
+
144
+ def test_is_simple_false_for_templates():
145
+ assert dotted.is_simple('a.$(x)') is False
146
+ assert dotted.is_simple('a.$0') is False
147
+
148
+
149
+ def test_is_simple_false_for_references():
150
+ assert dotted.is_simple('a.$$(b)') is False
151
+
152
+
153
+ def test_is_simple_false_for_guards():
154
+ assert dotted.is_simple('a=30') is False
155
+ assert dotted.is_simple('a.b=1') is False
156
+
157
+
158
+ def test_is_simple_false_for_transforms():
159
+ assert dotted.is_simple('a|int') is False
160
+ assert dotted.is_simple('a.b|str') is False
161
+
162
+
163
+ def test_is_simple_false_for_filters():
164
+ assert dotted.is_simple('a[*&x=1]') is False
165
+ assert dotted.is_simple('a[x=1]') is False
166
+
167
+
168
+ def test_is_simple_false_for_groups():
169
+ assert dotted.is_simple('(a, b)') is False
170
+ assert dotted.is_simple('a.(b, c)') is False
171
+
172
+
173
+ def test_is_simple_false_for_inverted():
174
+ assert dotted.is_simple('!a') is False
175
+
176
+
68
177
  # build / build_multi
69
178
 
70
179
  def test_build_simple():
@@ -297,23 +297,24 @@ def test_value_sub_with_binding():
297
297
 
298
298
 
299
299
  def test_value_sub_without_binding():
300
- # Unbound substitution keeps its name; listed in missing
300
+ # Unbound substitution: name is already a valid identifier, so it's
301
+ # used directly as the bind name. unbound maps bind → orig.
301
302
  result = sqlize('age>=$(min_age)')
302
303
  assert result == {
303
304
  'select': 'age',
304
305
  'where': 'age >= :min_age',
305
306
  'params': {},
306
- 'missing': ['min_age'],
307
+ 'unbound': {'min_age': 'min_age'},
307
308
  }
308
309
 
309
310
 
310
311
  def test_value_sub_repeated_dedupes():
311
- # Same name used twice → single :x placeholder, one missing entry
312
+ # Same name used twice → single placeholder, one unbound entry.
312
313
  result = sqlize('(age>=$(x) & weight=$(x))')
313
314
  assert result == {
314
315
  'where': '(age >= :x) AND (weight = :x)',
315
316
  'params': {},
316
- 'missing': ['x'],
317
+ 'unbound': {'x': 'x'},
317
318
  }
318
319
 
319
320
 
@@ -322,7 +323,7 @@ def test_mixed_subst_and_literal():
322
323
  assert result == {
323
324
  'where': '(age >= :min_age) AND (weight > :_p1)',
324
325
  'params': {'_p1': 200},
325
- 'missing': ['min_age'],
326
+ 'unbound': {'min_age': 'min_age'},
326
327
  }
327
328
 
328
329
 
@@ -337,39 +338,40 @@ def test_value_sub_dotted_name_with_bindings():
337
338
  }
338
339
 
339
340
 
340
- def test_value_sub_dotted_name_encoded():
341
- # Dotted subst name is encoded into a plain SQL identifier.
341
+ def test_value_sub_dotted_name_hashed():
342
+ # Dotted subst name hashes to an _s_<hex> bind name; unbound maps
343
+ # bind → orig so callers can recover the source name.
342
344
  result = sqlize('age>=$(user.min_age)')
343
- assert result == {
344
- 'select': 'age',
345
- 'where': 'age >= :user_dot_min_age',
346
- 'params': {},
347
- 'missing': ['user_dot_min_age'],
348
- }
349
-
350
-
351
- def test_value_sub_attr_name_encoded():
352
- result = sqlize('age>=$(obj@attr)')
353
- assert result['where'] == 'age >= :obj_at_attr'
354
- assert result['missing'] == ['obj_at_attr']
345
+ assert result['select'] == 'age'
346
+ assert set(result['unbound'].values()) == {'user.min_age'}
347
+ [(bind, orig)] = result['unbound'].items()
348
+ assert bind.startswith('_s_')
349
+ assert orig == 'user.min_age'
350
+ assert result['where'] == f'age >= :{bind}'
351
+ assert result['params'] == {}
355
352
 
356
353
 
357
- def test_value_sub_slot_name_encoded():
358
- result = sqlize('age>=$(arr[0])')
359
- assert result['where'] == 'age >= :arr_br_0'
360
- assert result['missing'] == ['arr_br_0']
354
+ def test_value_sub_hash_is_deterministic():
355
+ # Same subst name → same hash across calls (still keyed by bind)
356
+ a = list(sqlize('age>=$(user.min_age)')['unbound'])[0]
357
+ b = list(sqlize('weight>=$(user.min_age)')['unbound'])[0]
358
+ assert a == b
361
359
 
362
360
 
363
- def test_value_sub_mixed_path_name_encoded():
364
- result = sqlize('age>=$(users[0].name)')
365
- assert result['where'] == 'age >= :users_br_0_dot_name'
366
- assert result['missing'] == ['users_br_0_dot_name']
361
+ def test_value_sub_hash_names_are_distinct():
362
+ # Different subst names produce different hashes
363
+ a = list(sqlize('age>=$(user.min_age)')['unbound'])[0]
364
+ b = list(sqlize('age>=$(user.max_age)')['unbound'])[0]
365
+ assert a != b
367
366
 
368
367
 
369
- def test_value_sub_unencodable_char_errors():
370
- # Characters outside the supported op set raise
371
- with pytest.raises(TranslationError):
372
- sqlize('age>=$(user age)')
368
+ def test_value_sub_special_char_hashed():
369
+ # Names with spaces / quotes / punctuation hash without erroring
370
+ result = sqlize("age>=$('dr pepper')")
371
+ assert set(result['unbound'].values()) == {"'dr pepper'"}
372
+ [(bind, orig)] = result['unbound'].items()
373
+ assert bind.startswith('_s_')
374
+ assert orig == "'dr pepper'"
373
375
 
374
376
 
375
377
  def test_path_sub_with_binding():