dotted-notation 0.42.8__tar.gz → 0.43.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dotted_notation-0.42.8/dotted_notation.egg-info → dotted_notation-0.43.0}/PKG-INFO +178 -4
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/README.md +177 -3
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/__init__.py +3 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/access.py +61 -10
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/engine.py +2 -1
- dotted_notation-0.43.0/dotted/sqlize.py +472 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0/dotted_notation.egg-info}/PKG-INFO +178 -4
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/SOURCES.txt +2 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/setup.py +1 -1
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_attrs.py +195 -0
- dotted_notation-0.43.0/tests/test_sqlize.py +382 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/LICENSE +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/__main__.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/api.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/base.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/__init__.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/_compat.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/formats.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/main.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/containers.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/filters.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/grammar.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/groups.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/matchers.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/predicates.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/recursive.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/results.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/transforms.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/utils.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/utypes.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/wrappers.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/dependency_links.txt +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/entry_points.txt +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/requires.txt +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/top_level.txt +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/setup.cfg +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/__init__.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_api.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_appender.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_assemble.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_bindings.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_cli.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_concat.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_container_filter.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_cut.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_empty.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_filter_keyvalue.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_get.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_guard_transforms.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_invert.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_keys_values.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_match.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_matchable.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_named_subst.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_negation.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_nop.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_numeric.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_opgroup.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_pluck.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_predicates.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_quote_idempotent.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_recursive.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_reference.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_replace.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_slice.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_softcut.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_strict.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_string_glob.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_subst_escape.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_subst_transforms.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_threading.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_transforms.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_translate.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_type_restriction.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_unpack.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_update.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_update_if.py +0 -0
- {dotted_notation-0.42.8 → dotted_notation-0.43.0}/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.
|
|
3
|
+
Version: 0.43.0
|
|
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
|
|
@@ -164,6 +164,7 @@ Or pick only what you need:
|
|
|
164
164
|
- [Built-in Transforms](#built-in-transforms)
|
|
165
165
|
- [Container transform arguments](#container-transform-arguments)
|
|
166
166
|
- [Custom Transforms](#custom-transforms)
|
|
167
|
+
- [SQL Translation (`sqlize`)](#sqlize) — *under development*
|
|
167
168
|
- [Constants and Exceptions](#constants-and-exceptions)
|
|
168
169
|
- [CLI (`dq`)](#cli-dq)
|
|
169
170
|
- [File input](#file-input)
|
|
@@ -176,6 +177,7 @@ Or pick only what you need:
|
|
|
176
177
|
- [Why do I get a tuple for my get?](#why-do-i-get-a-tuple-for-my-get)
|
|
177
178
|
- [How do I craft an efficient path?](#how-do-i-craft-an-efficient-path)
|
|
178
179
|
- [Why do I get a RuntimeError when updating with a slice filter?](#why-do-i-get-a-runtimeerror-when-updating-with-a-slice-filter)
|
|
180
|
+
- [Why can't I use patterns to discover keys/attrs on my Stripe object?](#why-cant-i-use-patterns-on-stripe-objects)
|
|
179
181
|
|
|
180
182
|
<a id="safe-traversal-optional-chaining"></a>
|
|
181
183
|
## Safe Traversal (Optional Chaining)
|
|
@@ -893,8 +895,10 @@ _attr_ `@` field uses `getattr/setattr/delattr`.
|
|
|
893
895
|
|
|
894
896
|
A key field is expressed as `a` or part of a dotted expression, such as `a.b`. The
|
|
895
897
|
grammar parser is permissive for what can be in a key field. Pretty much any non-reserved
|
|
896
|
-
char will match.
|
|
897
|
-
|
|
898
|
+
char will match. Key fields work best with objects that have a `keys` method
|
|
899
|
+
(dictionaries and dictionary-like objects). For concrete access like `.name`, objects
|
|
900
|
+
with `__getitem__` and `__contains__` but no `keys` method are also supported via direct
|
|
901
|
+
lookup (though pattern access like `.*` requires `keys`).
|
|
898
902
|
|
|
899
903
|
>>> import dotted
|
|
900
904
|
>>> dotted.get({'a': {'b': 'hello'}}, 'a.b')
|
|
@@ -930,6 +934,27 @@ Two important reasons: nested expressions and patterns.
|
|
|
930
934
|
>>> dotted.get(ns, '@hello.me')
|
|
931
935
|
'goodbye'
|
|
932
936
|
|
|
937
|
+
**Concrete access** (`@name`) works on any object — dotted tries enumeration first,
|
|
938
|
+
then falls back to `getattr` directly. This means objects that provide attributes
|
|
939
|
+
via `__getattr__` (such as Stripe API objects and other dynamic-attribute wrappers)
|
|
940
|
+
work out of the box.
|
|
941
|
+
|
|
942
|
+
**Pattern access** (`@*`, `@/regex/`) requires enumerable attributes. Dotted discovers
|
|
943
|
+
attribute names from `__dict__` (standard instance attributes), `__slots__` (slot-based
|
|
944
|
+
classes), and `_fields` (namedtuples, which store data in the tuple itself and don't
|
|
945
|
+
expose field names through either `__dict__` or `__slots__`):
|
|
946
|
+
|
|
947
|
+
>>> class Point:
|
|
948
|
+
... __slots__ = ('x', 'y')
|
|
949
|
+
... def __init__(self, x, y):
|
|
950
|
+
... self.x = x
|
|
951
|
+
... self.y = y
|
|
952
|
+
>>> dotted.get(Point(3, 4), '@*')
|
|
953
|
+
(3, 4)
|
|
954
|
+
|
|
955
|
+
Objects that rely solely on `__getattr__` without exposing their keys through any of
|
|
956
|
+
these mechanisms will support concrete attr access but not [pattern matching](#patterns).
|
|
957
|
+
|
|
933
958
|
<a id="slicing"></a>
|
|
934
959
|
### Slicing
|
|
935
960
|
|
|
@@ -1114,7 +1139,9 @@ expressions. You'll note that patterns always return a tuple of matches.
|
|
|
1114
1139
|
>>> dotted.get(d, '/h.*/.*')
|
|
1115
1140
|
([1, 2, 3],)
|
|
1116
1141
|
|
|
1117
|
-
Dotted will return all values that match the pattern(s).
|
|
1142
|
+
Dotted will return all values that match the pattern(s). Patterns can only discover
|
|
1143
|
+
what is advertised — they work by enumerating keys or attributes, so the object must
|
|
1144
|
+
expose them via `keys()` (dicts), `__dict__`, `__slots__`, or `_fields` (namedtuples).
|
|
1118
1145
|
|
|
1119
1146
|
<a id="wildcards"></a>
|
|
1120
1147
|
### Wildcards
|
|
@@ -2762,6 +2789,141 @@ Register custom transforms using `register` or the `@transform` decorator:
|
|
|
2762
2789
|
|
|
2763
2790
|
View all registered transforms with `dotted.registry()`.
|
|
2764
2791
|
|
|
2792
|
+
<a id="sqlize"></a>
|
|
2793
|
+
## SQL Translation (`sqlize`)
|
|
2794
|
+
|
|
2795
|
+
> **Under development.** The API shape, output format, and supported subset
|
|
2796
|
+
> of dotted features may still change. Postgres-only, SQLAlchemy-style named
|
|
2797
|
+
> placeholders, scalar paths plus absolute-root references. Patterns (`*`,
|
|
2798
|
+
> `**`, `[*]`, filter-on-array) are not supported yet.
|
|
2799
|
+
|
|
2800
|
+
`dotted.sqlize` translates a dotted path into SQL clause components — a
|
|
2801
|
+
`select` expression, a `where` predicate, and a `params` dict with hoisted
|
|
2802
|
+
values. Literals are hoisted for injection safety; substitutions are
|
|
2803
|
+
preserved as named placeholders so callers can late-bind them.
|
|
2804
|
+
|
|
2805
|
+
The first segment of the dotted path is the SQL column. Any further segments
|
|
2806
|
+
are JSON navigation inside that column (JSONB). This means scalar columns
|
|
2807
|
+
and JSONB columns share one surface:
|
|
2808
|
+
|
|
2809
|
+
>>> import dotted
|
|
2810
|
+
>>> dotted.sqlize("status = 'active'")
|
|
2811
|
+
{'select': 'status', 'where': 'status = :_p1', 'params': {'_p1': 'active'}}
|
|
2812
|
+
|
|
2813
|
+
>>> dotted.sqlize("data.user.age >= 30")
|
|
2814
|
+
{'select': "data #>> '{user,age}'", 'where': "(data #>> '{user,age}')::numeric >= :_p1", 'params': {'_p1': 30}}
|
|
2815
|
+
|
|
2816
|
+
>>> dotted.sqlize("data.user.name") # pure traversal, no predicate
|
|
2817
|
+
{'select': "data #>> '{user,name}'", 'params': {}}
|
|
2818
|
+
|
|
2819
|
+
Guards encode the predicate:
|
|
2820
|
+
|
|
2821
|
+
>>> dotted.sqlize("age >= 30")
|
|
2822
|
+
{'select': 'age', 'where': 'age >= :_p1', 'params': {'_p1': 30}}
|
|
2823
|
+
|
|
2824
|
+
>>> dotted.sqlize("deleted_at = None")
|
|
2825
|
+
{'select': 'deleted_at', 'where': 'deleted_at IS NULL', 'params': {}}
|
|
2826
|
+
|
|
2827
|
+
>>> dotted.sqlize("name = /^alice/")
|
|
2828
|
+
{'select': 'name', 'where': 'name ~ :_p1', 'params': {'_p1': '^alice'}}
|
|
2829
|
+
|
|
2830
|
+
Boolean grouping maps directly onto `AND` / `OR` / `NOT`:
|
|
2831
|
+
|
|
2832
|
+
>>> dotted.sqlize('(age >= 18 & status = "active")')
|
|
2833
|
+
{'where': '(age >= :_p1) AND (status = :_p2)', 'params': {'_p1': 18, '_p2': 'active'}}
|
|
2834
|
+
|
|
2835
|
+
>>> dotted.sqlize('data.(user.age >= 30 & status = "active")')['where']
|
|
2836
|
+
"((data #>> '{user,age}')::numeric >= :_p1) AND ((data #>> '{status}') = :_p2)"
|
|
2837
|
+
|
|
2838
|
+
Guard transforms become SQL casts (`int`, `float`, `str`, `bool`):
|
|
2839
|
+
|
|
2840
|
+
>>> dotted.sqlize("data.user.age|int >= 30")['where']
|
|
2841
|
+
"(data #>> '{user,age}')::int >= :_p1"
|
|
2842
|
+
|
|
2843
|
+
### Hoisted params
|
|
2844
|
+
|
|
2845
|
+
Every RHS value becomes an entry in the `params` dict, keyed by name. This
|
|
2846
|
+
is the mechanism that makes the output safe — user input can't end up as
|
|
2847
|
+
SQL text, only as a parameter value.
|
|
2848
|
+
|
|
2849
|
+
>>> r = dotted.sqlize(f"name = {repr(user_input)}") # user input literal
|
|
2850
|
+
>>> r['where']
|
|
2851
|
+
'name = :_p1'
|
|
2852
|
+
>>> r['params']
|
|
2853
|
+
{'_p1': "'; DROP TABLE users;--"} # stays a value, never parsed as SQL
|
|
2854
|
+
|
|
2855
|
+
Literal values get generated names (`_p1`, `_p2`, …). Substitutions keep
|
|
2856
|
+
their declared names — see below.
|
|
2857
|
+
|
|
2858
|
+
### Substitutions (late binding)
|
|
2859
|
+
|
|
2860
|
+
A substitution is preserved in the SQL output as a named placeholder,
|
|
2861
|
+
letting the caller supply the value at execute time rather than sqlize
|
|
2862
|
+
time:
|
|
2863
|
+
|
|
2864
|
+
>>> r = dotted.sqlize("age >= $(min_age)")
|
|
2865
|
+
>>> r
|
|
2866
|
+
{'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
|
|
2867
|
+
|
|
2868
|
+
`missing` lists names that still need a value. Fill them in before passing
|
|
2869
|
+
`params` to a driver:
|
|
2870
|
+
|
|
2871
|
+
>>> r['params']['min_age'] = 30
|
|
2872
|
+
>>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
|
|
2873
|
+
|
|
2874
|
+
Repeated uses of the same name share one placeholder automatically:
|
|
2875
|
+
|
|
2876
|
+
>>> dotted.sqlize('(age >= $(x) & weight = $(x))')
|
|
2877
|
+
{'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
|
|
2878
|
+
|
|
2879
|
+
If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
|
|
2880
|
+
like any other literal:
|
|
2881
|
+
|
|
2882
|
+
>>> dotted.sqlize("age >= $(min_age)", bindings={'min_age': 30})
|
|
2883
|
+
{'select': 'age', 'where': 'age >= :_p1', 'params': {'_p1': 30}}
|
|
2884
|
+
|
|
2885
|
+
### References
|
|
2886
|
+
|
|
2887
|
+
Absolute-root references (`$$(path)`) map to dynamic JSON key lookups —
|
|
2888
|
+
Postgres's `->` / `#>` accept runtime-computed keys:
|
|
2889
|
+
|
|
2890
|
+
>>> dotted.sqlize('data.$$(data.config.field) = "Alice"')
|
|
2891
|
+
{'select': "data ->> (data #>> '{config,field}')", 'where': "(data ->> (data #>> '{config,field}')) = :_p1", 'params': {'_p1': 'Alice'}}
|
|
2892
|
+
|
|
2893
|
+
Relative references (`^`) and parent references (`^^+`) are not supported
|
|
2894
|
+
yet.
|
|
2895
|
+
|
|
2896
|
+
### Output keys
|
|
2897
|
+
|
|
2898
|
+
Returned dict may contain any subset of:
|
|
2899
|
+
|
|
2900
|
+
| Key | Type | Meaning |
|
|
2901
|
+
|-----------|-------------|-----------------------------------------------------|
|
|
2902
|
+
| `select` | `str` | Expression yielding the value dotted would return |
|
|
2903
|
+
| `where` | `str` | Predicate from guards / filters / boolean groups |
|
|
2904
|
+
| `params` | `dict` | Hoisted values keyed by name |
|
|
2905
|
+
| `missing` | `list[str]` | Substitution names still awaiting a value |
|
|
2906
|
+
|
|
2907
|
+
`select` is omitted when combining predicates across different columns
|
|
2908
|
+
(e.g. top-level groups), since there's no single meaningful expression.
|
|
2909
|
+
`where` is omitted when the path is pure traversal. Usage pattern:
|
|
2910
|
+
|
|
2911
|
+
>>> r = dotted.sqlize("data.user.age >= 30")
|
|
2912
|
+
>>> sql = f"SELECT {r['select']} FROM users WHERE {r['where']}"
|
|
2913
|
+
>>> # sql, r['params'] go to your driver
|
|
2914
|
+
|
|
2915
|
+
### Keyword arguments
|
|
2916
|
+
|
|
2917
|
+
`dotted.sqlize(path, *, bindings=None, flavor='postgres', format='sqlalchemy')`
|
|
2918
|
+
|
|
2919
|
+
- `bindings` — resolve substitutions at sqlize time.
|
|
2920
|
+
- `flavor` — SQL flavor for JSONB operators; only `'postgres'` is
|
|
2921
|
+
implemented.
|
|
2922
|
+
- `format` — placeholder style; only `'sqlalchemy'` (`:name`) is supported
|
|
2923
|
+
in v1.
|
|
2924
|
+
|
|
2925
|
+
Unsupported features and pattern paths raise `dotted.TranslationError`.
|
|
2926
|
+
|
|
2765
2927
|
<a id="constants-and-exceptions"></a>
|
|
2766
2928
|
## Constants and Exceptions
|
|
2767
2929
|
|
|
@@ -2943,3 +3105,15 @@ Prefer a concrete path when it expresses what you want; use pattern + `?` when y
|
|
|
2943
3105
|
A slice filter like `[id=1]` returns the **filtered sublist** as a single value — it operates on the list itself, not on individual items. Updating that sublist in place is ambiguous: the matching items may be at non-contiguous indices, so there's no clean way to splice a replacement back into the original.
|
|
2944
3106
|
|
|
2945
3107
|
But you probably don't want to update a slice filter anyway — instead, use a pattern like `[*&id=1]` which walks the items individually and updates each match in place.
|
|
3108
|
+
|
|
3109
|
+
<a id="why-cant-i-use-patterns-on-stripe-objects"></a>
|
|
3110
|
+
### Why can't I use patterns to discover keys/attrs on my Stripe object?
|
|
3111
|
+
|
|
3112
|
+
Pattern access (`@*`, `.*`) works by enumerating an object's known keys or attributes. Dotted checks `__dict__`, `__slots__`, `_fields` (namedtuples), and `keys()` (for key fields). Stripe objects don't use any of these — they store data in an internal `_data` dict and expose it through a custom `__getattr__`. Since Python has no standard protocol for asking "what keys does your `__getattr__` accept?", there's nothing for dotted to enumerate.
|
|
3113
|
+
|
|
3114
|
+
**Concrete access works fine.** `@name`, `@email`, `.id` — anything where you specify the exact key will work, because dotted falls back to trying `getattr`/`__getitem__` directly.
|
|
3115
|
+
|
|
3116
|
+
**Pattern access can't discover what you don't advertise.** This is a limitation of the object, not dotted. If you need pattern access, convert to a dict first:
|
|
3117
|
+
|
|
3118
|
+
obj_dict = obj.to_dict() # Stripe objects support this
|
|
3119
|
+
dotted.get(obj_dict, '*') # now enumerable
|
|
@@ -127,6 +127,7 @@ Or pick only what you need:
|
|
|
127
127
|
- [Built-in Transforms](#built-in-transforms)
|
|
128
128
|
- [Container transform arguments](#container-transform-arguments)
|
|
129
129
|
- [Custom Transforms](#custom-transforms)
|
|
130
|
+
- [SQL Translation (`sqlize`)](#sqlize) — *under development*
|
|
130
131
|
- [Constants and Exceptions](#constants-and-exceptions)
|
|
131
132
|
- [CLI (`dq`)](#cli-dq)
|
|
132
133
|
- [File input](#file-input)
|
|
@@ -139,6 +140,7 @@ Or pick only what you need:
|
|
|
139
140
|
- [Why do I get a tuple for my get?](#why-do-i-get-a-tuple-for-my-get)
|
|
140
141
|
- [How do I craft an efficient path?](#how-do-i-craft-an-efficient-path)
|
|
141
142
|
- [Why do I get a RuntimeError when updating with a slice filter?](#why-do-i-get-a-runtimeerror-when-updating-with-a-slice-filter)
|
|
143
|
+
- [Why can't I use patterns to discover keys/attrs on my Stripe object?](#why-cant-i-use-patterns-on-stripe-objects)
|
|
142
144
|
|
|
143
145
|
<a id="safe-traversal-optional-chaining"></a>
|
|
144
146
|
## Safe Traversal (Optional Chaining)
|
|
@@ -856,8 +858,10 @@ _attr_ `@` field uses `getattr/setattr/delattr`.
|
|
|
856
858
|
|
|
857
859
|
A key field is expressed as `a` or part of a dotted expression, such as `a.b`. The
|
|
858
860
|
grammar parser is permissive for what can be in a key field. Pretty much any non-reserved
|
|
859
|
-
char will match.
|
|
860
|
-
|
|
861
|
+
char will match. Key fields work best with objects that have a `keys` method
|
|
862
|
+
(dictionaries and dictionary-like objects). For concrete access like `.name`, objects
|
|
863
|
+
with `__getitem__` and `__contains__` but no `keys` method are also supported via direct
|
|
864
|
+
lookup (though pattern access like `.*` requires `keys`).
|
|
861
865
|
|
|
862
866
|
>>> import dotted
|
|
863
867
|
>>> dotted.get({'a': {'b': 'hello'}}, 'a.b')
|
|
@@ -893,6 +897,27 @@ Two important reasons: nested expressions and patterns.
|
|
|
893
897
|
>>> dotted.get(ns, '@hello.me')
|
|
894
898
|
'goodbye'
|
|
895
899
|
|
|
900
|
+
**Concrete access** (`@name`) works on any object — dotted tries enumeration first,
|
|
901
|
+
then falls back to `getattr` directly. This means objects that provide attributes
|
|
902
|
+
via `__getattr__` (such as Stripe API objects and other dynamic-attribute wrappers)
|
|
903
|
+
work out of the box.
|
|
904
|
+
|
|
905
|
+
**Pattern access** (`@*`, `@/regex/`) requires enumerable attributes. Dotted discovers
|
|
906
|
+
attribute names from `__dict__` (standard instance attributes), `__slots__` (slot-based
|
|
907
|
+
classes), and `_fields` (namedtuples, which store data in the tuple itself and don't
|
|
908
|
+
expose field names through either `__dict__` or `__slots__`):
|
|
909
|
+
|
|
910
|
+
>>> class Point:
|
|
911
|
+
... __slots__ = ('x', 'y')
|
|
912
|
+
... def __init__(self, x, y):
|
|
913
|
+
... self.x = x
|
|
914
|
+
... self.y = y
|
|
915
|
+
>>> dotted.get(Point(3, 4), '@*')
|
|
916
|
+
(3, 4)
|
|
917
|
+
|
|
918
|
+
Objects that rely solely on `__getattr__` without exposing their keys through any of
|
|
919
|
+
these mechanisms will support concrete attr access but not [pattern matching](#patterns).
|
|
920
|
+
|
|
896
921
|
<a id="slicing"></a>
|
|
897
922
|
### Slicing
|
|
898
923
|
|
|
@@ -1077,7 +1102,9 @@ expressions. You'll note that patterns always return a tuple of matches.
|
|
|
1077
1102
|
>>> dotted.get(d, '/h.*/.*')
|
|
1078
1103
|
([1, 2, 3],)
|
|
1079
1104
|
|
|
1080
|
-
Dotted will return all values that match the pattern(s).
|
|
1105
|
+
Dotted will return all values that match the pattern(s). Patterns can only discover
|
|
1106
|
+
what is advertised — they work by enumerating keys or attributes, so the object must
|
|
1107
|
+
expose them via `keys()` (dicts), `__dict__`, `__slots__`, or `_fields` (namedtuples).
|
|
1081
1108
|
|
|
1082
1109
|
<a id="wildcards"></a>
|
|
1083
1110
|
### Wildcards
|
|
@@ -2725,6 +2752,141 @@ Register custom transforms using `register` or the `@transform` decorator:
|
|
|
2725
2752
|
|
|
2726
2753
|
View all registered transforms with `dotted.registry()`.
|
|
2727
2754
|
|
|
2755
|
+
<a id="sqlize"></a>
|
|
2756
|
+
## SQL Translation (`sqlize`)
|
|
2757
|
+
|
|
2758
|
+
> **Under development.** The API shape, output format, and supported subset
|
|
2759
|
+
> of dotted features may still change. Postgres-only, SQLAlchemy-style named
|
|
2760
|
+
> placeholders, scalar paths plus absolute-root references. Patterns (`*`,
|
|
2761
|
+
> `**`, `[*]`, filter-on-array) are not supported yet.
|
|
2762
|
+
|
|
2763
|
+
`dotted.sqlize` translates a dotted path into SQL clause components — a
|
|
2764
|
+
`select` expression, a `where` predicate, and a `params` dict with hoisted
|
|
2765
|
+
values. Literals are hoisted for injection safety; substitutions are
|
|
2766
|
+
preserved as named placeholders so callers can late-bind them.
|
|
2767
|
+
|
|
2768
|
+
The first segment of the dotted path is the SQL column. Any further segments
|
|
2769
|
+
are JSON navigation inside that column (JSONB). This means scalar columns
|
|
2770
|
+
and JSONB columns share one surface:
|
|
2771
|
+
|
|
2772
|
+
>>> import dotted
|
|
2773
|
+
>>> dotted.sqlize("status = 'active'")
|
|
2774
|
+
{'select': 'status', 'where': 'status = :_p1', 'params': {'_p1': 'active'}}
|
|
2775
|
+
|
|
2776
|
+
>>> dotted.sqlize("data.user.age >= 30")
|
|
2777
|
+
{'select': "data #>> '{user,age}'", 'where': "(data #>> '{user,age}')::numeric >= :_p1", 'params': {'_p1': 30}}
|
|
2778
|
+
|
|
2779
|
+
>>> dotted.sqlize("data.user.name") # pure traversal, no predicate
|
|
2780
|
+
{'select': "data #>> '{user,name}'", 'params': {}}
|
|
2781
|
+
|
|
2782
|
+
Guards encode the predicate:
|
|
2783
|
+
|
|
2784
|
+
>>> dotted.sqlize("age >= 30")
|
|
2785
|
+
{'select': 'age', 'where': 'age >= :_p1', 'params': {'_p1': 30}}
|
|
2786
|
+
|
|
2787
|
+
>>> dotted.sqlize("deleted_at = None")
|
|
2788
|
+
{'select': 'deleted_at', 'where': 'deleted_at IS NULL', 'params': {}}
|
|
2789
|
+
|
|
2790
|
+
>>> dotted.sqlize("name = /^alice/")
|
|
2791
|
+
{'select': 'name', 'where': 'name ~ :_p1', 'params': {'_p1': '^alice'}}
|
|
2792
|
+
|
|
2793
|
+
Boolean grouping maps directly onto `AND` / `OR` / `NOT`:
|
|
2794
|
+
|
|
2795
|
+
>>> dotted.sqlize('(age >= 18 & status = "active")')
|
|
2796
|
+
{'where': '(age >= :_p1) AND (status = :_p2)', 'params': {'_p1': 18, '_p2': 'active'}}
|
|
2797
|
+
|
|
2798
|
+
>>> dotted.sqlize('data.(user.age >= 30 & status = "active")')['where']
|
|
2799
|
+
"((data #>> '{user,age}')::numeric >= :_p1) AND ((data #>> '{status}') = :_p2)"
|
|
2800
|
+
|
|
2801
|
+
Guard transforms become SQL casts (`int`, `float`, `str`, `bool`):
|
|
2802
|
+
|
|
2803
|
+
>>> dotted.sqlize("data.user.age|int >= 30")['where']
|
|
2804
|
+
"(data #>> '{user,age}')::int >= :_p1"
|
|
2805
|
+
|
|
2806
|
+
### Hoisted params
|
|
2807
|
+
|
|
2808
|
+
Every RHS value becomes an entry in the `params` dict, keyed by name. This
|
|
2809
|
+
is the mechanism that makes the output safe — user input can't end up as
|
|
2810
|
+
SQL text, only as a parameter value.
|
|
2811
|
+
|
|
2812
|
+
>>> r = dotted.sqlize(f"name = {repr(user_input)}") # user input literal
|
|
2813
|
+
>>> r['where']
|
|
2814
|
+
'name = :_p1'
|
|
2815
|
+
>>> r['params']
|
|
2816
|
+
{'_p1': "'; DROP TABLE users;--"} # stays a value, never parsed as SQL
|
|
2817
|
+
|
|
2818
|
+
Literal values get generated names (`_p1`, `_p2`, …). Substitutions keep
|
|
2819
|
+
their declared names — see below.
|
|
2820
|
+
|
|
2821
|
+
### Substitutions (late binding)
|
|
2822
|
+
|
|
2823
|
+
A substitution is preserved in the SQL output as a named placeholder,
|
|
2824
|
+
letting the caller supply the value at execute time rather than sqlize
|
|
2825
|
+
time:
|
|
2826
|
+
|
|
2827
|
+
>>> r = dotted.sqlize("age >= $(min_age)")
|
|
2828
|
+
>>> r
|
|
2829
|
+
{'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
|
|
2830
|
+
|
|
2831
|
+
`missing` lists names that still need a value. Fill them in before passing
|
|
2832
|
+
`params` to a driver:
|
|
2833
|
+
|
|
2834
|
+
>>> r['params']['min_age'] = 30
|
|
2835
|
+
>>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
|
|
2836
|
+
|
|
2837
|
+
Repeated uses of the same name share one placeholder automatically:
|
|
2838
|
+
|
|
2839
|
+
>>> dotted.sqlize('(age >= $(x) & weight = $(x))')
|
|
2840
|
+
{'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
|
|
2841
|
+
|
|
2842
|
+
If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
|
|
2843
|
+
like any other literal:
|
|
2844
|
+
|
|
2845
|
+
>>> dotted.sqlize("age >= $(min_age)", bindings={'min_age': 30})
|
|
2846
|
+
{'select': 'age', 'where': 'age >= :_p1', 'params': {'_p1': 30}}
|
|
2847
|
+
|
|
2848
|
+
### References
|
|
2849
|
+
|
|
2850
|
+
Absolute-root references (`$$(path)`) map to dynamic JSON key lookups —
|
|
2851
|
+
Postgres's `->` / `#>` accept runtime-computed keys:
|
|
2852
|
+
|
|
2853
|
+
>>> dotted.sqlize('data.$$(data.config.field) = "Alice"')
|
|
2854
|
+
{'select': "data ->> (data #>> '{config,field}')", 'where': "(data ->> (data #>> '{config,field}')) = :_p1", 'params': {'_p1': 'Alice'}}
|
|
2855
|
+
|
|
2856
|
+
Relative references (`^`) and parent references (`^^+`) are not supported
|
|
2857
|
+
yet.
|
|
2858
|
+
|
|
2859
|
+
### Output keys
|
|
2860
|
+
|
|
2861
|
+
Returned dict may contain any subset of:
|
|
2862
|
+
|
|
2863
|
+
| Key | Type | Meaning |
|
|
2864
|
+
|-----------|-------------|-----------------------------------------------------|
|
|
2865
|
+
| `select` | `str` | Expression yielding the value dotted would return |
|
|
2866
|
+
| `where` | `str` | Predicate from guards / filters / boolean groups |
|
|
2867
|
+
| `params` | `dict` | Hoisted values keyed by name |
|
|
2868
|
+
| `missing` | `list[str]` | Substitution names still awaiting a value |
|
|
2869
|
+
|
|
2870
|
+
`select` is omitted when combining predicates across different columns
|
|
2871
|
+
(e.g. top-level groups), since there's no single meaningful expression.
|
|
2872
|
+
`where` is omitted when the path is pure traversal. Usage pattern:
|
|
2873
|
+
|
|
2874
|
+
>>> r = dotted.sqlize("data.user.age >= 30")
|
|
2875
|
+
>>> sql = f"SELECT {r['select']} FROM users WHERE {r['where']}"
|
|
2876
|
+
>>> # sql, r['params'] go to your driver
|
|
2877
|
+
|
|
2878
|
+
### Keyword arguments
|
|
2879
|
+
|
|
2880
|
+
`dotted.sqlize(path, *, bindings=None, flavor='postgres', format='sqlalchemy')`
|
|
2881
|
+
|
|
2882
|
+
- `bindings` — resolve substitutions at sqlize time.
|
|
2883
|
+
- `flavor` — SQL flavor for JSONB operators; only `'postgres'` is
|
|
2884
|
+
implemented.
|
|
2885
|
+
- `format` — placeholder style; only `'sqlalchemy'` (`:name`) is supported
|
|
2886
|
+
in v1.
|
|
2887
|
+
|
|
2888
|
+
Unsupported features and pattern paths raise `dotted.TranslationError`.
|
|
2889
|
+
|
|
2728
2890
|
<a id="constants-and-exceptions"></a>
|
|
2729
2891
|
## Constants and Exceptions
|
|
2730
2892
|
|
|
@@ -2906,3 +3068,15 @@ Prefer a concrete path when it expresses what you want; use pattern + `?` when y
|
|
|
2906
3068
|
A slice filter like `[id=1]` returns the **filtered sublist** as a single value — it operates on the list itself, not on individual items. Updating that sublist in place is ambiguous: the matching items may be at non-contiguous indices, so there's no clean way to splice a replacement back into the original.
|
|
2907
3069
|
|
|
2908
3070
|
But you probably don't want to update a slice filter anyway — instead, use a pattern like `[*&id=1]` which walks the items individually and updates each match in place.
|
|
3071
|
+
|
|
3072
|
+
<a id="why-cant-i-use-patterns-on-stripe-objects"></a>
|
|
3073
|
+
### Why can't I use patterns to discover keys/attrs on my Stripe object?
|
|
3074
|
+
|
|
3075
|
+
Pattern access (`@*`, `.*`) works by enumerating an object's known keys or attributes. Dotted checks `__dict__`, `__slots__`, `_fields` (namedtuples), and `keys()` (for key fields). Stripe objects don't use any of these — they store data in an internal `_data` dict and expose it through a custom `__getattr__`. Since Python has no standard protocol for asking "what keys does your `__getattr__` accept?", there's nothing for dotted to enumerate.
|
|
3076
|
+
|
|
3077
|
+
**Concrete access works fine.** `@name`, `@email`, `.id` — anything where you specify the exact key will work, because dotted falls back to trying `getattr`/`__getitem__` directly.
|
|
3078
|
+
|
|
3079
|
+
**Pattern access can't discover what you don't advertise.** This is a limitation of the object, not dotted. If you need pattern access, convert to a dict first:
|
|
3080
|
+
|
|
3081
|
+
obj_dict = obj.to_dict() # Stripe objects support this
|
|
3082
|
+
dotted.get(obj_dict, '*') # now enumerable
|
|
@@ -68,6 +68,7 @@ from .api import \
|
|
|
68
68
|
update, update_multi, pack, update_if, update_if_multi, \
|
|
69
69
|
remove, remove_multi, remove_if, remove_if_multi, \
|
|
70
70
|
pluck, pluck_multi, walk, walk_multi, unpack, keys, values, items
|
|
71
|
+
from .sqlize import sqlize, TranslationError
|
|
71
72
|
|
|
72
73
|
__all__ = [
|
|
73
74
|
# Core
|
|
@@ -82,6 +83,8 @@ __all__ = [
|
|
|
82
83
|
'apply', 'apply_multi', 'register', 'transform',
|
|
83
84
|
# Utility
|
|
84
85
|
'parse', 'assemble', 'assemble_multi', 'quote', 'is_pattern', 'is_template', 'is_inverted', 'mutable',
|
|
86
|
+
# SQL
|
|
87
|
+
'sqlize', 'TranslationError',
|
|
85
88
|
# Constants
|
|
86
89
|
'ANY', 'AUTO', 'Attrs', 'GroupMode',
|
|
87
90
|
]
|
|
@@ -411,6 +411,13 @@ class Key(AccessOp):
|
|
|
411
411
|
return ()
|
|
412
412
|
key = self.op.value
|
|
413
413
|
if not isinstance(key, int):
|
|
414
|
+
# Concrete non-int key on object with __getitem__ but no keys():
|
|
415
|
+
# try direct access (e.g. Stripe-like objects)
|
|
416
|
+
if hasattr(node, '__contains__') and key in node:
|
|
417
|
+
try:
|
|
418
|
+
return iter([(key, node[key])])
|
|
419
|
+
except (KeyError, TypeError):
|
|
420
|
+
pass
|
|
414
421
|
return ()
|
|
415
422
|
if not filtered:
|
|
416
423
|
return self._items(node, range(len(node)), filtered=False)
|
|
@@ -515,18 +522,65 @@ class Attr(Key):
|
|
|
515
522
|
|
|
516
523
|
return _items()
|
|
517
524
|
|
|
525
|
+
@staticmethod
|
|
526
|
+
def _all_keys(node):
|
|
527
|
+
"""
|
|
528
|
+
Collect all known attribute names from an object:
|
|
529
|
+
__dict__, _fields (namedtuple), and __slots__ (MRO walk).
|
|
530
|
+
Preserves insertion order.
|
|
531
|
+
"""
|
|
532
|
+
seen = set()
|
|
533
|
+
keys = []
|
|
534
|
+
try:
|
|
535
|
+
for k in node.__dict__.keys():
|
|
536
|
+
if k not in seen:
|
|
537
|
+
seen.add(k)
|
|
538
|
+
keys.append(k)
|
|
539
|
+
except AttributeError:
|
|
540
|
+
pass
|
|
541
|
+
if isinstance(node, tuple):
|
|
542
|
+
for k in getattr(node, '_fields', ()):
|
|
543
|
+
if k not in seen:
|
|
544
|
+
seen.add(k)
|
|
545
|
+
keys.append(k)
|
|
546
|
+
for cls in type(node).__mro__:
|
|
547
|
+
for s in getattr(cls, '__slots__', ()):
|
|
548
|
+
if s not in ('__dict__', '__weakref__') and s not in seen:
|
|
549
|
+
seen.add(s)
|
|
550
|
+
keys.append(s)
|
|
551
|
+
return keys
|
|
552
|
+
|
|
553
|
+
def _concrete_fallback(self, node, inner, filtered):
|
|
554
|
+
"""
|
|
555
|
+
Wrap an items iterator: yield from inner, and if nothing
|
|
556
|
+
was yielded, try getattr directly for concrete (non-pattern) ops.
|
|
557
|
+
"""
|
|
558
|
+
found = False
|
|
559
|
+
for pair in inner:
|
|
560
|
+
found = True
|
|
561
|
+
yield pair
|
|
562
|
+
if found:
|
|
563
|
+
return
|
|
564
|
+
key = self.op.value
|
|
565
|
+
try:
|
|
566
|
+
v = getattr(node, key)
|
|
567
|
+
except AttributeError:
|
|
568
|
+
return
|
|
569
|
+
if filtered and not tuple(self.filtered(iter((v,)))):
|
|
570
|
+
return
|
|
571
|
+
yield (key, v)
|
|
572
|
+
|
|
518
573
|
def items(self, node, filtered=True, **kwargs):
|
|
519
574
|
if self.is_reference():
|
|
520
575
|
return itertools.chain.from_iterable(
|
|
521
576
|
r.items(node, filtered=filtered, **kwargs)
|
|
522
577
|
for r in self._resolved(node=node, **kwargs))
|
|
523
|
-
|
|
524
|
-
try:
|
|
525
|
-
all_keys = node.__dict__.keys()
|
|
526
|
-
except AttributeError:
|
|
527
|
-
all_keys = getattr(node, '_fields', ())
|
|
578
|
+
all_keys = self._all_keys(node)
|
|
528
579
|
keys = self.op.matches(all_keys) if filtered else all_keys
|
|
529
|
-
|
|
580
|
+
result = self._items(node, keys, filtered)
|
|
581
|
+
if not self.is_pattern():
|
|
582
|
+
result = self._concrete_fallback(node, result, filtered)
|
|
583
|
+
return result
|
|
530
584
|
|
|
531
585
|
def default(self):
|
|
532
586
|
o = types.SimpleNamespace()
|
|
@@ -555,10 +609,7 @@ class Attr(Key):
|
|
|
555
609
|
return self.update(node, self.op.value, val)
|
|
556
610
|
keys = tuple(self.keys(node))
|
|
557
611
|
# Try mutable update first
|
|
558
|
-
|
|
559
|
-
node_keys = node.__dict__.keys()
|
|
560
|
-
except AttributeError:
|
|
561
|
-
node_keys = getattr(node, '_fields', ())
|
|
612
|
+
node_keys = self._all_keys(node)
|
|
562
613
|
iterable = ((k, getattr(node, k)) for k in node_keys if k not in keys)
|
|
563
614
|
items = list(itertools.chain(iterable, ((k, val) for k in keys)))
|
|
564
615
|
try:
|
|
@@ -106,7 +106,8 @@ def _is_container(obj):
|
|
|
106
106
|
return False
|
|
107
107
|
# Dict-like, sequence-like, or has attributes
|
|
108
108
|
return (hasattr(obj, 'keys') or hasattr(obj, '__len__') or
|
|
109
|
-
hasattr(obj, '__iter__') or hasattr(obj, '__dict__')
|
|
109
|
+
hasattr(obj, '__iter__') or hasattr(obj, '__dict__') or
|
|
110
|
+
hasattr(type(obj), '__slots__'))
|
|
110
111
|
|
|
111
112
|
|
|
112
113
|
def _format_path(segments):
|