api-dock 0.7.0__tar.gz → 0.7.1__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 (32) hide show
  1. {api_dock-0.7.0 → api_dock-0.7.1}/PKG-INFO +120 -1
  2. {api_dock-0.7.0 → api_dock-0.7.1}/README.md +119 -0
  3. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/database_config.py +4 -0
  4. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/databases/example_db.yaml +14 -0
  5. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/route_mapper.py +8 -1
  6. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/sql_builder.py +390 -3
  7. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/PKG-INFO +120 -1
  8. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/SOURCES.txt +1 -0
  9. {api_dock-0.7.0 → api_dock-0.7.1}/pyproject.toml +1 -1
  10. api_dock-0.7.1/tests/test_sql_selector.py +277 -0
  11. {api_dock-0.7.0 → api_dock-0.7.1}/LICENSE.md +0 -0
  12. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/__init__.py +0 -0
  13. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/auth.py +0 -0
  14. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/cli.py +0 -0
  15. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/config.py +0 -0
  16. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/config_discovery.py +0 -0
  17. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/encryption.py +0 -0
  18. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/config.yaml +0 -0
  19. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/example_api_dock_config/remotes/example_remote.yaml +0 -0
  20. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/fast_api.py +0 -0
  21. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/flask_api.py +0 -0
  22. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/storage_auth.py +0 -0
  23. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock/types.py +0 -0
  24. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/dependency_links.txt +0 -0
  25. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/entry_points.txt +0 -0
  26. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/requires.txt +0 -0
  27. {api_dock-0.7.0 → api_dock-0.7.1}/api_dock.egg-info/top_level.txt +0 -0
  28. {api_dock-0.7.0 → api_dock-0.7.1}/setup.cfg +0 -0
  29. {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_inject_cookies.py +0 -0
  30. {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_proxy_pipeline.py +0 -0
  31. {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_sql_builder.py +0 -0
  32. {api_dock-0.7.0 → api_dock-0.7.1}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api_dock
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases
5
5
  Author-email: Brookie Guzder-Williams <bguzder-williams@berkeley.edu>
6
6
  License-Expression: BSD-3-Clause
@@ -672,6 +672,125 @@ Parameters are processed in this order (first match wins for early returns):
672
672
 
673
673
  ---
674
674
 
675
+ ## Conditional SQL Selection
676
+
677
+ Some routes need a *different* base query depending on the request — for example, `?count=true` should return a species histogram (`SELECT … COUNT(*) … GROUP BY …`) rather than rows. `sql_append` can't help (it only adds trailing clauses), and a second `route:` can't either (the path is identical). For this, a route's `sql` may be a **rule list** instead of a string: a first-match-wins decision tree that picks the base query from the presence and value of path, query, and cookie params.
678
+
679
+ Everything downstream is unchanged: the selected base composes with `query_params` WHERE-fragments and `sql_append` exactly as a plain `sql:` string does.
680
+
681
+ ### The histogram example
682
+
683
+ ```yaml
684
+ routes:
685
+ - route: detections
686
+ sql:
687
+ # ?count=<truthy> → species histogram
688
+ - when: count
689
+ then:
690
+ sql: >
691
+ SELECT [[detections]].common_name, [[detections]].scientific_name,
692
+ COUNT(*) AS count
693
+ FROM [[detections]]
694
+ sql_append: GROUP BY [[detections]].common_name, [[detections]].scientific_name
695
+ # otherwise → detection rows
696
+ - else: SELECT [[detections]].* FROM [[detections]]
697
+ query_params:
698
+ - recording:
699
+ sql: "[[detections]].recording_id = {{recording}}"
700
+ multivalue_sql: "[[detections]].recording_id IN {{recording}}"
701
+ - limit:
702
+ sql_append: LIMIT {{limit}} # applies in BOTH modes
703
+ ```
704
+
705
+ ```bash
706
+ GET /db/detections?recording=1&count=true
707
+ # SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count
708
+ # FROM detections WHERE detections.recording_id = '1'
709
+ # GROUP BY detections.common_name, detections.scientific_name
710
+
711
+ GET /db/detections?recording=1
712
+ # SELECT detections.* FROM detections WHERE detections.recording_id = '1'
713
+ ```
714
+
715
+ Note the pipeline order: the selected branch's `sql_append` (the `GROUP BY`) is applied **before** route-level `sql_append` (the shared `LIMIT`), so SQL clause order stays valid.
716
+
717
+ ### Rule forms
718
+
719
+ A `sql` list contains rules evaluated top to bottom; the **first match wins**. Each rule's payload (an *sql node*) is a SQL string, a leaf object `{sql, sql_append}`, or a nested rule list.
720
+
721
+ ```yaml
722
+ sql:
723
+ - when: count # fires when `count` is truthy (shorthand for equals: _truthy)
724
+ then: <sql node>
725
+
726
+ - when: mode
727
+ equals: 'true' # fires only when mode == "true" (case-insensitive)
728
+ then: <sql node>
729
+
730
+ - when: format # value map: different SQL per value
731
+ match:
732
+ species: <sql node> # ?format=species
733
+ recording: <sql node> # ?format=recording
734
+ _truthy: <sql node> # any other truthy value
735
+ _default: <sql node> # any present value not matched above
736
+
737
+ - when: [count, recording] # list: fires when ALL are truthy (AND)
738
+ then: <sql node>
739
+
740
+ - when: [count, something_else] # list + positional case list
741
+ match:
742
+ - values: [_any, x] # something_else == x, count anything
743
+ then: <sql node>
744
+ - values: [_truthy, _absent] # count truthy AND something_else not passed
745
+ then: <sql node>
746
+ - default: <sql node>
747
+
748
+ - else: <sql node> # default (a trailing bare string works too)
749
+ ```
750
+
751
+ Nesting works because a payload can itself be a rule list:
752
+
753
+ ```yaml
754
+ sql:
755
+ - when: some_value
756
+ match:
757
+ '4':
758
+ - when: count
759
+ then: <sql for value 4 with count>
760
+ - else: <sql for value 4>
761
+ _default: <sql for other values>
762
+ - else: <base sql>
763
+ ```
764
+
765
+ ### Value specs
766
+
767
+ | spec | matches when the param… |
768
+ |---|---|
769
+ | `'literal'` (`'4'`, `'true'`) | is present and equals it (case-insensitive) |
770
+ | `_truthy` / `_falsy` | present, and value is / isn't in `{"", "0", "false", "no", "off", "null", "none"}` |
771
+ | `_present` / `_absent` | exists / does not exist |
772
+ | `_any` | wildcard — present or absent (used for a position in a case list) |
773
+ | `_default` | catch-all for any *present* value (value maps only) |
774
+
775
+ In a single-param `match:` map, precedence is order-independent: exact literal > `_falsy`/`_truthy` > `_present`/`_absent` > `_default`. In a positional case list, cases match strictly top-to-bottom.
776
+
777
+ ### No match → URL error
778
+
779
+ If no rule matches and there is no default (`else`, a trailing bare string, or a `_default`/`default` catch-all), the request returns a **400** with `{"error": "No matching query configuration for the given parameters", "http_status": 400}`. Customize it with a terminal `no_match` rule:
780
+
781
+ ```yaml
782
+ sql:
783
+ - when: recording
784
+ then: SELECT [[detections]].* FROM [[detections]] WHERE recording_id = {{recording}}
785
+ - no_match:
786
+ error: "recording is required, or pass count=true for a histogram"
787
+ http_status: 400
788
+ ```
789
+
790
+ Cookies participate via the `cookies.<name>` key (e.g. `when: cookies.role`, `equals: admin`).
791
+
792
+ ---
793
+
675
794
  # CLI
676
795
 
677
796
  ## Commands
@@ -633,6 +633,125 @@ Parameters are processed in this order (first match wins for early returns):
633
633
 
634
634
  ---
635
635
 
636
+ ## Conditional SQL Selection
637
+
638
+ Some routes need a *different* base query depending on the request — for example, `?count=true` should return a species histogram (`SELECT … COUNT(*) … GROUP BY …`) rather than rows. `sql_append` can't help (it only adds trailing clauses), and a second `route:` can't either (the path is identical). For this, a route's `sql` may be a **rule list** instead of a string: a first-match-wins decision tree that picks the base query from the presence and value of path, query, and cookie params.
639
+
640
+ Everything downstream is unchanged: the selected base composes with `query_params` WHERE-fragments and `sql_append` exactly as a plain `sql:` string does.
641
+
642
+ ### The histogram example
643
+
644
+ ```yaml
645
+ routes:
646
+ - route: detections
647
+ sql:
648
+ # ?count=<truthy> → species histogram
649
+ - when: count
650
+ then:
651
+ sql: >
652
+ SELECT [[detections]].common_name, [[detections]].scientific_name,
653
+ COUNT(*) AS count
654
+ FROM [[detections]]
655
+ sql_append: GROUP BY [[detections]].common_name, [[detections]].scientific_name
656
+ # otherwise → detection rows
657
+ - else: SELECT [[detections]].* FROM [[detections]]
658
+ query_params:
659
+ - recording:
660
+ sql: "[[detections]].recording_id = {{recording}}"
661
+ multivalue_sql: "[[detections]].recording_id IN {{recording}}"
662
+ - limit:
663
+ sql_append: LIMIT {{limit}} # applies in BOTH modes
664
+ ```
665
+
666
+ ```bash
667
+ GET /db/detections?recording=1&count=true
668
+ # SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count
669
+ # FROM detections WHERE detections.recording_id = '1'
670
+ # GROUP BY detections.common_name, detections.scientific_name
671
+
672
+ GET /db/detections?recording=1
673
+ # SELECT detections.* FROM detections WHERE detections.recording_id = '1'
674
+ ```
675
+
676
+ Note the pipeline order: the selected branch's `sql_append` (the `GROUP BY`) is applied **before** route-level `sql_append` (the shared `LIMIT`), so SQL clause order stays valid.
677
+
678
+ ### Rule forms
679
+
680
+ A `sql` list contains rules evaluated top to bottom; the **first match wins**. Each rule's payload (an *sql node*) is a SQL string, a leaf object `{sql, sql_append}`, or a nested rule list.
681
+
682
+ ```yaml
683
+ sql:
684
+ - when: count # fires when `count` is truthy (shorthand for equals: _truthy)
685
+ then: <sql node>
686
+
687
+ - when: mode
688
+ equals: 'true' # fires only when mode == "true" (case-insensitive)
689
+ then: <sql node>
690
+
691
+ - when: format # value map: different SQL per value
692
+ match:
693
+ species: <sql node> # ?format=species
694
+ recording: <sql node> # ?format=recording
695
+ _truthy: <sql node> # any other truthy value
696
+ _default: <sql node> # any present value not matched above
697
+
698
+ - when: [count, recording] # list: fires when ALL are truthy (AND)
699
+ then: <sql node>
700
+
701
+ - when: [count, something_else] # list + positional case list
702
+ match:
703
+ - values: [_any, x] # something_else == x, count anything
704
+ then: <sql node>
705
+ - values: [_truthy, _absent] # count truthy AND something_else not passed
706
+ then: <sql node>
707
+ - default: <sql node>
708
+
709
+ - else: <sql node> # default (a trailing bare string works too)
710
+ ```
711
+
712
+ Nesting works because a payload can itself be a rule list:
713
+
714
+ ```yaml
715
+ sql:
716
+ - when: some_value
717
+ match:
718
+ '4':
719
+ - when: count
720
+ then: <sql for value 4 with count>
721
+ - else: <sql for value 4>
722
+ _default: <sql for other values>
723
+ - else: <base sql>
724
+ ```
725
+
726
+ ### Value specs
727
+
728
+ | spec | matches when the param… |
729
+ |---|---|
730
+ | `'literal'` (`'4'`, `'true'`) | is present and equals it (case-insensitive) |
731
+ | `_truthy` / `_falsy` | present, and value is / isn't in `{"", "0", "false", "no", "off", "null", "none"}` |
732
+ | `_present` / `_absent` | exists / does not exist |
733
+ | `_any` | wildcard — present or absent (used for a position in a case list) |
734
+ | `_default` | catch-all for any *present* value (value maps only) |
735
+
736
+ In a single-param `match:` map, precedence is order-independent: exact literal > `_falsy`/`_truthy` > `_present`/`_absent` > `_default`. In a positional case list, cases match strictly top-to-bottom.
737
+
738
+ ### No match → URL error
739
+
740
+ If no rule matches and there is no default (`else`, a trailing bare string, or a `_default`/`default` catch-all), the request returns a **400** with `{"error": "No matching query configuration for the given parameters", "http_status": 400}`. Customize it with a terminal `no_match` rule:
741
+
742
+ ```yaml
743
+ sql:
744
+ - when: recording
745
+ then: SELECT [[detections]].* FROM [[detections]] WHERE recording_id = {{recording}}
746
+ - no_match:
747
+ error: "recording is required, or pass count=true for a histogram"
748
+ http_status: 400
749
+ ```
750
+
751
+ Cookies participate via the `cookies.<name>` key (e.g. `when: cookies.role`, `equals: admin`).
752
+
753
+ ---
754
+
636
755
  # CLI
637
756
 
638
757
  ## Commands
@@ -295,6 +295,10 @@ def validate_route_config(route_config: Dict[str, Any]) -> bool:
295
295
  if 'route' not in route_config:
296
296
  return False
297
297
 
298
+ # The sql selector may be a plain string or a list of selector rules.
299
+ if 'sql' in route_config and not isinstance(route_config['sql'], (str, list)):
300
+ return False
301
+
298
302
  # Validate query_params structure if present
299
303
  if 'query_params' in route_config:
300
304
  query_params = route_config['query_params']
@@ -80,3 +80,17 @@ routes:
80
80
  # - debug:
81
81
  # response:
82
82
  # message: "Debug mode — no query executed"
83
+
84
+ # Conditional SQL selection — pick a different base query from a param's
85
+ # presence/value. Here ?count=<truthy> returns a histogram; otherwise rows.
86
+ # The selected base still composes with query_params (WHERE + sql_append).
87
+ # - route: detections
88
+ # sql:
89
+ # - when: count
90
+ # then:
91
+ # sql: SELECT [[items]].category, COUNT(*) AS count FROM [[items]]
92
+ # sql_append: GROUP BY [[items]].category
93
+ # - else: SELECT [[items]].* FROM [[items]]
94
+ # query_params:
95
+ # - category:
96
+ # sql: "[[items]].category = '{{category}}'"
@@ -18,7 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
18
18
  from api_dock.auth import validate_authentication
19
19
  from api_dock.config import filter_cookies_by_config, filter_remote_query_params, find_remote_config, find_route_mapping, get_authentication_config, get_database_names, get_remote_names, get_remote_versions, get_settings, is_route_allowed, is_versioned_remote, load_main_config, merge_inherited_config, resolve_latest_version
20
20
  from api_dock.database_config import find_database_route, get_database_versions, is_versioned_database, load_database_config, merge_query_params, resolve_latest_database_version
21
- from api_dock.sql_builder import build_sql_query, extract_path_parameters, process_query_parameters
21
+ from api_dock.sql_builder import build_sql_query, extract_path_parameters, process_query_parameters, SqlSelectionError
22
22
  from api_dock.storage_auth import detect_required_backends, extract_table_metadata_by_backend, extract_table_uris, setup_storage_authentication
23
23
  from api_dock.types import PreparedRequest, ProxyResponse
24
24
 
@@ -435,6 +435,13 @@ class RouteMapper:
435
435
  route_config, database_config, path_params, query_params,
436
436
  filtered_cookies, multi_query_params
437
437
  )
438
+ except SqlSelectionError as e:
439
+ return ProxyResponse(
440
+ status_code=e.status_code,
441
+ content=json.dumps(e.response).encode(),
442
+ content_type="application/json",
443
+ error_message=str(e.response.get("error")) if e.response.get("error") else None,
444
+ )
438
445
  except ValueError:
439
446
  return _error_response(500, "SQL query error")
440
447
 
@@ -17,9 +17,52 @@ from typing import Any, Dict, List, Optional, Tuple
17
17
  from api_dock.database_config import get_named_query, get_table_definition
18
18
 
19
19
 
20
+ #
21
+ # CONSTANTS
22
+ #
23
+ # Query-param values treated as "falsy" for _truthy/_falsy matching in the sql
24
+ # selector. Comparison is case-insensitive after stripping surrounding space.
25
+ FALSY_VALUES: frozenset = frozenset({"", "0", "false", "no", "off", "null", "none"})
26
+
27
+ # Response returned when an sql selector matches no branch and defines no
28
+ # default (else / trailing string / _default) or no_match override.
29
+ DEFAULT_NO_MATCH_RESPONSE: Dict[str, Any] = {
30
+ "error": "No matching query configuration for the given parameters",
31
+ "http_status": 400,
32
+ }
33
+
34
+ # Sentinel signalling that a selector rule did not fire (distinct from a rule
35
+ # that fires but resolves to an empty base SQL).
36
+ _NO_MATCH: Any = object()
37
+
38
+
20
39
  #
21
40
  # PUBLIC
22
41
  #
42
+ class SqlSelectionError(Exception):
43
+ """Raised when an sql selector matches no branch and defines no default.
44
+
45
+ Carries the JSON response body and HTTP status to return to the client, so
46
+ the caller can surface a proper URL error (default 400) rather than a
47
+ generic 500.
48
+
49
+ Attributes:
50
+ response: JSON-serializable response body to return to the client.
51
+ status_code: HTTP status code to return.
52
+ """
53
+
54
+ def __init__(self, response: Dict[str, Any]) -> None:
55
+ """Initialize the error from a response spec.
56
+
57
+ Args:
58
+ response: Response body dict; its ``http_status`` (default 400)
59
+ becomes the HTTP status code.
60
+ """
61
+ self.response = response
62
+ self.status_code = int(response.get("http_status", 400))
63
+ super().__init__(str(response.get("error", "No matching query configuration")))
64
+
65
+
23
66
  def build_sql_query(
24
67
  route_config: Dict[str, Any],
25
68
  database_config: Dict[str, Any],
@@ -55,8 +98,13 @@ def build_sql_query(
55
98
  if multi_query_params is None:
56
99
  multi_query_params = {}
57
100
 
58
- # Get the base SQL template from route config
59
- sql_template = route_config.get('sql', '')
101
+ # Resolve the base SQL via the selector. ``sql`` may be a plain string or a
102
+ # rule-list decision tree; selection is driven by path, query, and cookie
103
+ # params. branch_appends are post-WHERE clauses (e.g. GROUP BY) contributed
104
+ # by the selected branch, applied before route-level sql_append fragments.
105
+ selection_params = {**path_params, **query_params}
106
+ selection_params.update({f"cookies.{key}": value for key, value in cookies.items()})
107
+ sql_template, branch_appends = resolve_route_sql(route_config, selection_params)
60
108
 
61
109
  # Check if sql_template is a reference to a named query
62
110
  if sql_template.startswith("[[") and sql_template.endswith("]]"):
@@ -98,6 +146,16 @@ def build_sql_query(
98
146
  where_clause = ' WHERE ' + ' AND '.join(where_fragments)
99
147
  sql_with_tables += where_clause
100
148
 
149
+ # Apply post-WHERE clauses from the selected sql branch (e.g. GROUP BY for a
150
+ # histogram mode). Branch appends come before route-level sql_append so a
151
+ # branch GROUP BY precedes a shared ORDER BY / LIMIT.
152
+ if branch_appends:
153
+ expanded_branch = [
154
+ _substitute_table_references(fragment, database_config)
155
+ for fragment in branch_appends
156
+ ]
157
+ sql_with_tables += ' ' + ' '.join(expanded_branch)
158
+
101
159
  # Build post-WHERE append fragments (ORDER BY, LIMIT, etc.)
102
160
  append_fragments = build_append_clause_from_params(route_config, query_params, path_params)
103
161
  # Expand [[table_name]] references in APPEND fragments (same syntax as main sql)
@@ -112,6 +170,46 @@ def build_sql_query(
112
170
  return sql_with_params
113
171
 
114
172
 
173
+ def resolve_route_sql(
174
+ route_config: Dict[str, Any],
175
+ selection_params: Dict[str, str]) -> Tuple[str, List[str]]:
176
+ """Resolve a route's ``sql`` selector to a base SQL string and append clauses.
177
+
178
+ The ``sql`` value may be a plain string (used directly) or a list of rules
179
+ forming a first-match-wins decision tree. Each rule selects an sql node based
180
+ on the presence and value of params. An sql node is itself a string, a leaf
181
+ object (``{sql, sql_append}``), or a nested rule list.
182
+
183
+ Rule forms (evaluated top-to-bottom, first match wins):
184
+ - ``{when: <param>, then: <node>}`` — fires when the param is truthy.
185
+ - ``{when: <param>, equals: <valspec>, then: <node>}`` — fires on a value.
186
+ - ``{when: <param>, match: {<valspec>: <node>, ...}}`` — value map.
187
+ - ``{when: [<params>], then: <node>}`` — fires when all params are truthy.
188
+ - ``{when: [<params>], match: [{values: [...], then: <node>}, ...]}``
189
+ - ``{else: <node>}`` or a trailing bare string — the default.
190
+ - ``{no_match: <response>}`` — raise a URL error instead of a default.
191
+
192
+ Args:
193
+ route_config: Route configuration dictionary. ``sql`` is a string or a
194
+ list of selector rules.
195
+ selection_params: Merged params available for matching, keyed by name
196
+ (path and query params, plus cookies as ``cookies.<name>``). A key
197
+ is "present" iff it appears here.
198
+
199
+ Returns:
200
+ Tuple of ``(base_sql, branch_appends)`` where ``branch_appends`` are
201
+ post-WHERE SQL fragments contributed by the selected branch.
202
+
203
+ Raises:
204
+ SqlSelectionError: If no branch matches and no default/no_match is given.
205
+ """
206
+ sql_spec = route_config.get('sql', '')
207
+ resolved = _resolve_sql_node(sql_spec, selection_params)
208
+ if resolved is None:
209
+ raise SqlSelectionError(dict(DEFAULT_NO_MATCH_RESPONSE))
210
+ return resolved
211
+
212
+
115
213
  # Keep backward compatibility with old signature
116
214
  def build_sql_query_legacy(
117
215
  sql_template: str,
@@ -705,4 +803,293 @@ def _substitute_variables_in_dict(template_dict: Dict[str, Any], params: Dict[st
705
803
  result[key] = [_substitute_variables_in_string(str(item), params) if isinstance(item, str) else item for item in value]
706
804
  else:
707
805
  result[key] = value
708
- return result
806
+ return result
807
+
808
+
809
+ def _resolve_sql_node(node: Any, params: Dict[str, str]) -> Optional[Tuple[str, List[str]]]:
810
+ """Resolve an sql node to a base SQL string and post-WHERE append fragments.
811
+
812
+ An sql node is a string (base SQL, no appends), a leaf object
813
+ (``{sql, sql_append}``), or a rule list (nested selection).
814
+
815
+ Args:
816
+ node: The sql node to resolve.
817
+ params: Merged selection params (see resolve_route_sql).
818
+
819
+ Returns:
820
+ Tuple of (base_sql, branch_appends), or None if a rule-list node matched
821
+ nothing and defined no default.
822
+
823
+ Raises:
824
+ SqlSelectionError: If a nested no_match rule is reached.
825
+ """
826
+ if isinstance(node, str):
827
+ return (node, [])
828
+ if isinstance(node, dict):
829
+ if 'sql' in node:
830
+ return (str(node.get('sql', '')), _as_append_list(node.get('sql_append')))
831
+ return None
832
+ if isinstance(node, list):
833
+ return _resolve_rule_list(node, params)
834
+ return None
835
+
836
+
837
+ def _resolve_rule_list(rules: List[Any], params: Dict[str, str]) -> Optional[Tuple[str, List[str]]]:
838
+ """Resolve a rule list, returning the first matching branch's node.
839
+
840
+ Args:
841
+ rules: List of selector rules (see resolve_route_sql).
842
+ params: Merged selection params.
843
+
844
+ Returns:
845
+ Tuple of (base_sql, branch_appends), or None if nothing matched.
846
+
847
+ Raises:
848
+ SqlSelectionError: If a no_match rule is reached before any match.
849
+ """
850
+ for item in rules:
851
+ # Terminal bare-string default.
852
+ if isinstance(item, str):
853
+ return (item, [])
854
+ if not isinstance(item, dict):
855
+ continue
856
+ if 'else' in item:
857
+ return _require_node(item['else'], params)
858
+ if 'no_match' in item:
859
+ raise SqlSelectionError(_no_match_response(item['no_match']))
860
+ if 'when' in item:
861
+ matched = _match_rule(item, params)
862
+ if matched is _NO_MATCH:
863
+ continue
864
+ return _require_node(matched, params)
865
+ return None
866
+
867
+
868
+ def _require_node(node: Any, params: Dict[str, str]) -> Tuple[str, List[str]]:
869
+ """Resolve an sql node that must yield a result (committed branch).
870
+
871
+ Args:
872
+ node: The sql node to resolve.
873
+ params: Merged selection params.
874
+
875
+ Returns:
876
+ Tuple of (base_sql, branch_appends).
877
+
878
+ Raises:
879
+ SqlSelectionError: If the node resolves to no match (e.g. a nested rule
880
+ list with no default).
881
+ """
882
+ resolved = _resolve_sql_node(node, params)
883
+ if resolved is None:
884
+ raise SqlSelectionError(dict(DEFAULT_NO_MATCH_RESPONSE))
885
+ return resolved
886
+
887
+
888
+ def _match_rule(rule: Dict[str, Any], params: Dict[str, str]) -> Any:
889
+ """Evaluate a ``when`` rule, returning its payload node or _NO_MATCH.
890
+
891
+ Args:
892
+ rule: A rule dict containing ``when`` plus optional equals/match/then.
893
+ params: Merged selection params.
894
+
895
+ Returns:
896
+ The selected sql node to resolve, or _NO_MATCH if the rule did not fire.
897
+ """
898
+ when = rule.get('when')
899
+ if isinstance(when, list):
900
+ return _match_multi(rule, [str(w) for w in when], params)
901
+ return _match_single(rule, str(when), params)
902
+
903
+
904
+ def _match_single(rule: Dict[str, Any], param: str, params: Dict[str, str]) -> Any:
905
+ """Evaluate a single-param ``when`` rule.
906
+
907
+ Args:
908
+ rule: The rule dict.
909
+ param: The single param name from ``when``.
910
+ params: Merged selection params.
911
+
912
+ Returns:
913
+ The selected sql node, or _NO_MATCH.
914
+ """
915
+ present = param in params
916
+ value = params.get(param)
917
+ if 'match' in rule:
918
+ node = _resolve_value_map(rule['match'], value, present)
919
+ return node if node is not None else _NO_MATCH
920
+ spec = rule.get('equals', '_truthy')
921
+ if _value_matches(spec, value, present):
922
+ return rule.get('then', '')
923
+ return _NO_MATCH
924
+
925
+
926
+ def _match_multi(rule: Dict[str, Any], param_names: List[str], params: Dict[str, str]) -> Any:
927
+ """Evaluate a multi-param (list ``when``) rule.
928
+
929
+ ``match`` is a positional case list; otherwise the rule fires when every
930
+ named param satisfies ``equals`` (positional) or, by default, is truthy.
931
+
932
+ Args:
933
+ rule: The rule dict.
934
+ param_names: The param names from ``when``.
935
+ params: Merged selection params.
936
+
937
+ Returns:
938
+ The selected sql node, or _NO_MATCH.
939
+ """
940
+ if 'match' in rule:
941
+ cases = rule['match']
942
+ if not isinstance(cases, list):
943
+ return _NO_MATCH
944
+ for case in cases:
945
+ if not isinstance(case, dict):
946
+ continue
947
+ if 'default' in case:
948
+ return case['default']
949
+ values = case.get('values')
950
+ if not isinstance(values, list) or len(values) != len(param_names):
951
+ continue
952
+ if _all_positions_match(param_names, values, params):
953
+ return case.get('then', '')
954
+ return _NO_MATCH
955
+
956
+ specs = rule.get('equals')
957
+ if isinstance(specs, list) and len(specs) == len(param_names):
958
+ if _all_positions_match(param_names, specs, params):
959
+ return rule.get('then', '')
960
+ return _NO_MATCH
961
+
962
+ # Default: fire only when every named param is truthy (AND).
963
+ for name in param_names:
964
+ if not _value_matches('_truthy', params.get(name), name in params):
965
+ return _NO_MATCH
966
+ return rule.get('then', '')
967
+
968
+
969
+ def _all_positions_match(param_names: List[str], specs: List[Any], params: Dict[str, str]) -> bool:
970
+ """Check that each param satisfies its positional value spec.
971
+
972
+ Args:
973
+ param_names: Param names, aligned with ``specs``.
974
+ specs: Value specs, one per param.
975
+ params: Merged selection params.
976
+
977
+ Returns:
978
+ True if every position matches, False otherwise.
979
+ """
980
+ for name, spec in zip(param_names, specs):
981
+ if not _value_matches(spec, params.get(name), name in params):
982
+ return False
983
+ return True
984
+
985
+
986
+ def _resolve_value_map(value_map: Any, value: Optional[str], present: bool) -> Any:
987
+ """Select an sql node from a single-param value map by precedence.
988
+
989
+ Precedence (order-independent): exact literal > _falsy/_truthy >
990
+ _present/_absent > _default. An absent param matches only _absent.
991
+
992
+ Args:
993
+ value_map: Mapping of value spec -> sql node.
994
+ value: The param's value (or None if absent).
995
+ present: Whether the param is present.
996
+
997
+ Returns:
998
+ The matching sql node, or None if nothing matched.
999
+ """
1000
+ if not isinstance(value_map, dict):
1001
+ return None
1002
+
1003
+ if not present:
1004
+ return value_map.get('_absent')
1005
+
1006
+ value_norm = str(value).strip().lower()
1007
+ # 1. Exact literal (case-insensitive) among non-special keys.
1008
+ for key, node in value_map.items():
1009
+ if str(key).startswith('_'):
1010
+ continue
1011
+ if str(key).strip().lower() == value_norm:
1012
+ return node
1013
+ # 2. Truthiness.
1014
+ if _is_truthy(value):
1015
+ if '_truthy' in value_map:
1016
+ return value_map['_truthy']
1017
+ elif '_falsy' in value_map:
1018
+ return value_map['_falsy']
1019
+ # 3. Presence.
1020
+ if '_present' in value_map:
1021
+ return value_map['_present']
1022
+ # 4. Catch-all.
1023
+ return value_map.get('_default')
1024
+
1025
+
1026
+ def _value_matches(spec: Any, value: Optional[str], present: bool) -> bool:
1027
+ """Check whether a param value satisfies a single value spec.
1028
+
1029
+ Args:
1030
+ spec: A literal string or special token (_any, _present, _absent,
1031
+ _truthy, _falsy, _default).
1032
+ value: The param's value (or None if absent).
1033
+ present: Whether the param is present.
1034
+
1035
+ Returns:
1036
+ True if the value satisfies the spec, False otherwise.
1037
+ """
1038
+ spec_str = str(spec)
1039
+ if spec_str in ('_any', '_default'):
1040
+ return True
1041
+ if spec_str == '_present':
1042
+ return present
1043
+ if spec_str == '_absent':
1044
+ return not present
1045
+ if spec_str == '_truthy':
1046
+ return present and _is_truthy(value)
1047
+ if spec_str == '_falsy':
1048
+ return present and not _is_truthy(value)
1049
+ return present and str(value).strip().lower() == spec_str.strip().lower()
1050
+
1051
+
1052
+ def _is_truthy(value: Optional[str]) -> bool:
1053
+ """Return whether a param value is "truthy" per FALSY_VALUES.
1054
+
1055
+ Args:
1056
+ value: The value to test.
1057
+
1058
+ Returns:
1059
+ True unless the normalized value is in FALSY_VALUES.
1060
+ """
1061
+ return str(value).strip().lower() not in FALSY_VALUES
1062
+
1063
+
1064
+ def _as_append_list(value: Any) -> List[str]:
1065
+ """Normalize a leaf node's sql_append into a list of clause strings.
1066
+
1067
+ Args:
1068
+ value: A string, list of strings, or None.
1069
+
1070
+ Returns:
1071
+ List of append clause strings (empty if value is None/unsupported).
1072
+ """
1073
+ if value is None:
1074
+ return []
1075
+ if isinstance(value, str):
1076
+ return [value]
1077
+ if isinstance(value, list):
1078
+ return [str(item) for item in value]
1079
+ return []
1080
+
1081
+
1082
+ def _no_match_response(spec: Any) -> Dict[str, Any]:
1083
+ """Build a no_match response body, defaulting http_status to 400.
1084
+
1085
+ Args:
1086
+ spec: A response dict or a plain error string/value.
1087
+
1088
+ Returns:
1089
+ Response body dict with an http_status key.
1090
+ """
1091
+ if isinstance(spec, dict):
1092
+ response = dict(spec)
1093
+ response.setdefault('http_status', 400)
1094
+ return response
1095
+ return {'error': str(spec), 'http_status': 400}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api_dock
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases
5
5
  Author-email: Brookie Guzder-Williams <bguzder-williams@berkeley.edu>
6
6
  License-Expression: BSD-3-Clause
@@ -672,6 +672,125 @@ Parameters are processed in this order (first match wins for early returns):
672
672
 
673
673
  ---
674
674
 
675
+ ## Conditional SQL Selection
676
+
677
+ Some routes need a *different* base query depending on the request — for example, `?count=true` should return a species histogram (`SELECT … COUNT(*) … GROUP BY …`) rather than rows. `sql_append` can't help (it only adds trailing clauses), and a second `route:` can't either (the path is identical). For this, a route's `sql` may be a **rule list** instead of a string: a first-match-wins decision tree that picks the base query from the presence and value of path, query, and cookie params.
678
+
679
+ Everything downstream is unchanged: the selected base composes with `query_params` WHERE-fragments and `sql_append` exactly as a plain `sql:` string does.
680
+
681
+ ### The histogram example
682
+
683
+ ```yaml
684
+ routes:
685
+ - route: detections
686
+ sql:
687
+ # ?count=<truthy> → species histogram
688
+ - when: count
689
+ then:
690
+ sql: >
691
+ SELECT [[detections]].common_name, [[detections]].scientific_name,
692
+ COUNT(*) AS count
693
+ FROM [[detections]]
694
+ sql_append: GROUP BY [[detections]].common_name, [[detections]].scientific_name
695
+ # otherwise → detection rows
696
+ - else: SELECT [[detections]].* FROM [[detections]]
697
+ query_params:
698
+ - recording:
699
+ sql: "[[detections]].recording_id = {{recording}}"
700
+ multivalue_sql: "[[detections]].recording_id IN {{recording}}"
701
+ - limit:
702
+ sql_append: LIMIT {{limit}} # applies in BOTH modes
703
+ ```
704
+
705
+ ```bash
706
+ GET /db/detections?recording=1&count=true
707
+ # SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count
708
+ # FROM detections WHERE detections.recording_id = '1'
709
+ # GROUP BY detections.common_name, detections.scientific_name
710
+
711
+ GET /db/detections?recording=1
712
+ # SELECT detections.* FROM detections WHERE detections.recording_id = '1'
713
+ ```
714
+
715
+ Note the pipeline order: the selected branch's `sql_append` (the `GROUP BY`) is applied **before** route-level `sql_append` (the shared `LIMIT`), so SQL clause order stays valid.
716
+
717
+ ### Rule forms
718
+
719
+ A `sql` list contains rules evaluated top to bottom; the **first match wins**. Each rule's payload (an *sql node*) is a SQL string, a leaf object `{sql, sql_append}`, or a nested rule list.
720
+
721
+ ```yaml
722
+ sql:
723
+ - when: count # fires when `count` is truthy (shorthand for equals: _truthy)
724
+ then: <sql node>
725
+
726
+ - when: mode
727
+ equals: 'true' # fires only when mode == "true" (case-insensitive)
728
+ then: <sql node>
729
+
730
+ - when: format # value map: different SQL per value
731
+ match:
732
+ species: <sql node> # ?format=species
733
+ recording: <sql node> # ?format=recording
734
+ _truthy: <sql node> # any other truthy value
735
+ _default: <sql node> # any present value not matched above
736
+
737
+ - when: [count, recording] # list: fires when ALL are truthy (AND)
738
+ then: <sql node>
739
+
740
+ - when: [count, something_else] # list + positional case list
741
+ match:
742
+ - values: [_any, x] # something_else == x, count anything
743
+ then: <sql node>
744
+ - values: [_truthy, _absent] # count truthy AND something_else not passed
745
+ then: <sql node>
746
+ - default: <sql node>
747
+
748
+ - else: <sql node> # default (a trailing bare string works too)
749
+ ```
750
+
751
+ Nesting works because a payload can itself be a rule list:
752
+
753
+ ```yaml
754
+ sql:
755
+ - when: some_value
756
+ match:
757
+ '4':
758
+ - when: count
759
+ then: <sql for value 4 with count>
760
+ - else: <sql for value 4>
761
+ _default: <sql for other values>
762
+ - else: <base sql>
763
+ ```
764
+
765
+ ### Value specs
766
+
767
+ | spec | matches when the param… |
768
+ |---|---|
769
+ | `'literal'` (`'4'`, `'true'`) | is present and equals it (case-insensitive) |
770
+ | `_truthy` / `_falsy` | present, and value is / isn't in `{"", "0", "false", "no", "off", "null", "none"}` |
771
+ | `_present` / `_absent` | exists / does not exist |
772
+ | `_any` | wildcard — present or absent (used for a position in a case list) |
773
+ | `_default` | catch-all for any *present* value (value maps only) |
774
+
775
+ In a single-param `match:` map, precedence is order-independent: exact literal > `_falsy`/`_truthy` > `_present`/`_absent` > `_default`. In a positional case list, cases match strictly top-to-bottom.
776
+
777
+ ### No match → URL error
778
+
779
+ If no rule matches and there is no default (`else`, a trailing bare string, or a `_default`/`default` catch-all), the request returns a **400** with `{"error": "No matching query configuration for the given parameters", "http_status": 400}`. Customize it with a terminal `no_match` rule:
780
+
781
+ ```yaml
782
+ sql:
783
+ - when: recording
784
+ then: SELECT [[detections]].* FROM [[detections]] WHERE recording_id = {{recording}}
785
+ - no_match:
786
+ error: "recording is required, or pass count=true for a histogram"
787
+ http_status: 400
788
+ ```
789
+
790
+ Cookies participate via the `cookies.<name>` key (e.g. `when: cookies.role`, `equals: admin`).
791
+
792
+ ---
793
+
675
794
  # CLI
676
795
 
677
796
  ## Commands
@@ -27,4 +27,5 @@ api_dock/example_api_dock_config/remotes/example_remote.yaml
27
27
  tests/test_inject_cookies.py
28
28
  tests/test_proxy_pipeline.py
29
29
  tests/test_sql_builder.py
30
+ tests/test_sql_selector.py
30
31
  tests/test_types.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "api_dock"
7
- version = "0.7.0"
7
+ version = "0.7.1"
8
8
  description = "A flexible API gateway that allows you to proxy requests to multiple remote APIs and Databases"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -0,0 +1,277 @@
1
+ """
2
+
3
+ Tests for the conditional SQL selector in the SQL builder.
4
+
5
+ Covers ``resolve_route_sql`` (the decision-tree resolver) and its end-to-end
6
+ composition with the existing query_params WHERE/append pipeline in
7
+ ``build_sql_query``, plus the SqlSelectionError path through
8
+ ``RouteMapper.map_database_route``.
9
+
10
+ The selector lets a route's ``sql`` be a plain string (today's behavior) or a
11
+ first-match-wins list of rules that pick a base SQL node from the presence and
12
+ value of path/query/cookie params.
13
+
14
+ License: BSD 3-Clause
15
+
16
+ """
17
+
18
+ #
19
+ # IMPORTS
20
+ #
21
+ import json
22
+ from unittest.mock import patch
23
+
24
+ import pytest
25
+
26
+ from api_dock.route_mapper import RouteMapper
27
+ from api_dock.sql_builder import build_sql_query, resolve_route_sql, SqlSelectionError
28
+
29
+
30
+ #
31
+ # CONSTANTS
32
+ #
33
+ DATABASE_CONFIG = {"tables": {"detections": "data/detections.parquet"}}
34
+
35
+ COUNT_ROUTE = {
36
+ "route": "detections",
37
+ "sql": [
38
+ {
39
+ "when": "count",
40
+ "then": {
41
+ "sql": (
42
+ "SELECT [[detections]].common_name, [[detections]].scientific_name, "
43
+ "COUNT(*) AS count FROM [[detections]]"
44
+ ),
45
+ "sql_append": (
46
+ "GROUP BY [[detections]].common_name, [[detections]].scientific_name"
47
+ ),
48
+ },
49
+ },
50
+ {"else": "SELECT [[detections]].* FROM [[detections]]"},
51
+ ],
52
+ "query_params": [
53
+ {
54
+ "recording": {
55
+ "sql": "[[detections]].recording_id = {{recording}}",
56
+ "multivalue_sql": "[[detections]].recording_id IN {{recording}}",
57
+ }
58
+ },
59
+ {"limit": {"sql_append": "LIMIT {{limit}}"}},
60
+ ],
61
+ }
62
+
63
+
64
+ #
65
+ # PUBLIC
66
+ #
67
+ class TestResolveRouteSql:
68
+ """Unit tests for the decision-tree resolver."""
69
+
70
+ def test_plain_string_passthrough(self) -> None:
71
+ """A plain string sql resolves to itself with no branch appends."""
72
+ assert resolve_route_sql({"sql": "SELECT 1"}, {}) == ("SELECT 1", [])
73
+
74
+ def test_missing_sql_returns_empty(self) -> None:
75
+ """A route with no sql resolves to an empty base (legacy behavior)."""
76
+ assert resolve_route_sql({}, {}) == ("", [])
77
+
78
+ def test_when_truthy_fires(self) -> None:
79
+ """A bare when fires on a truthy value."""
80
+ rc = {"sql": [{"when": "count", "then": "HIST"}, {"else": "BASE"}]}
81
+ assert resolve_route_sql(rc, {"count": "True"}) == ("HIST", [])
82
+
83
+ def test_when_absent_falls_to_else(self) -> None:
84
+ """An absent query param skips the rule and falls to else."""
85
+ rc = {"sql": [{"when": "count", "then": "HIST"}, {"else": "BASE"}]}
86
+ assert resolve_route_sql(rc, {}) == ("BASE", [])
87
+
88
+ def test_when_falsy_falls_to_else(self) -> None:
89
+ """A falsy value does not fire a bare when."""
90
+ rc = {"sql": [{"when": "count", "then": "HIST"}, {"else": "BASE"}]}
91
+ assert resolve_route_sql(rc, {"count": "false"}) == ("BASE", [])
92
+
93
+ def test_leaf_object_with_append(self) -> None:
94
+ """A leaf object contributes base sql plus post-WHERE append clauses."""
95
+ rc = {
96
+ "sql": [
97
+ {"when": "count", "then": {"sql": "HIST", "sql_append": "GROUP BY x"}},
98
+ "BASE",
99
+ ]
100
+ }
101
+ assert resolve_route_sql(rc, {"count": "1"}) == ("HIST", ["GROUP BY x"])
102
+
103
+ def test_trailing_string_default(self) -> None:
104
+ """A trailing bare string acts as the default (implicit else)."""
105
+ rc = {"sql": [{"when": "count", "then": "HIST"}, "BASE"]}
106
+ assert resolve_route_sql(rc, {}) == ("BASE", [])
107
+
108
+ def test_equals_literal_case_insensitive(self) -> None:
109
+ """equals matches a specific value, case-insensitively, and only that value."""
110
+ rc = {"sql": [{"when": "mode", "equals": "true", "then": "T"}, {"else": "E"}]}
111
+ assert resolve_route_sql(rc, {"mode": "True"}) == ("T", [])
112
+ assert resolve_route_sql(rc, {"mode": "1"}) == ("E", [])
113
+
114
+ def test_value_map_precedence_literal_over_truthy(self) -> None:
115
+ """A value map prefers an exact literal over _truthy regardless of order."""
116
+ rc = {"sql": [{"when": "count", "match": {"_truthy": "TRU", "true": "LIT"}}, {"else": "E"}]}
117
+ assert resolve_route_sql(rc, {"count": "true"}) == ("LIT", [])
118
+ assert resolve_route_sql(rc, {"count": "1"}) == ("TRU", [])
119
+
120
+ def test_value_map_default_requires_presence(self) -> None:
121
+ """_default fires for any present value but not when the param is absent."""
122
+ rc = {"sql": [{"when": "count", "match": {"_default": "D"}}, {"else": "E"}]}
123
+ assert resolve_route_sql(rc, {"count": "anything"}) == ("D", [])
124
+ assert resolve_route_sql(rc, {}) == ("E", [])
125
+
126
+ def test_value_map_absent(self) -> None:
127
+ """_absent fires only when the param is absent."""
128
+ rc = {"sql": [{"when": "count", "match": {"_absent": "A", "_truthy": "T"}}, {"else": "E"}]}
129
+ assert resolve_route_sql(rc, {}) == ("A", [])
130
+ assert resolve_route_sql(rc, {"count": "1"}) == ("T", [])
131
+
132
+ def test_value_map_falsy(self) -> None:
133
+ """_falsy fires when a present value is falsy."""
134
+ rc = {"sql": [{"when": "flag", "match": {"_falsy": "F", "_truthy": "T"}}, {"else": "E"}]}
135
+ assert resolve_route_sql(rc, {"flag": "0"}) == ("F", [])
136
+ assert resolve_route_sql(rc, {"flag": "yes"}) == ("T", [])
137
+
138
+ def test_multi_when_all_truthy(self) -> None:
139
+ """A list when with then fires only when every param is truthy (AND)."""
140
+ rc = {"sql": [{"when": ["a", "b"], "then": "BOTH"}, {"else": "E"}]}
141
+ assert resolve_route_sql(rc, {"a": "1", "b": "1"}) == ("BOTH", [])
142
+ assert resolve_route_sql(rc, {"a": "1"}) == ("E", [])
143
+
144
+ def test_multi_match_case_list_with_any(self) -> None:
145
+ """A positional case list matches top-to-bottom, honoring _any wildcards."""
146
+ rc = {
147
+ "sql": [
148
+ {
149
+ "when": ["count", "something"],
150
+ "match": [
151
+ {"values": ["_any", "x"], "then": "SX"},
152
+ {"values": ["_truthy", "_absent"], "then": "CT"},
153
+ {"default": "DEF"},
154
+ ],
155
+ },
156
+ {"else": "E"},
157
+ ]
158
+ }
159
+ assert resolve_route_sql(rc, {"something": "x"}) == ("SX", [])
160
+ assert resolve_route_sql(rc, {"count": "1"}) == ("CT", [])
161
+ assert resolve_route_sql(rc, {"count": "1", "something": "y"}) == ("DEF", [])
162
+
163
+ def test_nested_rule_list(self) -> None:
164
+ """A branch payload may itself be a nested rule list."""
165
+ rc = {
166
+ "sql": [
167
+ {
168
+ "when": "some_value",
169
+ "match": {
170
+ "4": [{"when": "count", "then": "FOUR_COUNT"}, {"else": "FOUR"}],
171
+ "_default": "OTHER",
172
+ },
173
+ },
174
+ {"else": "E"},
175
+ ]
176
+ }
177
+ assert resolve_route_sql(rc, {"some_value": "4", "count": "1"}) == ("FOUR_COUNT", [])
178
+ assert resolve_route_sql(rc, {"some_value": "4"}) == ("FOUR", [])
179
+ assert resolve_route_sql(rc, {"some_value": "9"}) == ("OTHER", [])
180
+
181
+ def test_cookie_param_selection(self) -> None:
182
+ """Cookies participate in selection via the cookies.<name> key."""
183
+ rc = {"sql": [{"when": "cookies.role", "equals": "admin", "then": "A"}, {"else": "E"}]}
184
+ assert resolve_route_sql(rc, {"cookies.role": "admin"}) == ("A", [])
185
+ assert resolve_route_sql(rc, {"cookies.role": "user"}) == ("E", [])
186
+
187
+ def test_no_match_raises_default_400(self) -> None:
188
+ """No branch and no default raises a 400 SqlSelectionError."""
189
+ rc = {"sql": [{"when": "count", "then": "HIST"}]}
190
+ with pytest.raises(SqlSelectionError) as exc:
191
+ resolve_route_sql(rc, {})
192
+ assert exc.value.status_code == 400
193
+ assert "error" in exc.value.response
194
+
195
+ def test_no_match_custom_response(self) -> None:
196
+ """A no_match rule provides a custom error body and status."""
197
+ rc = {
198
+ "sql": [
199
+ {"when": "recording", "then": "R"},
200
+ {"no_match": {"error": "recording required", "http_status": 422}},
201
+ ]
202
+ }
203
+ with pytest.raises(SqlSelectionError) as exc:
204
+ resolve_route_sql(rc, {})
205
+ assert exc.value.status_code == 422
206
+ assert exc.value.response["error"] == "recording required"
207
+
208
+
209
+ class TestSelectorComposition:
210
+ """End-to-end SQL assembly combining the selector with query_params."""
211
+
212
+ def test_count_mode_composes_where_and_group_by(self) -> None:
213
+ """count=True selects the histogram base; recording filter + GROUP BY compose."""
214
+ sql = build_sql_query(
215
+ COUNT_ROUTE, DATABASE_CONFIG, {}, {"recording": "1", "count": "True"}, {}, {}
216
+ )
217
+ expected = (
218
+ "SELECT detections.common_name, detections.scientific_name, COUNT(*) AS count "
219
+ "FROM 'data/detections.parquet' AS detections "
220
+ "WHERE detections.recording_id = '1' "
221
+ "GROUP BY detections.common_name, detections.scientific_name"
222
+ )
223
+ assert sql == expected
224
+
225
+ def test_count_mode_with_shared_limit(self) -> None:
226
+ """A shared LIMIT append lands after the branch GROUP BY."""
227
+ sql = build_sql_query(
228
+ COUNT_ROUTE, DATABASE_CONFIG, {},
229
+ {"recording": "1", "count": "1", "limit": "20"}, {}, {}
230
+ )
231
+ assert sql.endswith("GROUP BY detections.common_name, detections.scientific_name LIMIT 20")
232
+
233
+ def test_default_mode_returns_rows(self) -> None:
234
+ """Without count, the else branch returns rows with the recording filter."""
235
+ sql = build_sql_query(COUNT_ROUTE, DATABASE_CONFIG, {}, {"recording": "1"}, {}, {})
236
+ expected = (
237
+ "SELECT detections.* FROM 'data/detections.parquet' AS detections "
238
+ "WHERE detections.recording_id = '1'"
239
+ )
240
+ assert sql == expected
241
+
242
+ def test_no_match_raises_through_build(self) -> None:
243
+ """A selector with no default raises SqlSelectionError from build_sql_query."""
244
+ route = {"route": "detections", "sql": [{"when": "count", "then": "SELECT 1"}]}
245
+ with pytest.raises(SqlSelectionError):
246
+ build_sql_query(route, DATABASE_CONFIG, {}, {}, {}, {})
247
+
248
+
249
+ class TestMapDatabaseRouteSelection:
250
+ """The SqlSelectionError path surfaces as a proper URL error response."""
251
+
252
+ def _make_rm(self):
253
+ rm = RouteMapper.__new__(RouteMapper)
254
+ rm.remote_names = []
255
+ rm.database_names = ["mydb"]
256
+ rm.config = {}
257
+ rm.settings = {}
258
+ return rm
259
+
260
+ @pytest.mark.anyio
261
+ async def test_selection_error_returns_400(self) -> None:
262
+ """No matching branch (and no default) yields a 400 with an error body."""
263
+ rm = self._make_rm()
264
+ route = {"route": "detections", "sql": [{"when": "count", "then": "SELECT 1"}]}
265
+ db_cfg = {"tables": {}, "routes": [route]}
266
+ with patch("api_dock.route_mapper.is_versioned_database", return_value=False), \
267
+ patch("api_dock.route_mapper.load_database_config", return_value=db_cfg), \
268
+ patch("api_dock.route_mapper.merge_inherited_config", side_effect=lambda c, p: c), \
269
+ patch("api_dock.route_mapper.filter_cookies_by_config", return_value={}), \
270
+ patch("api_dock.route_mapper.get_authentication_config", return_value=None), \
271
+ patch("api_dock.route_mapper.find_database_route", return_value=route), \
272
+ patch("api_dock.route_mapper.merge_query_params", side_effect=lambda r, d: r):
273
+ result = await rm.map_database_route("mydb", "detections", {}, {})
274
+
275
+ assert result.status_code == 400
276
+ parsed = json.loads(result.content)
277
+ assert "error" in parsed
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes