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.

Files changed (270) hide show
  1. clear_skies-2.0.27.dist-info/METADATA +78 -0
  2. clear_skies-2.0.27.dist-info/RECORD +270 -0
  3. clear_skies-2.0.27.dist-info/WHEEL +4 -0
  4. clear_skies-2.0.27.dist-info/licenses/LICENSE +7 -0
  5. clearskies/__init__.py +69 -0
  6. clearskies/action.py +7 -0
  7. clearskies/authentication/__init__.py +15 -0
  8. clearskies/authentication/authentication.py +44 -0
  9. clearskies/authentication/authorization.py +23 -0
  10. clearskies/authentication/authorization_pass_through.py +22 -0
  11. clearskies/authentication/jwks.py +165 -0
  12. clearskies/authentication/public.py +5 -0
  13. clearskies/authentication/secret_bearer.py +551 -0
  14. clearskies/autodoc/__init__.py +8 -0
  15. clearskies/autodoc/formats/__init__.py +5 -0
  16. clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
  17. clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
  18. clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
  19. clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
  20. clearskies/autodoc/formats/oai3_json/request.py +68 -0
  21. clearskies/autodoc/formats/oai3_json/response.py +28 -0
  22. clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
  23. clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
  24. clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
  25. clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
  26. clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
  27. clearskies/autodoc/formats/oai3_json/test.json +1985 -0
  28. clearskies/autodoc/py.typed +0 -0
  29. clearskies/autodoc/request/__init__.py +15 -0
  30. clearskies/autodoc/request/header.py +6 -0
  31. clearskies/autodoc/request/json_body.py +6 -0
  32. clearskies/autodoc/request/parameter.py +8 -0
  33. clearskies/autodoc/request/request.py +47 -0
  34. clearskies/autodoc/request/url_parameter.py +6 -0
  35. clearskies/autodoc/request/url_path.py +6 -0
  36. clearskies/autodoc/response/__init__.py +5 -0
  37. clearskies/autodoc/response/response.py +9 -0
  38. clearskies/autodoc/schema/__init__.py +31 -0
  39. clearskies/autodoc/schema/array.py +10 -0
  40. clearskies/autodoc/schema/base64.py +8 -0
  41. clearskies/autodoc/schema/boolean.py +5 -0
  42. clearskies/autodoc/schema/date.py +5 -0
  43. clearskies/autodoc/schema/datetime.py +5 -0
  44. clearskies/autodoc/schema/double.py +5 -0
  45. clearskies/autodoc/schema/enum.py +17 -0
  46. clearskies/autodoc/schema/integer.py +6 -0
  47. clearskies/autodoc/schema/long.py +5 -0
  48. clearskies/autodoc/schema/number.py +6 -0
  49. clearskies/autodoc/schema/object.py +13 -0
  50. clearskies/autodoc/schema/password.py +5 -0
  51. clearskies/autodoc/schema/schema.py +11 -0
  52. clearskies/autodoc/schema/string.py +5 -0
  53. clearskies/backends/__init__.py +67 -0
  54. clearskies/backends/api_backend.py +1194 -0
  55. clearskies/backends/backend.py +137 -0
  56. clearskies/backends/cursor_backend.py +339 -0
  57. clearskies/backends/graphql_backend.py +977 -0
  58. clearskies/backends/memory_backend.py +794 -0
  59. clearskies/backends/secrets_backend.py +100 -0
  60. clearskies/clients/__init__.py +5 -0
  61. clearskies/clients/graphql_client.py +182 -0
  62. clearskies/column.py +1221 -0
  63. clearskies/columns/__init__.py +71 -0
  64. clearskies/columns/audit.py +306 -0
  65. clearskies/columns/belongs_to_id.py +478 -0
  66. clearskies/columns/belongs_to_model.py +145 -0
  67. clearskies/columns/belongs_to_self.py +109 -0
  68. clearskies/columns/boolean.py +110 -0
  69. clearskies/columns/category_tree.py +274 -0
  70. clearskies/columns/category_tree_ancestors.py +51 -0
  71. clearskies/columns/category_tree_children.py +125 -0
  72. clearskies/columns/category_tree_descendants.py +48 -0
  73. clearskies/columns/created.py +92 -0
  74. clearskies/columns/created_by_authorization_data.py +114 -0
  75. clearskies/columns/created_by_header.py +103 -0
  76. clearskies/columns/created_by_ip.py +90 -0
  77. clearskies/columns/created_by_routing_data.py +102 -0
  78. clearskies/columns/created_by_user_agent.py +89 -0
  79. clearskies/columns/date.py +232 -0
  80. clearskies/columns/datetime.py +284 -0
  81. clearskies/columns/email.py +78 -0
  82. clearskies/columns/float.py +149 -0
  83. clearskies/columns/has_many.py +552 -0
  84. clearskies/columns/has_many_self.py +62 -0
  85. clearskies/columns/has_one.py +21 -0
  86. clearskies/columns/integer.py +158 -0
  87. clearskies/columns/json.py +126 -0
  88. clearskies/columns/many_to_many_ids.py +335 -0
  89. clearskies/columns/many_to_many_ids_with_data.py +281 -0
  90. clearskies/columns/many_to_many_models.py +163 -0
  91. clearskies/columns/many_to_many_pivots.py +132 -0
  92. clearskies/columns/phone.py +162 -0
  93. clearskies/columns/select.py +95 -0
  94. clearskies/columns/string.py +102 -0
  95. clearskies/columns/timestamp.py +164 -0
  96. clearskies/columns/updated.py +107 -0
  97. clearskies/columns/uuid.py +83 -0
  98. clearskies/configs/README.md +105 -0
  99. clearskies/configs/__init__.py +170 -0
  100. clearskies/configs/actions.py +43 -0
  101. clearskies/configs/any.py +15 -0
  102. clearskies/configs/any_dict.py +24 -0
  103. clearskies/configs/any_dict_or_callable.py +25 -0
  104. clearskies/configs/authentication.py +23 -0
  105. clearskies/configs/authorization.py +23 -0
  106. clearskies/configs/boolean.py +18 -0
  107. clearskies/configs/boolean_or_callable.py +20 -0
  108. clearskies/configs/callable_config.py +20 -0
  109. clearskies/configs/columns.py +34 -0
  110. clearskies/configs/conditions.py +30 -0
  111. clearskies/configs/config.py +26 -0
  112. clearskies/configs/datetime.py +20 -0
  113. clearskies/configs/datetime_or_callable.py +21 -0
  114. clearskies/configs/email.py +10 -0
  115. clearskies/configs/email_list.py +17 -0
  116. clearskies/configs/email_list_or_callable.py +17 -0
  117. clearskies/configs/email_or_email_list_or_callable.py +59 -0
  118. clearskies/configs/endpoint.py +23 -0
  119. clearskies/configs/endpoint_list.py +29 -0
  120. clearskies/configs/float.py +18 -0
  121. clearskies/configs/float_or_callable.py +20 -0
  122. clearskies/configs/headers.py +28 -0
  123. clearskies/configs/integer.py +18 -0
  124. clearskies/configs/integer_or_callable.py +20 -0
  125. clearskies/configs/joins.py +30 -0
  126. clearskies/configs/list_any_dict.py +32 -0
  127. clearskies/configs/list_any_dict_or_callable.py +33 -0
  128. clearskies/configs/model_class.py +35 -0
  129. clearskies/configs/model_column.py +67 -0
  130. clearskies/configs/model_columns.py +58 -0
  131. clearskies/configs/model_destination_name.py +26 -0
  132. clearskies/configs/model_to_id_column.py +45 -0
  133. clearskies/configs/readable_model_column.py +11 -0
  134. clearskies/configs/readable_model_columns.py +11 -0
  135. clearskies/configs/schema.py +23 -0
  136. clearskies/configs/searchable_model_columns.py +11 -0
  137. clearskies/configs/security_headers.py +39 -0
  138. clearskies/configs/select.py +28 -0
  139. clearskies/configs/select_list.py +49 -0
  140. clearskies/configs/string.py +31 -0
  141. clearskies/configs/string_dict.py +34 -0
  142. clearskies/configs/string_list.py +47 -0
  143. clearskies/configs/string_list_or_callable.py +48 -0
  144. clearskies/configs/string_or_callable.py +18 -0
  145. clearskies/configs/timedelta.py +20 -0
  146. clearskies/configs/timezone.py +20 -0
  147. clearskies/configs/url.py +25 -0
  148. clearskies/configs/validators.py +45 -0
  149. clearskies/configs/writeable_model_column.py +11 -0
  150. clearskies/configs/writeable_model_columns.py +11 -0
  151. clearskies/configurable.py +78 -0
  152. clearskies/contexts/__init__.py +11 -0
  153. clearskies/contexts/cli.py +130 -0
  154. clearskies/contexts/context.py +99 -0
  155. clearskies/contexts/wsgi.py +79 -0
  156. clearskies/contexts/wsgi_ref.py +87 -0
  157. clearskies/cursors/__init__.py +10 -0
  158. clearskies/cursors/cursor.py +161 -0
  159. clearskies/cursors/from_environment/__init__.py +5 -0
  160. clearskies/cursors/from_environment/mysql.py +51 -0
  161. clearskies/cursors/from_environment/postgresql.py +49 -0
  162. clearskies/cursors/from_environment/sqlite.py +35 -0
  163. clearskies/cursors/mysql.py +61 -0
  164. clearskies/cursors/postgresql.py +61 -0
  165. clearskies/cursors/sqlite.py +62 -0
  166. clearskies/decorators.py +33 -0
  167. clearskies/decorators.pyi +10 -0
  168. clearskies/di/__init__.py +15 -0
  169. clearskies/di/additional_config.py +130 -0
  170. clearskies/di/additional_config_auto_import.py +17 -0
  171. clearskies/di/di.py +948 -0
  172. clearskies/di/inject/__init__.py +25 -0
  173. clearskies/di/inject/akeyless_sdk.py +16 -0
  174. clearskies/di/inject/by_class.py +24 -0
  175. clearskies/di/inject/by_name.py +22 -0
  176. clearskies/di/inject/di.py +16 -0
  177. clearskies/di/inject/environment.py +15 -0
  178. clearskies/di/inject/input_output.py +19 -0
  179. clearskies/di/inject/logger.py +16 -0
  180. clearskies/di/inject/now.py +16 -0
  181. clearskies/di/inject/requests.py +16 -0
  182. clearskies/di/inject/secrets.py +15 -0
  183. clearskies/di/inject/utcnow.py +16 -0
  184. clearskies/di/inject/uuid.py +16 -0
  185. clearskies/di/injectable.py +32 -0
  186. clearskies/di/injectable_properties.py +131 -0
  187. clearskies/end.py +219 -0
  188. clearskies/endpoint.py +1303 -0
  189. clearskies/endpoint_group.py +333 -0
  190. clearskies/endpoints/__init__.py +25 -0
  191. clearskies/endpoints/advanced_search.py +519 -0
  192. clearskies/endpoints/callable.py +382 -0
  193. clearskies/endpoints/create.py +201 -0
  194. clearskies/endpoints/delete.py +133 -0
  195. clearskies/endpoints/get.py +267 -0
  196. clearskies/endpoints/health_check.py +181 -0
  197. clearskies/endpoints/list.py +567 -0
  198. clearskies/endpoints/restful_api.py +417 -0
  199. clearskies/endpoints/schema.py +185 -0
  200. clearskies/endpoints/simple_search.py +279 -0
  201. clearskies/endpoints/update.py +188 -0
  202. clearskies/environment.py +106 -0
  203. clearskies/exceptions/__init__.py +19 -0
  204. clearskies/exceptions/authentication.py +2 -0
  205. clearskies/exceptions/authorization.py +2 -0
  206. clearskies/exceptions/client_error.py +2 -0
  207. clearskies/exceptions/input_errors.py +4 -0
  208. clearskies/exceptions/missing_dependency.py +2 -0
  209. clearskies/exceptions/moved_permanently.py +3 -0
  210. clearskies/exceptions/moved_temporarily.py +3 -0
  211. clearskies/exceptions/not_found.py +2 -0
  212. clearskies/functional/__init__.py +7 -0
  213. clearskies/functional/json.py +47 -0
  214. clearskies/functional/routing.py +92 -0
  215. clearskies/functional/string.py +112 -0
  216. clearskies/functional/validations.py +76 -0
  217. clearskies/input_outputs/__init__.py +13 -0
  218. clearskies/input_outputs/cli.py +157 -0
  219. clearskies/input_outputs/exceptions/__init__.py +7 -0
  220. clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
  221. clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
  222. clearskies/input_outputs/headers.py +54 -0
  223. clearskies/input_outputs/input_output.py +116 -0
  224. clearskies/input_outputs/programmatic.py +62 -0
  225. clearskies/input_outputs/py.typed +0 -0
  226. clearskies/input_outputs/wsgi.py +80 -0
  227. clearskies/loggable.py +19 -0
  228. clearskies/model.py +2039 -0
  229. clearskies/py.typed +0 -0
  230. clearskies/query/__init__.py +12 -0
  231. clearskies/query/condition.py +228 -0
  232. clearskies/query/join.py +136 -0
  233. clearskies/query/query.py +195 -0
  234. clearskies/query/sort.py +27 -0
  235. clearskies/schema.py +82 -0
  236. clearskies/secrets/__init__.py +7 -0
  237. clearskies/secrets/additional_configs/__init__.py +32 -0
  238. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
  239. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
  240. clearskies/secrets/akeyless.py +507 -0
  241. clearskies/secrets/exceptions/__init__.py +7 -0
  242. clearskies/secrets/exceptions/not_found_error.py +2 -0
  243. clearskies/secrets/exceptions/permissions_error.py +2 -0
  244. clearskies/secrets/secrets.py +39 -0
  245. clearskies/security_header.py +17 -0
  246. clearskies/security_headers/__init__.py +11 -0
  247. clearskies/security_headers/cache_control.py +68 -0
  248. clearskies/security_headers/cors.py +51 -0
  249. clearskies/security_headers/csp.py +95 -0
  250. clearskies/security_headers/hsts.py +23 -0
  251. clearskies/security_headers/x_content_type_options.py +0 -0
  252. clearskies/security_headers/x_frame_options.py +0 -0
  253. clearskies/typing.py +11 -0
  254. clearskies/validator.py +36 -0
  255. clearskies/validators/__init__.py +33 -0
  256. clearskies/validators/after_column.py +61 -0
  257. clearskies/validators/before_column.py +15 -0
  258. clearskies/validators/in_the_future.py +29 -0
  259. clearskies/validators/in_the_future_at_least.py +13 -0
  260. clearskies/validators/in_the_future_at_most.py +12 -0
  261. clearskies/validators/in_the_past.py +29 -0
  262. clearskies/validators/in_the_past_at_least.py +12 -0
  263. clearskies/validators/in_the_past_at_most.py +12 -0
  264. clearskies/validators/maximum_length.py +25 -0
  265. clearskies/validators/maximum_value.py +28 -0
  266. clearskies/validators/minimum_length.py +25 -0
  267. clearskies/validators/minimum_value.py +28 -0
  268. clearskies/validators/required.py +32 -0
  269. clearskies/validators/timedelta.py +58 -0
  270. 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
+ ]