dotted-notation 0.43.4__tar.gz → 0.43.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {dotted_notation-0.43.4/dotted_notation.egg-info → dotted_notation-0.43.6}/PKG-INFO +65 -10
  2. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/README.md +64 -9
  3. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/__init__.py +6 -2
  4. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/access.py +8 -0
  5. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/api.py +88 -0
  6. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/base.py +6 -0
  7. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/filters.py +45 -0
  8. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/groups.py +10 -0
  9. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/sqlize.py +418 -63
  10. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/wrappers.py +25 -0
  11. {dotted_notation-0.43.4 → dotted_notation-0.43.6/dotted_notation.egg-info}/PKG-INFO +65 -10
  12. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/setup.py +1 -1
  13. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_api.py +109 -0
  14. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_sqlize.py +126 -38
  15. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/LICENSE +0 -0
  16. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/__main__.py +0 -0
  17. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/cli/__init__.py +0 -0
  18. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/cli/_compat.py +0 -0
  19. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/cli/formats.py +0 -0
  20. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/cli/main.py +0 -0
  21. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/containers.py +0 -0
  22. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/engine.py +0 -0
  23. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/grammar.py +0 -0
  24. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/matchers.py +0 -0
  25. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/predicates.py +0 -0
  26. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/recursive.py +0 -0
  27. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/results.py +0 -0
  28. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/transforms.py +0 -0
  29. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/utils.py +0 -0
  30. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted/utypes.py +0 -0
  31. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted_notation.egg-info/SOURCES.txt +0 -0
  32. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted_notation.egg-info/dependency_links.txt +0 -0
  33. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted_notation.egg-info/entry_points.txt +0 -0
  34. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted_notation.egg-info/requires.txt +0 -0
  35. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/dotted_notation.egg-info/top_level.txt +0 -0
  36. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/setup.cfg +0 -0
  37. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/__init__.py +0 -0
  38. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_appender.py +0 -0
  39. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_assemble.py +0 -0
  40. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_attrs.py +0 -0
  41. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_bindings.py +0 -0
  42. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_cli.py +0 -0
  43. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_concat.py +0 -0
  44. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_container_filter.py +0 -0
  45. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_cut.py +0 -0
  46. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_empty.py +0 -0
  47. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_filter_keyvalue.py +0 -0
  48. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_get.py +0 -0
  49. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_guard_transforms.py +0 -0
  50. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_invert.py +0 -0
  51. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_json_sentinels.py +0 -0
  52. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_keys_values.py +0 -0
  53. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_match.py +0 -0
  54. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_matchable.py +0 -0
  55. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_named_subst.py +0 -0
  56. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_negation.py +0 -0
  57. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_nop.py +0 -0
  58. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_numeric.py +0 -0
  59. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_opgroup.py +0 -0
  60. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_pluck.py +0 -0
  61. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_predicates.py +0 -0
  62. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_quote_idempotent.py +0 -0
  63. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_recursive.py +0 -0
  64. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_reference.py +0 -0
  65. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_replace.py +0 -0
  66. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_slice.py +0 -0
  67. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_softcut.py +0 -0
  68. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_strict.py +0 -0
  69. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_string_glob.py +0 -0
  70. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_subst_escape.py +0 -0
  71. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_subst_transforms.py +0 -0
  72. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_threading.py +0 -0
  73. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_transforms.py +0 -0
  74. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_translate.py +0 -0
  75. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_type_restriction.py +0 -0
  76. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_unpack.py +0 -0
  77. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_update.py +0 -0
  78. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_update_if.py +0 -0
  79. {dotted_notation-0.43.4 → dotted_notation-0.43.6}/tests/test_value_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotted_notation
3
- Version: 0.43.4
3
+ Version: 0.43.6
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
@@ -2801,9 +2801,11 @@ View all registered transforms with `dotted.registry()`.
2801
2801
  ## SQL Translation (`sqlize`)
2802
2802
 
2803
2803
  > **Under development.** The API shape, output format, and supported subset
2804
- > of dotted features may still change. Postgres-only, SQLAlchemy-style named
2805
- > placeholders, scalar paths plus absolute-root references. Patterns (`*`,
2806
- > `**`, `[*]`, filter-on-array) are not supported yet.
2804
+ > of dotted features may still change. Postgres-only, SQLAlchemy-style
2805
+ > named placeholders. Pattern paths (`*`, `[*]`, `**`, filter brackets)
2806
+ > are supported only as WHERE-side existence checks via
2807
+ > `jsonb_path_exists`; pattern paths that yield sets as SELECT (needing
2808
+ > `LATERAL jsonb_path_query`) are not supported yet.
2807
2809
 
2808
2810
  `dotted.sqlize` translates a dotted path into SQL clause components — a
2809
2811
  `select` expression, a `where` predicate, and a `params` dict with hoisted
@@ -2871,18 +2873,36 @@ time:
2871
2873
 
2872
2874
  >>> r = dotted.sqlize("age >= $(min_age)")
2873
2875
  >>> r
2874
- {'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
2876
+ {'select': 'age', 'where': 'age >= :min_age', 'params': {},
2877
+ 'unbound': {'min_age': 'min_age'}}
2875
2878
 
2876
- `missing` lists names that still need a value. Fill them in before passing
2877
- `params` to a driver:
2879
+ `unbound` is a dict keyed by the **SQL bind parameter name** the name
2880
+ that appears in the SQL `:placeholder` and as a key in `params`. Each
2881
+ value is the **original substitution name** (what was inside `$(...)`),
2882
+ kept as provenance so callers can trace a bind back to its source.
2883
+
2884
+ `unbound.keys()` is the list of bind slots the caller needs to fill.
2885
+ Most often you just fill `params` by bind name directly:
2878
2886
 
2879
2887
  >>> r['params']['min_age'] = 30
2880
2888
  >>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
2881
2889
 
2882
- Repeated uses of the same name share one placeholder automatically:
2890
+ Non-identifier names (dotted paths, quoted keys, spaces, etc.) hash to a
2891
+ deterministic `_s_<hex>` bind name — the bind-side name is different
2892
+ from what appeared in source, but the original is preserved in the
2893
+ `unbound` value:
2894
+
2895
+ >>> r = dotted.sqlize('age >= $(user.min_age)')
2896
+ >>> r['where'] # doctest: +ELLIPSIS
2897
+ 'age >= :_s_...'
2898
+ >>> list(r['unbound'].values())
2899
+ ['user.min_age']
2900
+
2901
+ Repeated uses of the same name share one entry automatically:
2883
2902
 
2884
2903
  >>> dotted.sqlize('(age >= $(x) & weight = $(x))')
2885
- {'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
2904
+ {'where': '(age >= :x) AND (weight = :x)', 'params': {},
2905
+ 'unbound': {'x': 'x'}}
2886
2906
 
2887
2907
  If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
2888
2908
  like any other literal:
@@ -2901,6 +2921,41 @@ Postgres's `->` / `#>` accept runtime-computed keys:
2901
2921
  Relative references (`^`) and parent references (`^^+`) are not supported
2902
2922
  yet.
2903
2923
 
2924
+ ### Pattern paths
2925
+
2926
+ Paths that contain wildcards (`*`, `[*]`), recursive descent (`**`), or
2927
+ bracket filters (`[pred]`, `[*&pred]`) translate to a Postgres
2928
+ `jsonb_path_exists(…)` WHERE predicate. The dotted path becomes a JSONPath
2929
+ expression; guard values embed inline when safely literal (numbers,
2930
+ booleans, null) or flow through a `jsonb_build_object` vars argument
2931
+ (strings, substitutions).
2932
+
2933
+ >>> dotted.sqlize("data.users[*].age >= 30")['where']
2934
+ "jsonb_path_exists(data, '$.users[*].age ? (@ >= 30)')"
2935
+
2936
+ >>> dotted.sqlize("data.**.active = True")['where']
2937
+ "jsonb_path_exists(data, '$.**.active ? (@ == true)')"
2938
+
2939
+ >>> dotted.sqlize("data.users[age>=30]")['where']
2940
+ "jsonb_path_exists(data, '$.users[*] ? (@.age >= 30)')"
2941
+
2942
+ >>> dotted.sqlize('data.users[*&age>=30].name = "alice"')['where']
2943
+ "jsonb_path_exists(data, '$.users[*] ? (@.age >= 30).name ? (@ == :_p1)', jsonb_build_object('_p1', :_p1))"
2944
+
2945
+ Pattern predicates compose with scalar ones via dotted's `&` / `,` / `!`:
2946
+
2947
+ >>> dotted.sqlize('(data.users[*].age >= 30 & status = "active")')['where']
2948
+ "(jsonb_path_exists(data, '$.users[*].age ? (@ >= 30)')) AND (status = :_p1)"
2949
+
2950
+ `select` is set to the column itself — pattern paths don't have a single
2951
+ extractable value. Use the WHERE predicate to filter rows; write your
2952
+ own SELECT for the columns you want.
2953
+
2954
+ Not yet supported:
2955
+ - Pattern paths as SELECT (needing `LATERAL jsonb_path_query`)
2956
+ - Regex-in-access like `data./prefix_\d+/.field`
2957
+ - Pattern refs
2958
+
2904
2959
  ### Output keys
2905
2960
 
2906
2961
  Returned dict may contain any subset of:
@@ -2910,7 +2965,7 @@ Returned dict may contain any subset of:
2910
2965
  | `select` | `str` | Expression yielding the value dotted would return |
2911
2966
  | `where` | `str` | Predicate from guards / filters / boolean groups |
2912
2967
  | `params` | `dict` | Hoisted values keyed by name |
2913
- | `missing` | `list[str]` | Substitution names still awaiting a value |
2968
+ | `unbound` | `dict` | Deferred substitutions: `{bind_param_name: original_name}` |
2914
2969
 
2915
2970
  `select` is omitted when combining predicates across different columns
2916
2971
  (e.g. top-level groups), since there's no single meaningful expression.
@@ -2764,9 +2764,11 @@ View all registered transforms with `dotted.registry()`.
2764
2764
  ## SQL Translation (`sqlize`)
2765
2765
 
2766
2766
  > **Under development.** The API shape, output format, and supported subset
2767
- > of dotted features may still change. Postgres-only, SQLAlchemy-style named
2768
- > placeholders, scalar paths plus absolute-root references. Patterns (`*`,
2769
- > `**`, `[*]`, filter-on-array) are not supported yet.
2767
+ > of dotted features may still change. Postgres-only, SQLAlchemy-style
2768
+ > named placeholders. Pattern paths (`*`, `[*]`, `**`, filter brackets)
2769
+ > are supported only as WHERE-side existence checks via
2770
+ > `jsonb_path_exists`; pattern paths that yield sets as SELECT (needing
2771
+ > `LATERAL jsonb_path_query`) are not supported yet.
2770
2772
 
2771
2773
  `dotted.sqlize` translates a dotted path into SQL clause components — a
2772
2774
  `select` expression, a `where` predicate, and a `params` dict with hoisted
@@ -2834,18 +2836,36 @@ time:
2834
2836
 
2835
2837
  >>> r = dotted.sqlize("age >= $(min_age)")
2836
2838
  >>> r
2837
- {'select': 'age', 'where': 'age >= :min_age', 'params': {}, 'missing': ['min_age']}
2839
+ {'select': 'age', 'where': 'age >= :min_age', 'params': {},
2840
+ 'unbound': {'min_age': 'min_age'}}
2838
2841
 
2839
- `missing` lists names that still need a value. Fill them in before passing
2840
- `params` to a driver:
2842
+ `unbound` is a dict keyed by the **SQL bind parameter name** the name
2843
+ that appears in the SQL `:placeholder` and as a key in `params`. Each
2844
+ value is the **original substitution name** (what was inside `$(...)`),
2845
+ kept as provenance so callers can trace a bind back to its source.
2846
+
2847
+ `unbound.keys()` is the list of bind slots the caller needs to fill.
2848
+ Most often you just fill `params` by bind name directly:
2841
2849
 
2842
2850
  >>> r['params']['min_age'] = 30
2843
2851
  >>> # hand (r['where'], r['params']) to cursor.execute / session.execute / etc.
2844
2852
 
2845
- Repeated uses of the same name share one placeholder automatically:
2853
+ Non-identifier names (dotted paths, quoted keys, spaces, etc.) hash to a
2854
+ deterministic `_s_<hex>` bind name — the bind-side name is different
2855
+ from what appeared in source, but the original is preserved in the
2856
+ `unbound` value:
2857
+
2858
+ >>> r = dotted.sqlize('age >= $(user.min_age)')
2859
+ >>> r['where'] # doctest: +ELLIPSIS
2860
+ 'age >= :_s_...'
2861
+ >>> list(r['unbound'].values())
2862
+ ['user.min_age']
2863
+
2864
+ Repeated uses of the same name share one entry automatically:
2846
2865
 
2847
2866
  >>> dotted.sqlize('(age >= $(x) & weight = $(x))')
2848
- {'where': '(age >= :x) AND (weight = :x)', 'params': {}, 'missing': ['x']}
2867
+ {'where': '(age >= :x) AND (weight = :x)', 'params': {},
2868
+ 'unbound': {'x': 'x'}}
2849
2869
 
2850
2870
  If you pass `bindings=`, substitutions are resolved at sqlize time and hoisted
2851
2871
  like any other literal:
@@ -2864,6 +2884,41 @@ Postgres's `->` / `#>` accept runtime-computed keys:
2864
2884
  Relative references (`^`) and parent references (`^^+`) are not supported
2865
2885
  yet.
2866
2886
 
2887
+ ### Pattern paths
2888
+
2889
+ Paths that contain wildcards (`*`, `[*]`), recursive descent (`**`), or
2890
+ bracket filters (`[pred]`, `[*&pred]`) translate to a Postgres
2891
+ `jsonb_path_exists(…)` WHERE predicate. The dotted path becomes a JSONPath
2892
+ expression; guard values embed inline when safely literal (numbers,
2893
+ booleans, null) or flow through a `jsonb_build_object` vars argument
2894
+ (strings, substitutions).
2895
+
2896
+ >>> dotted.sqlize("data.users[*].age >= 30")['where']
2897
+ "jsonb_path_exists(data, '$.users[*].age ? (@ >= 30)')"
2898
+
2899
+ >>> dotted.sqlize("data.**.active = True")['where']
2900
+ "jsonb_path_exists(data, '$.**.active ? (@ == true)')"
2901
+
2902
+ >>> dotted.sqlize("data.users[age>=30]")['where']
2903
+ "jsonb_path_exists(data, '$.users[*] ? (@.age >= 30)')"
2904
+
2905
+ >>> dotted.sqlize('data.users[*&age>=30].name = "alice"')['where']
2906
+ "jsonb_path_exists(data, '$.users[*] ? (@.age >= 30).name ? (@ == :_p1)', jsonb_build_object('_p1', :_p1))"
2907
+
2908
+ Pattern predicates compose with scalar ones via dotted's `&` / `,` / `!`:
2909
+
2910
+ >>> dotted.sqlize('(data.users[*].age >= 30 & status = "active")')['where']
2911
+ "(jsonb_path_exists(data, '$.users[*].age ? (@ >= 30)')) AND (status = :_p1)"
2912
+
2913
+ `select` is set to the column itself — pattern paths don't have a single
2914
+ extractable value. Use the WHERE predicate to filter rows; write your
2915
+ own SELECT for the columns you want.
2916
+
2917
+ Not yet supported:
2918
+ - Pattern paths as SELECT (needing `LATERAL jsonb_path_query`)
2919
+ - Regex-in-access like `data./prefix_\d+/.field`
2920
+ - Pattern refs
2921
+
2867
2922
  ### Output keys
2868
2923
 
2869
2924
  Returned dict may contain any subset of:
@@ -2873,7 +2928,7 @@ Returned dict may contain any subset of:
2873
2928
  | `select` | `str` | Expression yielding the value dotted would return |
2874
2929
  | `where` | `str` | Predicate from guards / filters / boolean groups |
2875
2930
  | `params` | `dict` | Hoisted values keyed by name |
2876
- | `missing` | `list[str]` | Substitution names still awaiting a value |
2931
+ | `unbound` | `dict` | Deferred substitutions: `{bind_param_name: original_name}` |
2877
2932
 
2878
2933
  `select` is omitted when combining predicates across different columns
2879
2934
  (e.g. top-level groups), since there's no single meaningful expression.
@@ -56,7 +56,8 @@ For full documentation including all options and flags:
56
56
  or see README.md
57
57
  """
58
58
  from .api import \
59
- parse, is_pattern, is_template, is_inverted, mutable, quote, ANY, AUTO, Attrs, GroupMode, \
59
+ parse, is_pattern, is_template, is_reference, is_indeterminate, is_simple, \
60
+ is_inverted, mutable, quote, ANY, AUTO, Attrs, GroupMode, \
60
61
  register, transform, \
61
62
  assemble, assemble_multi, \
62
63
  build, build_multi, \
@@ -82,7 +83,10 @@ __all__ = [
82
83
  # Transform
83
84
  'apply', 'apply_multi', 'register', 'transform',
84
85
  # Utility
85
- 'parse', 'assemble', 'assemble_multi', 'quote', 'is_pattern', 'is_template', 'is_inverted', 'mutable',
86
+ 'parse', 'assemble', 'assemble_multi', 'quote',
87
+ 'is_pattern', 'is_template', 'is_reference',
88
+ 'is_indeterminate', 'is_simple',
89
+ 'is_inverted', 'mutable',
86
90
  # SQL
87
91
  'sqlize', 'TranslationError',
88
92
  # Constants
@@ -870,6 +870,14 @@ class SliceFilter(BaseOp):
870
870
  hasattr(f, 'is_template') and f.is_template()
871
871
  for f in self.filters)
872
872
 
873
+ def is_reference(self):
874
+ """
875
+ True if any attached filter contains an internal reference.
876
+ """
877
+ return any(
878
+ hasattr(f, 'is_reference') and f.is_reference()
879
+ for f in self.filters)
880
+
873
881
  def is_empty(self, node):
874
882
  return not node
875
883
 
@@ -188,6 +188,94 @@ def is_template(key):
188
188
  return _is_template(parse(key))
189
189
 
190
190
 
191
+ @functools.lru_cache(CACHE_SIZE)
192
+ def _is_reference(ops):
193
+ if not ops:
194
+ return False
195
+ if ops[0].is_reference():
196
+ return True
197
+ return _is_reference(ops[1:])
198
+
199
+
200
+ def is_reference(key):
201
+ """
202
+ True if dotted path contains internal references ($$(...)).
203
+ Walks into wraps, filters, and group branches.
204
+
205
+ >>> is_reference('a.$$(b)')
206
+ True
207
+ >>> is_reference('a.b')
208
+ False
209
+ >>> is_reference('a.$(x)')
210
+ False
211
+ """
212
+ if isinstance(key, results.Dotted):
213
+ return _is_reference(key)
214
+ return _is_reference(parse(key))
215
+
216
+
217
+ def is_indeterminate(key):
218
+ """
219
+ True if the path contains substitutions or references — anything
220
+ whose access target isn't pinned down by the source alone and
221
+ requires bindings or data to resolve. Patterns are not indeterminate:
222
+ they describe a set inherent to the query.
223
+
224
+ >>> is_indeterminate('a.$(x)')
225
+ True
226
+ >>> is_indeterminate('a.$$(b)')
227
+ True
228
+ >>> is_indeterminate('a.b')
229
+ False
230
+ >>> is_indeterminate('a.*.b')
231
+ False
232
+ """
233
+ parsed = key if isinstance(key, results.Dotted) else parse(key)
234
+ return _is_template(parsed) or _is_reference(parsed)
235
+
236
+
237
+ @functools.lru_cache(CACHE_SIZE)
238
+ def _is_simple(ops):
239
+ for op in ops:
240
+ if not isinstance(op, (access.Key, access.Attr, access.Slot)):
241
+ return False
242
+ if not isinstance(op.op, matchers.Const):
243
+ return False
244
+ return True
245
+
246
+
247
+ def is_simple(key):
248
+ """
249
+ True if the path is a plain chain of access ops (Key, Attr, Slot)
250
+ with literal matchers — no patterns, substitutions, references,
251
+ guards, transforms, filters, groups, or other decorations.
252
+
253
+ This is the strictest classification: paths that spell out one
254
+ specific access target character-for-character.
255
+
256
+ >>> is_simple('a.b.c')
257
+ True
258
+ >>> is_simple('a[0].b')
259
+ True
260
+ >>> is_simple('a@attr')
261
+ True
262
+ >>> is_simple('a.*.b')
263
+ False
264
+ >>> is_simple('a=30')
265
+ False
266
+ >>> is_simple('a|int')
267
+ False
268
+ >>> is_simple('a.$(x)')
269
+ False
270
+ >>> is_simple('a.$$(b)')
271
+ False
272
+ """
273
+ parsed = key if isinstance(key, results.Dotted) else parse(key)
274
+ if parsed.transforms:
275
+ return False
276
+ return _is_simple(parsed)
277
+
278
+
191
279
  def is_inverted(key):
192
280
  """
193
281
  True if an inverted style pattern
@@ -179,6 +179,12 @@ class TraversalOp(Op):
179
179
  """
180
180
  return False
181
181
 
182
+ def is_reference(self):
183
+ """
184
+ True if this op contains an internal reference.
185
+ """
186
+ return False
187
+
182
188
 
183
189
  class MatchOp(Op):
184
190
  """
@@ -13,6 +13,13 @@ def _any_template(items):
13
13
  return any(hasattr(x, 'is_template') and x.is_template() for x in items)
14
14
 
15
15
 
16
+ def _any_reference(items):
17
+ """
18
+ True if any item in an iterable has is_reference() returning True.
19
+ """
20
+ return any(hasattr(x, 'is_reference') and x.is_reference() for x in items)
21
+
22
+
16
23
  class FilterOp(base.MatchOp):
17
24
  def is_pattern(self):
18
25
  return False
@@ -63,6 +70,12 @@ class FilterKey(base.MatchOp):
63
70
  """
64
71
  return _any_template(self.parts)
65
72
 
73
+ def is_reference(self):
74
+ """
75
+ True if any part of the filter key contains an internal reference.
76
+ """
77
+ return _any_reference(self.parts)
78
+
66
79
  def get_values(self, node):
67
80
  """
68
81
  Get all values from node matching this key, traversing dotted path if needed.
@@ -211,6 +224,17 @@ class FilterKeyValue(FilterOp):
211
224
  return True
212
225
  return _any_template(self.transforms)
213
226
 
227
+ def is_reference(self):
228
+ """
229
+ True if the key, value, or any transform contains an internal
230
+ reference.
231
+ """
232
+ if hasattr(self.key, 'is_reference') and self.key.is_reference():
233
+ return True
234
+ if hasattr(self.val, 'is_reference') and self.val.is_reference():
235
+ return True
236
+ return _any_reference(self.transforms)
237
+
214
238
  def _eq_match(self, node):
215
239
  """
216
240
  True if any value from self.key in node matches self.val (after transforms).
@@ -379,6 +403,11 @@ class FilterGroup(FilterOp):
379
403
  and hasattr(self.inner, 'is_template')
380
404
  and self.inner.is_template())
381
405
 
406
+ def is_reference(self):
407
+ return (self.inner is not None
408
+ and hasattr(self.inner, 'is_reference')
409
+ and self.inner.is_reference())
410
+
382
411
  def resolve(self, bindings, partial=False):
383
412
  """
384
413
  Resolve $N in inner filter.
@@ -434,6 +463,9 @@ class FilterAnd(FilterOp):
434
463
  def is_template(self):
435
464
  return _any_template(self.filters)
436
465
 
466
+ def is_reference(self):
467
+ return _any_reference(self.filters)
468
+
437
469
  def resolve(self, bindings, partial=False):
438
470
  """
439
471
  Resolve $N in each filter.
@@ -477,6 +509,9 @@ class FilterOr(FilterOp):
477
509
  def is_template(self):
478
510
  return _any_template(self.filters)
479
511
 
512
+ def is_reference(self):
513
+ return _any_reference(self.filters)
514
+
480
515
  def resolve(self, bindings, partial=False):
481
516
  """
482
517
  Resolve $N in each filter.
@@ -527,6 +562,11 @@ class FilterKeyValueFirst(FilterOp):
527
562
  and hasattr(self.inner, 'is_template')
528
563
  and self.inner.is_template())
529
564
 
565
+ def is_reference(self):
566
+ return (self.inner is not None
567
+ and hasattr(self.inner, 'is_reference')
568
+ and self.inner.is_reference())
569
+
530
570
  def resolve(self, bindings, partial=False):
531
571
  """
532
572
  Resolve $N in inner filter.
@@ -585,6 +625,11 @@ class FilterNot(FilterOp):
585
625
  and hasattr(self.inner, 'is_template')
586
626
  and self.inner.is_template())
587
627
 
628
+ def is_reference(self):
629
+ return (self.inner is not None
630
+ and hasattr(self.inner, 'is_reference')
631
+ and self.inner.is_reference())
632
+
588
633
  def resolve(self, bindings, partial=False):
589
634
  """
590
635
  Resolve $N in inner filter.
@@ -42,6 +42,16 @@ class OpGroup(base.TraversalOp):
42
42
  return True
43
43
  return False
44
44
 
45
+ def is_reference(self):
46
+ """
47
+ True if any branch contains an internal reference.
48
+ """
49
+ for branch in base.branches_only(self.branches):
50
+ for op in branch:
51
+ if hasattr(op, 'is_reference') and op.is_reference():
52
+ return True
53
+ return False
54
+
45
55
  def default(self):
46
56
  """
47
57
  Derive default from the first branch's first op, so auto-creation works