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.
Files changed (78) hide show
  1. {dotted_notation-0.42.8/dotted_notation.egg-info → dotted_notation-0.43.0}/PKG-INFO +178 -4
  2. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/README.md +177 -3
  3. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/__init__.py +3 -0
  4. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/access.py +61 -10
  5. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/engine.py +2 -1
  6. dotted_notation-0.43.0/dotted/sqlize.py +472 -0
  7. {dotted_notation-0.42.8 → dotted_notation-0.43.0/dotted_notation.egg-info}/PKG-INFO +178 -4
  8. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/SOURCES.txt +2 -0
  9. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/setup.py +1 -1
  10. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_attrs.py +195 -0
  11. dotted_notation-0.43.0/tests/test_sqlize.py +382 -0
  12. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/LICENSE +0 -0
  13. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/__main__.py +0 -0
  14. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/api.py +0 -0
  15. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/base.py +0 -0
  16. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/__init__.py +0 -0
  17. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/_compat.py +0 -0
  18. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/formats.py +0 -0
  19. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/cli/main.py +0 -0
  20. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/containers.py +0 -0
  21. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/filters.py +0 -0
  22. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/grammar.py +0 -0
  23. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/groups.py +0 -0
  24. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/matchers.py +0 -0
  25. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/predicates.py +0 -0
  26. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/recursive.py +0 -0
  27. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/results.py +0 -0
  28. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/transforms.py +0 -0
  29. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/utils.py +0 -0
  30. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/utypes.py +0 -0
  31. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted/wrappers.py +0 -0
  32. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/dependency_links.txt +0 -0
  33. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/entry_points.txt +0 -0
  34. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/requires.txt +0 -0
  35. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/dotted_notation.egg-info/top_level.txt +0 -0
  36. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/setup.cfg +0 -0
  37. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/__init__.py +0 -0
  38. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_api.py +0 -0
  39. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_appender.py +0 -0
  40. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_assemble.py +0 -0
  41. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_bindings.py +0 -0
  42. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_cli.py +0 -0
  43. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_concat.py +0 -0
  44. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_container_filter.py +0 -0
  45. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_cut.py +0 -0
  46. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_empty.py +0 -0
  47. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_filter_keyvalue.py +0 -0
  48. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_get.py +0 -0
  49. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_guard_transforms.py +0 -0
  50. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_invert.py +0 -0
  51. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_keys_values.py +0 -0
  52. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_match.py +0 -0
  53. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_matchable.py +0 -0
  54. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_named_subst.py +0 -0
  55. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_negation.py +0 -0
  56. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_nop.py +0 -0
  57. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_numeric.py +0 -0
  58. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_opgroup.py +0 -0
  59. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_pluck.py +0 -0
  60. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_predicates.py +0 -0
  61. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_quote_idempotent.py +0 -0
  62. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_recursive.py +0 -0
  63. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_reference.py +0 -0
  64. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_replace.py +0 -0
  65. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_slice.py +0 -0
  66. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_softcut.py +0 -0
  67. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_strict.py +0 -0
  68. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_string_glob.py +0 -0
  69. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_subst_escape.py +0 -0
  70. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_subst_transforms.py +0 -0
  71. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_threading.py +0 -0
  72. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_transforms.py +0 -0
  73. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_translate.py +0 -0
  74. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_type_restriction.py +0 -0
  75. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_unpack.py +0 -0
  76. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_update.py +0 -0
  77. {dotted_notation-0.42.8 → dotted_notation-0.43.0}/tests/test_update_if.py +0 -0
  78. {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.42.8
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. Note that key fields will only work on objects that have a `keys`
897
- method. Basically, they work with dictionary or dictionary-like objects.
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. Note that key fields will only work on objects that have a `keys`
860
- method. Basically, they work with dictionary or dictionary-like objects.
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
- # Try __dict__ first (normal objects), then _fields (namedtuple)
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
- return self._items(node, keys, filtered)
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
- try:
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):