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.
- {dotted_notation-0.43.3/dotted_notation.egg-info → dotted_notation-0.43.5}/PKG-INFO +33 -7
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/README.md +32 -6
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/__init__.py +6 -2
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/access.py +16 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/api.py +88 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/base.py +6 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/filters.py +89 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/groups.py +10 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/matchers.py +5 -1
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/sqlize.py +52 -59
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/wrappers.py +50 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5/dotted_notation.egg-info}/PKG-INFO +33 -7
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/setup.py +1 -1
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_api.py +109 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_named_subst.py +74 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_sqlize.py +33 -31
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/LICENSE +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/__main__.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/__init__.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/_compat.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/formats.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/cli/main.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/containers.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/engine.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/grammar.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/predicates.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/recursive.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/results.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/transforms.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/utils.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted/utypes.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/SOURCES.txt +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/dependency_links.txt +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/entry_points.txt +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/requires.txt +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/dotted_notation.egg-info/top_level.txt +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/setup.cfg +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/__init__.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_appender.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_assemble.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_attrs.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_bindings.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_cli.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_concat.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_container_filter.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_cut.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_empty.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_filter_keyvalue.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_get.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_guard_transforms.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_invert.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_json_sentinels.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_keys_values.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_match.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_matchable.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_negation.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_nop.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_numeric.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_opgroup.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_pluck.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_predicates.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_quote_idempotent.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_recursive.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_reference.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_replace.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_slice.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_softcut.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_strict.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_string_glob.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_subst_escape.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_subst_transforms.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_threading.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_transforms.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_translate.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_type_restriction.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_unpack.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_update.py +0 -0
- {dotted_notation-0.43.3 → dotted_notation-0.43.5}/tests/test_update_if.py +0 -0
- {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
|
+
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': {},
|
|
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
|
-
`
|
|
2869
|
-
`params`
|
|
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
|
-
|
|
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': {},
|
|
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
|
-
| `
|
|
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': {},
|
|
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
|
-
`
|
|
2832
|
-
`params`
|
|
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
|
-
|
|
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': {},
|
|
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
|
-
| `
|
|
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,
|
|
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',
|
|
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
|
|
@@ -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(
|
|
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,
|
|
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
|
|
17
|
-
`
|
|
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
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
59
|
+
def _param_name_for_subst(name):
|
|
62
60
|
"""
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
116
|
-
|
|
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 = {}
|
|
121
|
-
self.
|
|
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'
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
144
|
-
if
|
|
145
|
-
self.
|
|
146
|
-
|
|
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.
|
|
200
|
-
d['
|
|
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
|
-
|
|
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
|
-
|
|
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`, …).
|
|
505
|
-
|
|
506
|
-
|
|
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'}}
|