clear-skies 2.0.27__py3-none-any.whl
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.
Potentially problematic release.
This version of clear-skies might be problematic. Click here for more details.
- clear_skies-2.0.27.dist-info/METADATA +78 -0
- clear_skies-2.0.27.dist-info/RECORD +270 -0
- clear_skies-2.0.27.dist-info/WHEEL +4 -0
- clear_skies-2.0.27.dist-info/licenses/LICENSE +7 -0
- clearskies/__init__.py +69 -0
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +15 -0
- clearskies/authentication/authentication.py +44 -0
- clearskies/authentication/authorization.py +23 -0
- clearskies/authentication/authorization_pass_through.py +22 -0
- clearskies/authentication/jwks.py +165 -0
- clearskies/authentication/public.py +5 -0
- clearskies/authentication/secret_bearer.py +551 -0
- clearskies/autodoc/__init__.py +8 -0
- clearskies/autodoc/formats/__init__.py +5 -0
- clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
- clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
- clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
- clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
- clearskies/autodoc/formats/oai3_json/request.py +68 -0
- clearskies/autodoc/formats/oai3_json/response.py +28 -0
- clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
- clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
- clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
- clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
- clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
- clearskies/autodoc/formats/oai3_json/test.json +1985 -0
- clearskies/autodoc/py.typed +0 -0
- clearskies/autodoc/request/__init__.py +15 -0
- clearskies/autodoc/request/header.py +6 -0
- clearskies/autodoc/request/json_body.py +6 -0
- clearskies/autodoc/request/parameter.py +8 -0
- clearskies/autodoc/request/request.py +47 -0
- clearskies/autodoc/request/url_parameter.py +6 -0
- clearskies/autodoc/request/url_path.py +6 -0
- clearskies/autodoc/response/__init__.py +5 -0
- clearskies/autodoc/response/response.py +9 -0
- clearskies/autodoc/schema/__init__.py +31 -0
- clearskies/autodoc/schema/array.py +10 -0
- clearskies/autodoc/schema/base64.py +8 -0
- clearskies/autodoc/schema/boolean.py +5 -0
- clearskies/autodoc/schema/date.py +5 -0
- clearskies/autodoc/schema/datetime.py +5 -0
- clearskies/autodoc/schema/double.py +5 -0
- clearskies/autodoc/schema/enum.py +17 -0
- clearskies/autodoc/schema/integer.py +6 -0
- clearskies/autodoc/schema/long.py +5 -0
- clearskies/autodoc/schema/number.py +6 -0
- clearskies/autodoc/schema/object.py +13 -0
- clearskies/autodoc/schema/password.py +5 -0
- clearskies/autodoc/schema/schema.py +11 -0
- clearskies/autodoc/schema/string.py +5 -0
- clearskies/backends/__init__.py +67 -0
- clearskies/backends/api_backend.py +1194 -0
- clearskies/backends/backend.py +137 -0
- clearskies/backends/cursor_backend.py +339 -0
- clearskies/backends/graphql_backend.py +977 -0
- clearskies/backends/memory_backend.py +794 -0
- clearskies/backends/secrets_backend.py +100 -0
- clearskies/clients/__init__.py +5 -0
- clearskies/clients/graphql_client.py +182 -0
- clearskies/column.py +1221 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +306 -0
- clearskies/columns/belongs_to_id.py +478 -0
- clearskies/columns/belongs_to_model.py +145 -0
- clearskies/columns/belongs_to_self.py +109 -0
- clearskies/columns/boolean.py +110 -0
- clearskies/columns/category_tree.py +274 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +125 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +92 -0
- clearskies/columns/created_by_authorization_data.py +114 -0
- clearskies/columns/created_by_header.py +103 -0
- clearskies/columns/created_by_ip.py +90 -0
- clearskies/columns/created_by_routing_data.py +102 -0
- clearskies/columns/created_by_user_agent.py +89 -0
- clearskies/columns/date.py +232 -0
- clearskies/columns/datetime.py +284 -0
- clearskies/columns/email.py +78 -0
- clearskies/columns/float.py +149 -0
- clearskies/columns/has_many.py +552 -0
- clearskies/columns/has_many_self.py +62 -0
- clearskies/columns/has_one.py +21 -0
- clearskies/columns/integer.py +158 -0
- clearskies/columns/json.py +126 -0
- clearskies/columns/many_to_many_ids.py +335 -0
- clearskies/columns/many_to_many_ids_with_data.py +281 -0
- clearskies/columns/many_to_many_models.py +163 -0
- clearskies/columns/many_to_many_pivots.py +132 -0
- clearskies/columns/phone.py +162 -0
- clearskies/columns/select.py +95 -0
- clearskies/columns/string.py +102 -0
- clearskies/columns/timestamp.py +164 -0
- clearskies/columns/updated.py +107 -0
- clearskies/columns/uuid.py +83 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +170 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +15 -0
- clearskies/configs/any_dict.py +24 -0
- clearskies/configs/any_dict_or_callable.py +25 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +18 -0
- clearskies/configs/boolean_or_callable.py +20 -0
- clearskies/configs/callable_config.py +20 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +26 -0
- clearskies/configs/datetime.py +20 -0
- clearskies/configs/datetime_or_callable.py +21 -0
- clearskies/configs/email.py +10 -0
- clearskies/configs/email_list.py +17 -0
- clearskies/configs/email_list_or_callable.py +17 -0
- clearskies/configs/email_or_email_list_or_callable.py +59 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/endpoint_list.py +29 -0
- clearskies/configs/float.py +18 -0
- clearskies/configs/float_or_callable.py +20 -0
- clearskies/configs/headers.py +28 -0
- clearskies/configs/integer.py +18 -0
- clearskies/configs/integer_or_callable.py +20 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +32 -0
- clearskies/configs/list_any_dict_or_callable.py +33 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +67 -0
- clearskies/configs/model_columns.py +58 -0
- clearskies/configs/model_destination_name.py +26 -0
- clearskies/configs/model_to_id_column.py +45 -0
- clearskies/configs/readable_model_column.py +11 -0
- clearskies/configs/readable_model_columns.py +11 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +11 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +28 -0
- clearskies/configs/select_list.py +49 -0
- clearskies/configs/string.py +31 -0
- clearskies/configs/string_dict.py +34 -0
- clearskies/configs/string_list.py +47 -0
- clearskies/configs/string_list_or_callable.py +48 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +20 -0
- clearskies/configs/timezone.py +20 -0
- clearskies/configs/url.py +25 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +11 -0
- clearskies/configs/writeable_model_columns.py +11 -0
- clearskies/configurable.py +78 -0
- clearskies/contexts/__init__.py +11 -0
- clearskies/contexts/cli.py +130 -0
- clearskies/contexts/context.py +99 -0
- clearskies/contexts/wsgi.py +79 -0
- clearskies/contexts/wsgi_ref.py +87 -0
- clearskies/cursors/__init__.py +10 -0
- clearskies/cursors/cursor.py +161 -0
- clearskies/cursors/from_environment/__init__.py +5 -0
- clearskies/cursors/from_environment/mysql.py +51 -0
- clearskies/cursors/from_environment/postgresql.py +49 -0
- clearskies/cursors/from_environment/sqlite.py +35 -0
- clearskies/cursors/mysql.py +61 -0
- clearskies/cursors/postgresql.py +61 -0
- clearskies/cursors/sqlite.py +62 -0
- clearskies/decorators.py +33 -0
- clearskies/decorators.pyi +10 -0
- clearskies/di/__init__.py +15 -0
- clearskies/di/additional_config.py +130 -0
- clearskies/di/additional_config_auto_import.py +17 -0
- clearskies/di/di.py +948 -0
- clearskies/di/inject/__init__.py +25 -0
- clearskies/di/inject/akeyless_sdk.py +16 -0
- clearskies/di/inject/by_class.py +24 -0
- clearskies/di/inject/by_name.py +22 -0
- clearskies/di/inject/di.py +16 -0
- clearskies/di/inject/environment.py +15 -0
- clearskies/di/inject/input_output.py +19 -0
- clearskies/di/inject/logger.py +16 -0
- clearskies/di/inject/now.py +16 -0
- clearskies/di/inject/requests.py +16 -0
- clearskies/di/inject/secrets.py +15 -0
- clearskies/di/inject/utcnow.py +16 -0
- clearskies/di/inject/uuid.py +16 -0
- clearskies/di/injectable.py +32 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/end.py +219 -0
- clearskies/endpoint.py +1303 -0
- clearskies/endpoint_group.py +333 -0
- clearskies/endpoints/__init__.py +25 -0
- clearskies/endpoints/advanced_search.py +519 -0
- clearskies/endpoints/callable.py +382 -0
- clearskies/endpoints/create.py +201 -0
- clearskies/endpoints/delete.py +133 -0
- clearskies/endpoints/get.py +267 -0
- clearskies/endpoints/health_check.py +181 -0
- clearskies/endpoints/list.py +567 -0
- clearskies/endpoints/restful_api.py +417 -0
- clearskies/endpoints/schema.py +185 -0
- clearskies/endpoints/simple_search.py +279 -0
- clearskies/endpoints/update.py +188 -0
- clearskies/environment.py +106 -0
- clearskies/exceptions/__init__.py +19 -0
- clearskies/exceptions/authentication.py +2 -0
- clearskies/exceptions/authorization.py +2 -0
- clearskies/exceptions/client_error.py +2 -0
- clearskies/exceptions/input_errors.py +4 -0
- clearskies/exceptions/missing_dependency.py +2 -0
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/exceptions/not_found.py +2 -0
- clearskies/functional/__init__.py +7 -0
- clearskies/functional/json.py +47 -0
- clearskies/functional/routing.py +92 -0
- clearskies/functional/string.py +112 -0
- clearskies/functional/validations.py +76 -0
- clearskies/input_outputs/__init__.py +13 -0
- clearskies/input_outputs/cli.py +157 -0
- clearskies/input_outputs/exceptions/__init__.py +7 -0
- clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
- clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
- clearskies/input_outputs/headers.py +54 -0
- clearskies/input_outputs/input_output.py +116 -0
- clearskies/input_outputs/programmatic.py +62 -0
- clearskies/input_outputs/py.typed +0 -0
- clearskies/input_outputs/wsgi.py +80 -0
- clearskies/loggable.py +19 -0
- clearskies/model.py +2039 -0
- clearskies/py.typed +0 -0
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +228 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +195 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +7 -0
- clearskies/secrets/additional_configs/__init__.py +32 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
- clearskies/secrets/akeyless.py +507 -0
- clearskies/secrets/exceptions/__init__.py +7 -0
- clearskies/secrets/exceptions/not_found_error.py +2 -0
- clearskies/secrets/exceptions/permissions_error.py +2 -0
- clearskies/secrets/secrets.py +39 -0
- clearskies/security_header.py +17 -0
- clearskies/security_headers/__init__.py +11 -0
- clearskies/security_headers/cache_control.py +68 -0
- clearskies/security_headers/cors.py +51 -0
- clearskies/security_headers/csp.py +95 -0
- clearskies/security_headers/hsts.py +23 -0
- clearskies/security_headers/x_content_type_options.py +0 -0
- clearskies/security_headers/x_frame_options.py +0 -0
- clearskies/typing.py +11 -0
- clearskies/validator.py +36 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +61 -0
- clearskies/validators/before_column.py +15 -0
- clearskies/validators/in_the_future.py +29 -0
- clearskies/validators/in_the_future_at_least.py +13 -0
- clearskies/validators/in_the_future_at_most.py +12 -0
- clearskies/validators/in_the_past.py +29 -0
- clearskies/validators/in_the_past_at_least.py +12 -0
- clearskies/validators/in_the_past_at_most.py +12 -0
- clearskies/validators/maximum_length.py +25 -0
- clearskies/validators/maximum_value.py +28 -0
- clearskies/validators/minimum_length.py +25 -0
- clearskies/validators/minimum_value.py +28 -0
- clearskies/validators/required.py +32 -0
- clearskies/validators/timedelta.py +58 -0
- clearskies/validators/unique.py +28 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
4
|
+
|
|
5
|
+
from clearskies import configs, configurable, decorators, loggable
|
|
6
|
+
from clearskies.autodoc.schema import Integer as AutoDocInteger
|
|
7
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
8
|
+
from clearskies.autodoc.schema import String as AutoDocString
|
|
9
|
+
from clearskies.backends.backend import Backend
|
|
10
|
+
from clearskies.clients import graphql_client as client
|
|
11
|
+
from clearskies.di import InjectableProperties, inject
|
|
12
|
+
from clearskies.functional.string import swap_casing
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from clearskies import Column, Model
|
|
16
|
+
from clearskies.query import Query
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GraphqlBackend(Backend, configurable.Configurable, InjectableProperties, loggable.Loggable):
|
|
20
|
+
"""
|
|
21
|
+
Autonomous backend for integrating clearskies models with GraphQL APIs.
|
|
22
|
+
|
|
23
|
+
Dynamically constructs GraphQL queries by introspecting the clearskies Model.
|
|
24
|
+
Supports CRUD operations, pagination, filtering, and relationships.
|
|
25
|
+
|
|
26
|
+
Configuration:
|
|
27
|
+
- graphql_client: GraphqlClient instance (required)
|
|
28
|
+
- root_field: Override the root field name (optional, defaults to model.destination_name())
|
|
29
|
+
- pagination_style: "cursor" for Relay-style or "offset" for limit/offset (default: "cursor")
|
|
30
|
+
- api_case: Case convention used by the GraphQL API (default: "camelCase")
|
|
31
|
+
- model_case: Case convention used by clearskies models (default: "snake_case")
|
|
32
|
+
- is_collection: Explicitly set if this resource is a collection (True) or singular (False/None=auto-detect)
|
|
33
|
+
- include_relationships: Enable automatic relationship fetching (default: False for performance)
|
|
34
|
+
- max_relationship_depth: Maximum depth for nested relationships (default: 2)
|
|
35
|
+
- nested_relationships: Allow relationships within relationships (default: False)
|
|
36
|
+
- relationship_limit: Default limit for HasMany/ManyToMany relationships (default: 10)
|
|
37
|
+
- use_connection_for_relationships: Use connection pattern for collections (default: True)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Tell clearskies that count() may not be reliable for all GraphQL APIs
|
|
41
|
+
# This prevents clearskies from trying to call count() in situations where
|
|
42
|
+
# it would fail (e.g., for relationship queries with incompatible filters)
|
|
43
|
+
can_count = True
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
The GraphQL client instance used to execute queries.
|
|
47
|
+
|
|
48
|
+
An instance of clearskies.clients.GraphqlClient that handles the connection to your GraphQL API.
|
|
49
|
+
This is required for the backend to function. Example:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import clearskies
|
|
53
|
+
|
|
54
|
+
class Project(clearskies.Model):
|
|
55
|
+
id_column_name = "id"
|
|
56
|
+
backend = clearskies.backends.GraphqlBackend(
|
|
57
|
+
graphql_client=clearskies.clients.GraphqlClient(
|
|
58
|
+
endpoint="https://api.example.com/graphql",
|
|
59
|
+
authentication=clearskies.authentication.SecretBearer(
|
|
60
|
+
environment_key="API_TOKEN"
|
|
61
|
+
)
|
|
62
|
+
),
|
|
63
|
+
root_field="projects"
|
|
64
|
+
)
|
|
65
|
+
id = clearskies.columns.String()
|
|
66
|
+
name = clearskies.columns.String()
|
|
67
|
+
```
|
|
68
|
+
"""
|
|
69
|
+
graphql_client = configs.Any(default=None)
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
The name of the GraphQL client in the DI container.
|
|
73
|
+
|
|
74
|
+
If you don't provide a graphql_client directly, the backend will look for a client
|
|
75
|
+
registered in the dependency injection container with this name. Defaults to "graphql_client".
|
|
76
|
+
"""
|
|
77
|
+
graphql_client_name = configs.String(default="graphql_client")
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
Override the root field name used in GraphQL queries.
|
|
81
|
+
|
|
82
|
+
By default, the backend uses model.destination_name() converted to the API's case convention.
|
|
83
|
+
Use this to explicitly set a different root field name. Example:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
backend = clearskies.backends.GraphqlBackend(
|
|
87
|
+
graphql_client=my_client,
|
|
88
|
+
root_field="allProjects" # Override default "projects"
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
"""
|
|
92
|
+
root_field = configs.String(default="")
|
|
93
|
+
|
|
94
|
+
"""
|
|
95
|
+
The pagination strategy used by the GraphQL API.
|
|
96
|
+
|
|
97
|
+
Supported values:
|
|
98
|
+
- "cursor": Relay-style cursor pagination with pageInfo { endCursor, hasNextPage }
|
|
99
|
+
- "offset": Traditional limit/offset pagination
|
|
100
|
+
|
|
101
|
+
Defaults to "cursor" which is the most common pattern in GraphQL APIs.
|
|
102
|
+
"""
|
|
103
|
+
pagination_style = configs.String(default="cursor")
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
The case convention used by the GraphQL API for field names.
|
|
107
|
+
|
|
108
|
+
Common values: "camelCase", "snake_case", "PascalCase", "kebab-case"
|
|
109
|
+
Defaults to "camelCase" which is the GraphQL standard.
|
|
110
|
+
"""
|
|
111
|
+
api_case = configs.String(default="camelCase")
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
The case convention used by clearskies model column names.
|
|
115
|
+
|
|
116
|
+
Common values: "snake_case", "camelCase", "PascalCase", "kebab-case"
|
|
117
|
+
Defaults to "snake_case" which is the Python/clearskies standard.
|
|
118
|
+
"""
|
|
119
|
+
model_case = configs.String(default="snake_case")
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
Explicitly set whether the resource is a collection or singular.
|
|
123
|
+
|
|
124
|
+
Values:
|
|
125
|
+
- None: Auto-detect based on field name patterns (default)
|
|
126
|
+
- True: Resource is a collection (returns multiple items with pagination)
|
|
127
|
+
- False: Resource is singular (returns a single object, like "currentUser")
|
|
128
|
+
|
|
129
|
+
Auto-detection works for most cases, but you can override it if needed.
|
|
130
|
+
"""
|
|
131
|
+
is_collection = configs.Boolean(default=None, required=False)
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
Maximum depth for nested relationship queries.
|
|
135
|
+
|
|
136
|
+
Controls how deep the backend will traverse relationships when building GraphQL queries.
|
|
137
|
+
For example, with max_relationship_depth=2:
|
|
138
|
+
- Depth 0: Root model (Group)
|
|
139
|
+
- Depth 1: First level relationships (Group.projects)
|
|
140
|
+
- Depth 2: Second level relationships (Project.namespace)
|
|
141
|
+
|
|
142
|
+
This prevents infinite recursion in circular relationships. Defaults to 2.
|
|
143
|
+
"""
|
|
144
|
+
max_relationship_depth = configs.Integer(default=2)
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
Default limit for HasMany and ManyToMany relationship collections.
|
|
148
|
+
|
|
149
|
+
When fetching related collections (e.g., projects for a group), this sets the maximum
|
|
150
|
+
number of related records to fetch. Defaults to 10.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
```python
|
|
154
|
+
backend = clearskies.backends.GraphqlBackend(
|
|
155
|
+
graphql_client=my_client,
|
|
156
|
+
relationship_limit=50 # Fetch up to 50 related items
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
"""
|
|
160
|
+
relationship_limit = configs.Integer(default=10)
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
Whether to use GraphQL connection pattern for relationship collections.
|
|
164
|
+
|
|
165
|
+
When True, relationship queries use the connection pattern:
|
|
166
|
+
```graphql
|
|
167
|
+
projects(first: 10) {
|
|
168
|
+
nodes { id name }
|
|
169
|
+
pageInfo { endCursor hasNextPage }
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
When False, expects direct arrays:
|
|
174
|
+
```graphql
|
|
175
|
+
projects { id name }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Defaults to True (Relay-style connections are the GraphQL standard).
|
|
179
|
+
"""
|
|
180
|
+
use_connection_for_relationships = configs.Boolean(default=True)
|
|
181
|
+
|
|
182
|
+
_client: client.GraphqlClient
|
|
183
|
+
di = inject.Di()
|
|
184
|
+
|
|
185
|
+
@decorators.parameters_to_properties
|
|
186
|
+
def __init__(
|
|
187
|
+
self,
|
|
188
|
+
graphql_client: client.GraphqlClient | None = None,
|
|
189
|
+
graphql_client_name: str = "graphql_client",
|
|
190
|
+
root_field: str = "",
|
|
191
|
+
pagination_style: str = "cursor",
|
|
192
|
+
api_case: str = "camelCase",
|
|
193
|
+
model_case: str = "snake_case",
|
|
194
|
+
is_collection: bool | None = None,
|
|
195
|
+
max_relationship_depth: int = 2,
|
|
196
|
+
relationship_limit: int = 10,
|
|
197
|
+
use_connection_for_relationships: bool = True,
|
|
198
|
+
):
|
|
199
|
+
self.finalize_and_validate_configuration()
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def client(self) -> client.GraphqlClient:
|
|
203
|
+
"""
|
|
204
|
+
Get the GraphQL client instance.
|
|
205
|
+
|
|
206
|
+
Lazily creates or retrieves the GraphqlClient used to execute queries. If a graphql_client
|
|
207
|
+
was provided during initialization, it's used directly. Otherwise, the client is retrieved
|
|
208
|
+
from the dependency injection container using graphql_client_name.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
GraphqlClient: The configured GraphQL client instance for executing queries.
|
|
212
|
+
"""
|
|
213
|
+
if hasattr(self, "_client"):
|
|
214
|
+
return self._client
|
|
215
|
+
|
|
216
|
+
if self.graphql_client:
|
|
217
|
+
self._client = self.graphql_client
|
|
218
|
+
else:
|
|
219
|
+
self.logger.warning("No GraphQL client provided, creating default client.")
|
|
220
|
+
self._client = inject.ByName(self.graphql_client_name) # type: ignore[assignment]
|
|
221
|
+
self._client.injectable_properties(self.di)
|
|
222
|
+
return self._client
|
|
223
|
+
|
|
224
|
+
def _model_to_api_name(self, model_name: str) -> str:
|
|
225
|
+
"""Convert a model field name to API field name."""
|
|
226
|
+
return swap_casing(model_name, self.model_case, self.api_case)
|
|
227
|
+
|
|
228
|
+
def _api_to_model_name(self, api_name: str) -> str:
|
|
229
|
+
"""Convert an API field name to model field name."""
|
|
230
|
+
return swap_casing(api_name, self.api_case, self.model_case)
|
|
231
|
+
|
|
232
|
+
def _get_root_field_name(self, model: "Model" | type["Model"]) -> str:
|
|
233
|
+
"""Get the root field name for GraphQL queries."""
|
|
234
|
+
if self.root_field:
|
|
235
|
+
return self.root_field
|
|
236
|
+
# Use the model's destination name and convert to API case
|
|
237
|
+
return swap_casing(model.destination_name(), self.model_case, self.api_case)
|
|
238
|
+
|
|
239
|
+
def _is_relationship_column(self, column: "Column") -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Check if a column represents a relationship that needs N+1 optimization.
|
|
242
|
+
|
|
243
|
+
Uses the wants_n_plus_one flag which is the official clearskies pattern
|
|
244
|
+
for identifying relationship columns (same pattern used by CursorBackend).
|
|
245
|
+
"""
|
|
246
|
+
# Primary detection: check the wants_n_plus_one flag
|
|
247
|
+
if hasattr(column, "wants_n_plus_one") and column.wants_n_plus_one:
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
# Fallback: class name inspection for backwards compatibility
|
|
251
|
+
column_type = column.__class__.__name__
|
|
252
|
+
return column_type in ["BelongsTo", "HasMany", "ManyToMany", "BelongsToId", "BelongsToModel"]
|
|
253
|
+
|
|
254
|
+
def _get_relationship_model(self, column: "Column") -> type["Model"] | None:
|
|
255
|
+
"""
|
|
256
|
+
Extract the related model class from a relationship column.
|
|
257
|
+
|
|
258
|
+
Tries multiple strategies to find the related model.
|
|
259
|
+
"""
|
|
260
|
+
column_type = column.__class__.__name__
|
|
261
|
+
|
|
262
|
+
# Strategy 1: Check for parent_models_class config (BelongsTo, BelongsToId)
|
|
263
|
+
if hasattr(column, "config"):
|
|
264
|
+
model = column.config("parent_models_class")
|
|
265
|
+
if model:
|
|
266
|
+
return model # type: ignore[return-value]
|
|
267
|
+
|
|
268
|
+
# Strategy 2: Check for child_models_class config (HasMany)
|
|
269
|
+
model = column.config("child_models_class")
|
|
270
|
+
if model:
|
|
271
|
+
return model # type: ignore[return-value]
|
|
272
|
+
|
|
273
|
+
# Strategy 3: For BelongsToModel, look up the corresponding BelongsToId column
|
|
274
|
+
if column_type == "BelongsToModel":
|
|
275
|
+
# BelongsToModel stores the belongs_to_id column name in belongs_to_column_name attribute
|
|
276
|
+
if hasattr(column, "belongs_to_column_name"):
|
|
277
|
+
belongs_to_id_column_name = column.belongs_to_column_name
|
|
278
|
+
if belongs_to_id_column_name:
|
|
279
|
+
# Get the model columns and look up the BelongsToId column
|
|
280
|
+
model_columns = column.get_model_columns() if hasattr(column, "get_model_columns") else {}
|
|
281
|
+
belongs_to_id_column = model_columns.get(belongs_to_id_column_name)
|
|
282
|
+
if belongs_to_id_column:
|
|
283
|
+
# BelongsToId has parent_model_class attribute
|
|
284
|
+
if hasattr(belongs_to_id_column, "parent_model_class"):
|
|
285
|
+
model = belongs_to_id_column.parent_model_class
|
|
286
|
+
if model:
|
|
287
|
+
return model # type: ignore[return-value]
|
|
288
|
+
|
|
289
|
+
# Strategy 4: Check for model_class attribute
|
|
290
|
+
if hasattr(column, "model_class") and column.model_class:
|
|
291
|
+
# Make sure it's not the same as the parent model
|
|
292
|
+
parent_columns = column.get_model_columns() if hasattr(column, "get_model_columns") else {}
|
|
293
|
+
if parent_columns and column.model_class != type(parent_columns):
|
|
294
|
+
return column.model_class # type: ignore[return-value]
|
|
295
|
+
|
|
296
|
+
# Could not determine relationship model
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
def _build_relationship_field(self, column: "Column", depth: int) -> str:
|
|
300
|
+
"""
|
|
301
|
+
Build a nested GraphQL field for a relationship column.
|
|
302
|
+
|
|
303
|
+
Dispatches to specific builders based on relationship type.
|
|
304
|
+
"""
|
|
305
|
+
column_type = column.__class__.__name__
|
|
306
|
+
|
|
307
|
+
if column_type in ["BelongsTo", "BelongsToModel", "BelongsToId"]:
|
|
308
|
+
return self._build_belongs_to_field(column, depth)
|
|
309
|
+
elif column_type in ["HasMany", "ManyToMany"]:
|
|
310
|
+
return self._build_has_many_field(column, depth)
|
|
311
|
+
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
def _build_belongs_to_field(self, column: "Column", depth: int) -> str:
|
|
315
|
+
"""
|
|
316
|
+
Build a nested field for BelongsTo relationships (single parent).
|
|
317
|
+
|
|
318
|
+
Pattern: Direct nested object
|
|
319
|
+
Example: user { id name email }
|
|
320
|
+
"""
|
|
321
|
+
related_model = self._get_relationship_model(column)
|
|
322
|
+
if not related_model:
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
field_name = self._model_to_api_name(column.name)
|
|
326
|
+
|
|
327
|
+
# Build fields for the related model
|
|
328
|
+
# Always include relationships at depth + 1 (controlled by max_relationship_depth)
|
|
329
|
+
related_fields = self._build_graphql_fields(related_model.get_columns(), depth=depth + 1)
|
|
330
|
+
|
|
331
|
+
return f"{field_name} {{ {related_fields} }}"
|
|
332
|
+
|
|
333
|
+
def _build_has_many_field(self, column: "Column", depth: int) -> str:
|
|
334
|
+
"""
|
|
335
|
+
Build a nested field for HasMany relationships (collection of children).
|
|
336
|
+
|
|
337
|
+
Pattern: Connection with nodes/edges or direct array
|
|
338
|
+
Example: orders(first: 10) { nodes { id total } pageInfo { endCursor hasNextPage } }
|
|
339
|
+
"""
|
|
340
|
+
related_model = self._get_relationship_model(column)
|
|
341
|
+
if not related_model:
|
|
342
|
+
return ""
|
|
343
|
+
|
|
344
|
+
field_name = self._model_to_api_name(column.name)
|
|
345
|
+
|
|
346
|
+
# Build fields for the related model
|
|
347
|
+
# Recursion is controlled by max_relationship_depth
|
|
348
|
+
related_fields = self._build_graphql_fields(
|
|
349
|
+
related_model.get_columns(),
|
|
350
|
+
depth=depth + 1,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Use connection pattern or direct array based on configuration
|
|
354
|
+
if self.use_connection_for_relationships:
|
|
355
|
+
return f"""{field_name}(first: {self.relationship_limit}) {{
|
|
356
|
+
nodes {{ {related_fields} }}
|
|
357
|
+
pageInfo {{ endCursor hasNextPage }}
|
|
358
|
+
}}"""
|
|
359
|
+
else:
|
|
360
|
+
return f"{field_name} {{ {related_fields} }}"
|
|
361
|
+
|
|
362
|
+
def _build_nested_field_from_underscore(self, column_name: str) -> str:
|
|
363
|
+
"""
|
|
364
|
+
Build a nested GraphQL field from double underscore notation.
|
|
365
|
+
|
|
366
|
+
Converts clearskies' double underscore notation to GraphQL nested field syntax.
|
|
367
|
+
|
|
368
|
+
Examples:
|
|
369
|
+
"user__name" -> "user { name }"
|
|
370
|
+
"project__owner__email" -> "project { owner { email } }"
|
|
371
|
+
"order__customer__address__city" -> "order { customer { address { city } } }"
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
column_name: Column name with double underscores (e.g., "user__name")
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
GraphQL nested field string
|
|
378
|
+
"""
|
|
379
|
+
parts = column_name.split("__")
|
|
380
|
+
if len(parts) < 2:
|
|
381
|
+
# Not a nested field, shouldn't happen but handle gracefully
|
|
382
|
+
return self._model_to_api_name(column_name)
|
|
383
|
+
|
|
384
|
+
# Convert all parts to API case
|
|
385
|
+
api_parts = [self._model_to_api_name(part) for part in parts]
|
|
386
|
+
|
|
387
|
+
# Build nested structure from the inside out
|
|
388
|
+
# Start with the innermost field (the actual value we want)
|
|
389
|
+
result = api_parts[-1]
|
|
390
|
+
|
|
391
|
+
# Wrap each level from right to left
|
|
392
|
+
# e.g., ["user", "name"] -> "user { name }"
|
|
393
|
+
# e.g., ["project", "owner", "email"] -> "project { owner { email } }"
|
|
394
|
+
for i in range(len(api_parts) - 2, -1, -1):
|
|
395
|
+
result = f"{api_parts[i]} {{ {result} }}"
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
def _build_graphql_fields(self, columns: dict[str, "Column"], depth: int = 0) -> str:
|
|
400
|
+
"""
|
|
401
|
+
Dynamically build GraphQL field selection from model columns.
|
|
402
|
+
|
|
403
|
+
Handles nested relationships up to a certain depth to prevent infinite recursion.
|
|
404
|
+
Automatically converts field names from model case to API case.
|
|
405
|
+
|
|
406
|
+
ALWAYS includes relationships for columns with wants_n_plus_one=True, as per
|
|
407
|
+
clearskies' standard backend behavior. This is not opt-in - it's automatic.
|
|
408
|
+
|
|
409
|
+
Depth levels:
|
|
410
|
+
- depth=0: Root model (e.g., Group)
|
|
411
|
+
- depth=1: First level relationships (e.g., Group.projects)
|
|
412
|
+
- depth=2: Second level relationships (e.g., Project.group)
|
|
413
|
+
|
|
414
|
+
With max_relationship_depth=2, we include relationships at depth 0 and 1, but not at depth 2.
|
|
415
|
+
"""
|
|
416
|
+
if depth >= self.max_relationship_depth:
|
|
417
|
+
return "id"
|
|
418
|
+
|
|
419
|
+
fields = []
|
|
420
|
+
for name, column in columns.items():
|
|
421
|
+
# Skip non-readable columns
|
|
422
|
+
if not column.is_readable:
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
# Handle relationship columns using the N+1 pattern
|
|
426
|
+
# This is ALWAYS done for columns with wants_n_plus_one=True
|
|
427
|
+
if self._is_relationship_column(column):
|
|
428
|
+
# Only include relationships if we haven't reached the max depth
|
|
429
|
+
# This prevents infinite recursion (Group → Project → Group → Project...)
|
|
430
|
+
if depth < self.max_relationship_depth - 1:
|
|
431
|
+
# Generate nested GraphQL structure instead of SQL JOIN
|
|
432
|
+
nested_field = self._build_relationship_field(column, depth)
|
|
433
|
+
if nested_field:
|
|
434
|
+
fields.append(nested_field)
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# Handle double underscore notation (e.g., "user__name" -> nested query)
|
|
438
|
+
# GraphQL natively supports nested field selection, so we can convert this easily
|
|
439
|
+
if "__" in name:
|
|
440
|
+
# Build nested GraphQL structure from double underscore notation
|
|
441
|
+
# Example: "user__name" becomes "user { name }"
|
|
442
|
+
# Example: "project__owner__email" becomes "project { owner { email } }"
|
|
443
|
+
nested_field = self._build_nested_field_from_underscore(name)
|
|
444
|
+
if nested_field:
|
|
445
|
+
fields.append(nested_field)
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
# Convert model field name to API field name
|
|
449
|
+
api_field_name = self._model_to_api_name(name)
|
|
450
|
+
fields.append(api_field_name)
|
|
451
|
+
|
|
452
|
+
return " ".join(fields) if fields else "id"
|
|
453
|
+
|
|
454
|
+
def _is_singular_resource(self, root_field: str) -> bool:
|
|
455
|
+
"""
|
|
456
|
+
Determine if a resource is singular (single object) or plural (collection).
|
|
457
|
+
|
|
458
|
+
Singular resources (e.g., currentUser, viewer, me) return a single object.
|
|
459
|
+
Plural resources (e.g., projects, users, items) return collections with pagination.
|
|
460
|
+
|
|
461
|
+
This can be explicitly configured via the is_collection parameter, or auto-detected
|
|
462
|
+
using common GraphQL naming patterns.
|
|
463
|
+
"""
|
|
464
|
+
# If explicitly configured, use that
|
|
465
|
+
if self.is_collection is not None:
|
|
466
|
+
return not self.is_collection
|
|
467
|
+
|
|
468
|
+
# Auto-detect using common GraphQL naming patterns
|
|
469
|
+
root_lower = root_field.lower()
|
|
470
|
+
|
|
471
|
+
# Common patterns for singular resources in various GraphQL APIs
|
|
472
|
+
# (GitHub, GitLab, Shopify, etc.)
|
|
473
|
+
singular_patterns = ["current", "viewer", "me", "my"]
|
|
474
|
+
|
|
475
|
+
# Check if it starts with a singular pattern
|
|
476
|
+
for pattern in singular_patterns:
|
|
477
|
+
if root_lower.startswith(pattern):
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
# If root_field ends with 's', it's likely plural (collection)
|
|
481
|
+
# Exception: words ending in 'ss' (e.g., 'address', 'business')
|
|
482
|
+
if root_field.endswith("s") and not root_field.endswith("ss"):
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
# If uncertain, check against common singular words
|
|
486
|
+
# Most GraphQL APIs use plural for collections, singular for single objects
|
|
487
|
+
# Default to plural (collection) if ends with common singular patterns
|
|
488
|
+
singular_endings = ["er", "or", "ion", "ment", "ness", "ship"]
|
|
489
|
+
for ending in singular_endings:
|
|
490
|
+
if root_lower.endswith(ending):
|
|
491
|
+
return True
|
|
492
|
+
|
|
493
|
+
# Final fallback: if it looks like a typical noun, assume singular
|
|
494
|
+
# This is a safe default as it won't add pagination structure to non-paginated queries
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
def _build_query(self, query: "Query") -> tuple[str, dict]:
|
|
498
|
+
"""
|
|
499
|
+
Dynamically build a GraphQL query from a clearskies Query object.
|
|
500
|
+
|
|
501
|
+
Returns: (query_string, variables_dict)
|
|
502
|
+
"""
|
|
503
|
+
model = query.model_class
|
|
504
|
+
root_field = self._get_root_field_name(model)
|
|
505
|
+
columns = model.get_columns()
|
|
506
|
+
|
|
507
|
+
# Build field selection
|
|
508
|
+
fields = self._build_graphql_fields(columns)
|
|
509
|
+
|
|
510
|
+
# Determine if this is a singular resource or a collection
|
|
511
|
+
is_singular = self._is_singular_resource(root_field)
|
|
512
|
+
|
|
513
|
+
# Build query arguments
|
|
514
|
+
args_parts = []
|
|
515
|
+
variables = {}
|
|
516
|
+
variable_definitions = []
|
|
517
|
+
|
|
518
|
+
# Handle filters (where conditions) - only for collections
|
|
519
|
+
if not is_singular:
|
|
520
|
+
for i, condition in enumerate(query.conditions):
|
|
521
|
+
# Convert model column name to API field name
|
|
522
|
+
api_column_name = self._model_to_api_name(condition.column_name)
|
|
523
|
+
|
|
524
|
+
if condition.operator == "=":
|
|
525
|
+
value = condition.values[0]
|
|
526
|
+
column = columns.get(condition.column_name)
|
|
527
|
+
|
|
528
|
+
# Check if this is a Select/Enum column
|
|
529
|
+
# For enum types, pass the value directly in the query (not as a variable)
|
|
530
|
+
# This avoids GraphQL type mismatch issues with enum types
|
|
531
|
+
if column and column.__class__.__name__ == "Select":
|
|
532
|
+
# Pass enum value directly in the query without variables
|
|
533
|
+
args_parts.append(f"{api_column_name}: {value}")
|
|
534
|
+
else:
|
|
535
|
+
# Use variables for non-enum types
|
|
536
|
+
var_name = f"filter_{condition.column_name}_{i}"
|
|
537
|
+
args_parts.append(f"{api_column_name}: ${var_name}")
|
|
538
|
+
|
|
539
|
+
if isinstance(value, bool) or str(value).lower() in ("true", "false"):
|
|
540
|
+
variable_definitions.append(f"${var_name}: Boolean")
|
|
541
|
+
# Convert string 'true'/'false' to boolean
|
|
542
|
+
if isinstance(value, str):
|
|
543
|
+
variables[var_name] = value.lower() == "true"
|
|
544
|
+
else:
|
|
545
|
+
variables[var_name] = value
|
|
546
|
+
elif isinstance(value, int):
|
|
547
|
+
variable_definitions.append(f"${var_name}: Int")
|
|
548
|
+
variables[var_name] = int(value) # type: ignore[assignment]
|
|
549
|
+
else:
|
|
550
|
+
variable_definitions.append(f"${var_name}: String")
|
|
551
|
+
variables[var_name] = str(value) # type: ignore[assignment]
|
|
552
|
+
elif condition.operator == "in" and len(condition.values) > 0:
|
|
553
|
+
var_name = f"filter_{condition.column_name}_in_{i}"
|
|
554
|
+
args_parts.append(f"{api_column_name}_in: ${var_name}")
|
|
555
|
+
variable_definitions.append(f"${var_name}: [String!]")
|
|
556
|
+
variables[var_name] = [str(v) for v in condition.values] # type: ignore[assignment]
|
|
557
|
+
|
|
558
|
+
# Handle pagination - only for collections
|
|
559
|
+
if not is_singular:
|
|
560
|
+
if self.pagination_style == "cursor":
|
|
561
|
+
if "cursor" in query.pagination:
|
|
562
|
+
args_parts.append("after: $after")
|
|
563
|
+
variable_definitions.append("$after: String")
|
|
564
|
+
variables["after"] = str(query.pagination["cursor"]) # type: ignore[assignment]
|
|
565
|
+
|
|
566
|
+
if query.limit:
|
|
567
|
+
args_parts.append("first: $first")
|
|
568
|
+
variable_definitions.append("$first: Int")
|
|
569
|
+
variables["first"] = int(query.limit) # type: ignore[assignment]
|
|
570
|
+
else: # offset-based pagination
|
|
571
|
+
if query.limit:
|
|
572
|
+
args_parts.append("limit: $limit")
|
|
573
|
+
variable_definitions.append("$limit: Int")
|
|
574
|
+
variables["limit"] = int(query.limit) # type: ignore[assignment]
|
|
575
|
+
|
|
576
|
+
if "start" in query.pagination:
|
|
577
|
+
args_parts.append("offset: $offset")
|
|
578
|
+
variable_definitions.append("$offset: Int")
|
|
579
|
+
variables["offset"] = int(query.pagination["start"]) # type: ignore[assignment]
|
|
580
|
+
|
|
581
|
+
# Handle sorting - only for collections
|
|
582
|
+
if not is_singular and query.sorts:
|
|
583
|
+
sort = query.sorts[0]
|
|
584
|
+
api_sort_column = self._model_to_api_name(sort.column_name)
|
|
585
|
+
args_parts.append("sortBy: $sortBy")
|
|
586
|
+
args_parts.append("sortDirection: $sortDirection")
|
|
587
|
+
variable_definitions.append("$sortBy: String")
|
|
588
|
+
variable_definitions.append("$sortDirection: String")
|
|
589
|
+
variables["sortBy"] = api_sort_column # type: ignore[assignment]
|
|
590
|
+
variables["sortDirection"] = sort.direction.upper() # type: ignore[assignment]
|
|
591
|
+
|
|
592
|
+
# Build the query string
|
|
593
|
+
args_str = f"({', '.join(args_parts)})" if args_parts else ""
|
|
594
|
+
var_def_str = f"({', '.join(variable_definitions)})" if variable_definitions else ""
|
|
595
|
+
|
|
596
|
+
# Build different query structures for singular vs plural resources
|
|
597
|
+
if is_singular:
|
|
598
|
+
# Singular resource - returns a single object directly
|
|
599
|
+
query_str = f"""
|
|
600
|
+
query GetRecords{var_def_str} {{
|
|
601
|
+
{root_field}{args_str} {{
|
|
602
|
+
{fields}
|
|
603
|
+
}}
|
|
604
|
+
}}
|
|
605
|
+
"""
|
|
606
|
+
elif self.pagination_style == "cursor":
|
|
607
|
+
# Plural resource with cursor pagination - returns connection with nodes/pageInfo
|
|
608
|
+
query_str = f"""
|
|
609
|
+
query GetRecords{var_def_str} {{
|
|
610
|
+
{root_field}{args_str} {{
|
|
611
|
+
nodes {{
|
|
612
|
+
{fields}
|
|
613
|
+
}}
|
|
614
|
+
pageInfo {{
|
|
615
|
+
endCursor
|
|
616
|
+
hasNextPage
|
|
617
|
+
}}
|
|
618
|
+
}}
|
|
619
|
+
}}
|
|
620
|
+
"""
|
|
621
|
+
else:
|
|
622
|
+
# Plural resource with offset pagination - returns array
|
|
623
|
+
query_str = f"""
|
|
624
|
+
query GetRecords{var_def_str} {{
|
|
625
|
+
{root_field}{args_str} {{
|
|
626
|
+
{fields}
|
|
627
|
+
}}
|
|
628
|
+
}}
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
return query_str, variables
|
|
632
|
+
|
|
633
|
+
def _extract_records(self, response: dict) -> list[dict]:
|
|
634
|
+
# Extract records from nested GraphQL response
|
|
635
|
+
# Support both {"data": {...}} and direct {...} responses
|
|
636
|
+
data = response.get("data", response)
|
|
637
|
+
records = data.get(self.root_field, [])
|
|
638
|
+
# If the root field is a dict, try to unwrap one more level (for single-object queries)
|
|
639
|
+
if records is None:
|
|
640
|
+
return []
|
|
641
|
+
if isinstance(records, dict):
|
|
642
|
+
# If this dict has only scalar fields, wrap it in a list (single record)
|
|
643
|
+
if not any(isinstance(v, (dict, list)) for v in records.values()):
|
|
644
|
+
return [records]
|
|
645
|
+
# If this dict has "nodes" or "edges", handle as before
|
|
646
|
+
if "nodes" in records and isinstance(records["nodes"], list):
|
|
647
|
+
return records["nodes"]
|
|
648
|
+
if "edges" in records:
|
|
649
|
+
# Relay-style connection
|
|
650
|
+
return [edge["node"] for edge in records["edges"]]
|
|
651
|
+
# Otherwise, return as a single record in a list
|
|
652
|
+
return [records]
|
|
653
|
+
return records
|
|
654
|
+
|
|
655
|
+
def _map_relationship_data(self, record: dict, column: "Column", parent_model: "Model | None" = None) -> Any:
|
|
656
|
+
"""
|
|
657
|
+
Map nested relationship data from GraphQL response to clearskies format.
|
|
658
|
+
|
|
659
|
+
Returns raw dict data (not Model instances) to maintain separation between
|
|
660
|
+
_data (raw values) and _transformed_data (processed values). The relationship
|
|
661
|
+
columns handle transformation to Model instances when accessed.
|
|
662
|
+
|
|
663
|
+
For BelongsTo relationships, returns a single dict.
|
|
664
|
+
For HasMany/ManyToMany relationships, returns a list of dicts.
|
|
665
|
+
"""
|
|
666
|
+
related_model = self._get_relationship_model(column)
|
|
667
|
+
if not related_model:
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
api_field_name = self._model_to_api_name(column.name)
|
|
671
|
+
nested_data = record.get(api_field_name)
|
|
672
|
+
|
|
673
|
+
if nested_data is None:
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
column_type = column.__class__.__name__
|
|
677
|
+
|
|
678
|
+
# BelongsTo: single object - return raw dict
|
|
679
|
+
if column_type in ["BelongsTo", "BelongsToModel", "BelongsToId"]:
|
|
680
|
+
if isinstance(nested_data, dict):
|
|
681
|
+
# Map and return the raw dict data
|
|
682
|
+
return self._map_record(nested_data, related_model.get_columns())
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
# HasMany/ManyToMany: collection - return list of raw dicts
|
|
686
|
+
if column_type in ["HasMany", "ManyToMany"]:
|
|
687
|
+
# Extract nodes from connection pattern
|
|
688
|
+
nodes = []
|
|
689
|
+
if isinstance(nested_data, dict) and "nodes" in nested_data:
|
|
690
|
+
nodes = nested_data["nodes"] if isinstance(nested_data["nodes"], list) else []
|
|
691
|
+
elif isinstance(nested_data, list):
|
|
692
|
+
nodes = nested_data
|
|
693
|
+
|
|
694
|
+
# Map each node to a raw dict (NOT Model instances)
|
|
695
|
+
child_dicts = []
|
|
696
|
+
for node in nodes:
|
|
697
|
+
child_data = self._map_record(node, related_model.get_columns())
|
|
698
|
+
child_dicts.append(child_data)
|
|
699
|
+
|
|
700
|
+
return child_dicts
|
|
701
|
+
|
|
702
|
+
return None
|
|
703
|
+
|
|
704
|
+
def _map_record(self, record: dict, columns: dict, parent_model: "Model | None" = None) -> dict:
|
|
705
|
+
"""
|
|
706
|
+
Map GraphQL response record to clearskies model format.
|
|
707
|
+
|
|
708
|
+
Handles case conversion from API format to model format.
|
|
709
|
+
Flattens nested GraphQL records to clearskies flat dict.
|
|
710
|
+
Supports nested relationship data mapping.
|
|
711
|
+
"""
|
|
712
|
+
flat = {}
|
|
713
|
+
for name, col in columns.items():
|
|
714
|
+
# Handle relationship columns
|
|
715
|
+
if self._is_relationship_column(col):
|
|
716
|
+
relationship_data = self._map_relationship_data(record, col, parent_model)
|
|
717
|
+
if relationship_data is not None:
|
|
718
|
+
flat[name] = relationship_data
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
# Handle nested field notation (e.g., "user__name")
|
|
722
|
+
if "__" in name:
|
|
723
|
+
value = record
|
|
724
|
+
for part in name.split("__"):
|
|
725
|
+
api_part = self._model_to_api_name(part)
|
|
726
|
+
if isinstance(value, dict):
|
|
727
|
+
value = value.get(api_part) # type: ignore[assignment]
|
|
728
|
+
else:
|
|
729
|
+
value = None
|
|
730
|
+
flat[name] = value
|
|
731
|
+
else:
|
|
732
|
+
# Simple field - convert name and extract value
|
|
733
|
+
api_field_name = self._model_to_api_name(name)
|
|
734
|
+
flat[name] = record.get(api_field_name) # type: ignore[assignment]
|
|
735
|
+
|
|
736
|
+
return flat
|
|
737
|
+
|
|
738
|
+
def _build_mutation(
|
|
739
|
+
self, operation: str, model: "Model", data: dict[str, Any], id: int | str | None = None
|
|
740
|
+
) -> tuple[str, dict]:
|
|
741
|
+
"""
|
|
742
|
+
Dynamically build a GraphQL mutation.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
operation: "create", "update", or "delete"
|
|
746
|
+
model: The clearskies Model
|
|
747
|
+
data: Data to mutate
|
|
748
|
+
id: Record ID (for update/delete)
|
|
749
|
+
|
|
750
|
+
Returns: (mutation_string, variables_dict)
|
|
751
|
+
"""
|
|
752
|
+
root_field = self._get_root_field_name(model)
|
|
753
|
+
mutation_name = f"{operation}{root_field.capitalize()}"
|
|
754
|
+
columns = model.get_columns()
|
|
755
|
+
fields = self._build_graphql_fields(columns)
|
|
756
|
+
|
|
757
|
+
variables = {}
|
|
758
|
+
variable_definitions = []
|
|
759
|
+
args_parts = []
|
|
760
|
+
|
|
761
|
+
if operation in ["update", "delete"]:
|
|
762
|
+
variable_definitions.append("$id: ID!")
|
|
763
|
+
args_parts.append("id: $id")
|
|
764
|
+
variables["id"] = str(id)
|
|
765
|
+
|
|
766
|
+
if operation in ["create", "update"]:
|
|
767
|
+
# Build input object - convert model field names to API field names
|
|
768
|
+
for key, value in data.items():
|
|
769
|
+
if key in columns and columns[key].is_writeable:
|
|
770
|
+
api_field_name = self._model_to_api_name(key)
|
|
771
|
+
var_name = f"input_{key}"
|
|
772
|
+
variable_definitions.append(f"${var_name}: String")
|
|
773
|
+
args_parts.append(f"{api_field_name}: ${var_name}")
|
|
774
|
+
variables[var_name] = str(value) if value is not None else None # type: ignore[assignment]
|
|
775
|
+
|
|
776
|
+
var_def_str = f"({', '.join(variable_definitions)})" if variable_definitions else ""
|
|
777
|
+
args_str = f"({', '.join(args_parts)})" if args_parts else ""
|
|
778
|
+
|
|
779
|
+
if operation == "delete":
|
|
780
|
+
mutation_str = f"""
|
|
781
|
+
mutation {mutation_name}{var_def_str} {{
|
|
782
|
+
{operation}{root_field.capitalize()}{args_str} {{
|
|
783
|
+
success
|
|
784
|
+
}}
|
|
785
|
+
}}
|
|
786
|
+
"""
|
|
787
|
+
else:
|
|
788
|
+
mutation_str = f"""
|
|
789
|
+
mutation {mutation_name}{var_def_str} {{
|
|
790
|
+
{operation}{root_field.capitalize()}{args_str} {{
|
|
791
|
+
{fields}
|
|
792
|
+
}}
|
|
793
|
+
}}
|
|
794
|
+
"""
|
|
795
|
+
|
|
796
|
+
return mutation_str, variables
|
|
797
|
+
|
|
798
|
+
def update(self, id: int | str, data: dict[str, Any], model: "Model") -> dict[str, Any]:
|
|
799
|
+
"""Update a record via GraphQL mutation."""
|
|
800
|
+
mutation_str, variables = self._build_mutation("update", model, data, id)
|
|
801
|
+
try:
|
|
802
|
+
response = self.client.execute(mutation_str, variable_values=variables)
|
|
803
|
+
records = self._extract_records(response)
|
|
804
|
+
if not records:
|
|
805
|
+
raise Exception("No data returned from update mutation")
|
|
806
|
+
return self._map_record(records[0], model.get_columns())
|
|
807
|
+
except Exception as e:
|
|
808
|
+
raise Exception(f"GraphQL update failed: {e}")
|
|
809
|
+
|
|
810
|
+
def create(self, data: dict[str, Any], model: "Model") -> dict[str, Any]:
|
|
811
|
+
"""Create a record via GraphQL mutation."""
|
|
812
|
+
mutation_str, variables = self._build_mutation("create", model, data)
|
|
813
|
+
try:
|
|
814
|
+
response = self.client.execute(mutation_str, variable_values=variables)
|
|
815
|
+
records = self._extract_records(response)
|
|
816
|
+
if not records:
|
|
817
|
+
raise Exception("No data returned from create mutation")
|
|
818
|
+
return self._map_record(records[0], model.get_columns())
|
|
819
|
+
except Exception as e:
|
|
820
|
+
raise Exception(f"GraphQL create failed: {e}")
|
|
821
|
+
|
|
822
|
+
def delete(self, id: int | str, model: "Model") -> bool:
|
|
823
|
+
"""Delete a record via GraphQL mutation."""
|
|
824
|
+
mutation_str, variables = self._build_mutation("delete", model, {}, id)
|
|
825
|
+
try:
|
|
826
|
+
self.client.execute(mutation_str, variable_values=variables)
|
|
827
|
+
return True
|
|
828
|
+
except Exception as e:
|
|
829
|
+
raise Exception(f"GraphQL delete failed: {e}")
|
|
830
|
+
|
|
831
|
+
def count(self, query: "Query") -> int:
|
|
832
|
+
"""
|
|
833
|
+
Return the count of records matching the query.
|
|
834
|
+
|
|
835
|
+
Attempts to use a dedicated count field or falls back to counting returned records.
|
|
836
|
+
"""
|
|
837
|
+
# Try to build a count query
|
|
838
|
+
model = query.model_class
|
|
839
|
+
root_field = self._get_root_field_name(model)
|
|
840
|
+
|
|
841
|
+
# First, try a dedicated count query
|
|
842
|
+
count_query = f"""
|
|
843
|
+
query {{
|
|
844
|
+
{root_field}Count
|
|
845
|
+
}}
|
|
846
|
+
"""
|
|
847
|
+
try:
|
|
848
|
+
response = self.client.execute(count_query)
|
|
849
|
+
data = response.get("data", {})
|
|
850
|
+
if f"{root_field}Count" in data:
|
|
851
|
+
return int(data[f"{root_field}Count"])
|
|
852
|
+
except Exception:
|
|
853
|
+
# Count query not supported, fall back to fetching and counting
|
|
854
|
+
pass
|
|
855
|
+
|
|
856
|
+
# Fallback: fetch records and count them
|
|
857
|
+
query_str, variables = self._build_query(query)
|
|
858
|
+
try:
|
|
859
|
+
response = self.client.execute(query_str, variable_values=variables)
|
|
860
|
+
return len(self._extract_records(response))
|
|
861
|
+
except Exception as e:
|
|
862
|
+
raise Exception(f"GraphQL count failed: {e}")
|
|
863
|
+
|
|
864
|
+
def records(self, query: "Query", next_page_data: dict[str, str | int] | None = None) -> list[dict[str, Any]]:
|
|
865
|
+
"""
|
|
866
|
+
Fetch records matching the query.
|
|
867
|
+
|
|
868
|
+
Handles pagination data to enable fetching additional pages.
|
|
869
|
+
Supports pre-loaded records from relationship columns.
|
|
870
|
+
"""
|
|
871
|
+
# Check if query has pre-loaded records (from relationship columns)
|
|
872
|
+
self.logger.debug(f"Checking for pre-loaded records. hasattr: {hasattr(query, '_pre_loaded_records')}")
|
|
873
|
+
self.logger.debug(f"Query attributes: {dir(query)}")
|
|
874
|
+
if hasattr(query, "_pre_loaded_records"):
|
|
875
|
+
self.logger.debug("Using pre-loaded relationship data, skipping GraphQL query")
|
|
876
|
+
pre_loaded = query._pre_loaded_records # type: ignore[attr-defined]
|
|
877
|
+
# Clear the pre-loaded data to avoid reuse
|
|
878
|
+
delattr(query, "_pre_loaded_records")
|
|
879
|
+
return pre_loaded
|
|
880
|
+
|
|
881
|
+
query_str, variables = self._build_query(query)
|
|
882
|
+
self.logger.info(f"GraphQL Query:\n{query_str}")
|
|
883
|
+
self.logger.info(f"Variables: {variables}")
|
|
884
|
+
try:
|
|
885
|
+
response = self.client.execute(query_str, variable_values=variables)
|
|
886
|
+
self.logger.debug(f"GraphQL response: {response}")
|
|
887
|
+
|
|
888
|
+
# Extract records from response
|
|
889
|
+
records = self._extract_records(response)
|
|
890
|
+
self.logger.debug(f"Extracted {len(records)} records from GraphQL response.")
|
|
891
|
+
|
|
892
|
+
# Map records to clearskies format
|
|
893
|
+
mapped = [self._map_record(r, query.model_class.get_columns()) for r in records]
|
|
894
|
+
self.logger.debug(f"Mapped records: {mapped}")
|
|
895
|
+
|
|
896
|
+
# Handle pagination
|
|
897
|
+
if isinstance(next_page_data, dict):
|
|
898
|
+
if self.pagination_style == "cursor":
|
|
899
|
+
# Extract cursor from pageInfo
|
|
900
|
+
data = response.get("data", response)
|
|
901
|
+
root_field = self._get_root_field_name(query.model_class)
|
|
902
|
+
root_data = data.get(root_field, {})
|
|
903
|
+
|
|
904
|
+
if "pageInfo" in root_data:
|
|
905
|
+
page_info = root_data["pageInfo"]
|
|
906
|
+
if page_info.get("hasNextPage"):
|
|
907
|
+
next_page_data["cursor"] = str(page_info.get("endCursor", "")) # type: ignore[assignment]
|
|
908
|
+
else:
|
|
909
|
+
# Offset-based pagination
|
|
910
|
+
limit = query.limit
|
|
911
|
+
start = query.pagination.get("start", 0)
|
|
912
|
+
if limit and len(records) == limit:
|
|
913
|
+
next_page_data["start"] = int(start) + int(limit)
|
|
914
|
+
|
|
915
|
+
return mapped
|
|
916
|
+
except Exception as e:
|
|
917
|
+
self.logger.error(f"GraphQL records failed: {e}")
|
|
918
|
+
raise Exception(f"GraphQL records failed: {e}")
|
|
919
|
+
|
|
920
|
+
def validate_pagination_data(self, data: dict[str, Any], case_mapping: Callable[[str], str]) -> str:
|
|
921
|
+
"""Validate pagination data based on the configured pagination style."""
|
|
922
|
+
allowed_keys = set(self.allowed_pagination_keys())
|
|
923
|
+
extra_keys = set(data.keys()) - allowed_keys
|
|
924
|
+
|
|
925
|
+
if extra_keys:
|
|
926
|
+
return f"Invalid pagination key(s): '{','.join(extra_keys)}'. Allowed keys: {', '.join(allowed_keys)}"
|
|
927
|
+
|
|
928
|
+
if self.pagination_style == "cursor":
|
|
929
|
+
if data and "cursor" not in data:
|
|
930
|
+
key_name = case_mapping("cursor")
|
|
931
|
+
return f"You must specify '{key_name}' when setting pagination"
|
|
932
|
+
else: # offset
|
|
933
|
+
if data and "start" not in data:
|
|
934
|
+
key_name = case_mapping("start")
|
|
935
|
+
return f"You must specify '{key_name}' when setting pagination"
|
|
936
|
+
if "start" in data:
|
|
937
|
+
try:
|
|
938
|
+
int(data["start"])
|
|
939
|
+
except Exception:
|
|
940
|
+
key_name = case_mapping("start")
|
|
941
|
+
return f"Invalid pagination data: '{key_name}' must be a number"
|
|
942
|
+
|
|
943
|
+
return ""
|
|
944
|
+
|
|
945
|
+
def allowed_pagination_keys(self) -> list[str]:
|
|
946
|
+
"""Return allowed pagination keys based on style."""
|
|
947
|
+
if self.pagination_style == "cursor":
|
|
948
|
+
return ["cursor"]
|
|
949
|
+
return ["start"]
|
|
950
|
+
|
|
951
|
+
def documentation_pagination_next_page_response(self, case_mapping: Callable) -> list[Any]:
|
|
952
|
+
"""Return pagination documentation for responses."""
|
|
953
|
+
if self.pagination_style == "cursor":
|
|
954
|
+
return [AutoDocString(case_mapping("cursor"), example="eyJpZCI6IjEyMyJ9")]
|
|
955
|
+
return [AutoDocInteger(case_mapping("start"), example=0)]
|
|
956
|
+
|
|
957
|
+
def documentation_pagination_next_page_example(self, case_mapping: Callable) -> dict[str, Any]:
|
|
958
|
+
"""Return example pagination data."""
|
|
959
|
+
if self.pagination_style == "cursor":
|
|
960
|
+
return {case_mapping("cursor"): "eyJpZCI6IjEyMyJ9"}
|
|
961
|
+
return {case_mapping("start"): 0}
|
|
962
|
+
|
|
963
|
+
def documentation_pagination_parameters(self, case_mapping: Callable) -> list[tuple[AutoDocSchema, str]]:
|
|
964
|
+
"""Return pagination parameter documentation."""
|
|
965
|
+
if self.pagination_style == "cursor":
|
|
966
|
+
return [
|
|
967
|
+
(
|
|
968
|
+
AutoDocString(case_mapping("cursor"), example="eyJpZCI6IjEyMyJ9"),
|
|
969
|
+
"A cursor token to fetch the next page of results",
|
|
970
|
+
)
|
|
971
|
+
]
|
|
972
|
+
return [
|
|
973
|
+
(
|
|
974
|
+
AutoDocInteger(case_mapping("start"), example=0),
|
|
975
|
+
"The zero-indexed record number to start listing results from",
|
|
976
|
+
)
|
|
977
|
+
]
|