nexusx 2.0.0__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.
- nexusx/__init__.py +117 -0
- nexusx/context.py +212 -0
- nexusx/decorator.py +102 -0
- nexusx/discovery/__init__.py +5 -0
- nexusx/discovery/entity_discovery.py +112 -0
- nexusx/er_diagram.py +222 -0
- nexusx/execution/__init__.py +6 -0
- nexusx/execution/argument_builder.py +199 -0
- nexusx/execution/field_tree_builder.py +37 -0
- nexusx/execution/query_executor.py +486 -0
- nexusx/graphiql.py +68 -0
- nexusx/handler.py +183 -0
- nexusx/introspection.py +805 -0
- nexusx/loader/__init__.py +19 -0
- nexusx/loader/factories.py +570 -0
- nexusx/loader/pagination.py +107 -0
- nexusx/loader/query_meta.py +162 -0
- nexusx/loader/registry.py +513 -0
- nexusx/mcp/__init__.py +56 -0
- nexusx/mcp/builders/__init__.py +7 -0
- nexusx/mcp/builders/schema_formatter.py +264 -0
- nexusx/mcp/builders/type_tracer.py +163 -0
- nexusx/mcp/managers/__init__.py +7 -0
- nexusx/mcp/managers/app_resources.py +46 -0
- nexusx/mcp/managers/multi_app_manager.py +129 -0
- nexusx/mcp/managers/single_app_manager.py +82 -0
- nexusx/mcp/server.py +213 -0
- nexusx/mcp/tools/__init__.py +15 -0
- nexusx/mcp/tools/get_operation_schema.py +202 -0
- nexusx/mcp/tools/graphql_mutation.py +87 -0
- nexusx/mcp/tools/graphql_query.py +88 -0
- nexusx/mcp/tools/list_operations.py +89 -0
- nexusx/mcp/tools/multi_app_tools.py +467 -0
- nexusx/mcp/tools/simple_tools.py +190 -0
- nexusx/mcp/types/__init__.py +15 -0
- nexusx/mcp/types/app_config.py +29 -0
- nexusx/mcp/types/errors.py +97 -0
- nexusx/query_parser.py +150 -0
- nexusx/relationship.py +98 -0
- nexusx/resolver.py +735 -0
- nexusx/response_builder.py +350 -0
- nexusx/scanning/__init__.py +5 -0
- nexusx/scanning/method_scanner.py +76 -0
- nexusx/sdl_generator.py +706 -0
- nexusx/standard_queries.py +237 -0
- nexusx/subset.py +676 -0
- nexusx/type_converter.py +163 -0
- nexusx/use_case/__init__.py +19 -0
- nexusx/use_case/business.py +129 -0
- nexusx/use_case/context.py +30 -0
- nexusx/use_case/introspector.py +566 -0
- nexusx/use_case/manager.py +149 -0
- nexusx/use_case/router.py +329 -0
- nexusx/use_case/server.py +433 -0
- nexusx/use_case/types.py +35 -0
- nexusx/utils/__init__.py +5 -0
- nexusx/utils/naming.py +57 -0
- nexusx/utils/schema_helpers.py +131 -0
- nexusx/utils/type_compat.py +112 -0
- nexusx/utils/type_utils.py +72 -0
- nexusx/voyager/__init__.py +8 -0
- nexusx/voyager/create_voyager.py +189 -0
- nexusx/voyager/er_diagram_dot.py +231 -0
- nexusx/voyager/filter.py +275 -0
- nexusx/voyager/module.py +98 -0
- nexusx/voyager/render.py +581 -0
- nexusx/voyager/render_style.py +112 -0
- nexusx/voyager/templates/dot/cluster.j2 +10 -0
- nexusx/voyager/templates/dot/cluster_container.j2 +9 -0
- nexusx/voyager/templates/dot/digraph.j2 +25 -0
- nexusx/voyager/templates/dot/er_diagram.j2 +29 -0
- nexusx/voyager/templates/dot/link.j2 +1 -0
- nexusx/voyager/templates/dot/route_node.j2 +5 -0
- nexusx/voyager/templates/dot/schema_node.j2 +5 -0
- nexusx/voyager/templates/dot/tag_node.j2 +5 -0
- nexusx/voyager/templates/html/colored_text.j2 +1 -0
- nexusx/voyager/templates/html/pydantic_meta.j2 +1 -0
- nexusx/voyager/templates/html/schema_field_row.j2 +1 -0
- nexusx/voyager/templates/html/schema_header.j2 +1 -0
- nexusx/voyager/templates/html/schema_table.j2 +4 -0
- nexusx/voyager/type.py +107 -0
- nexusx/voyager/type_helper.py +324 -0
- nexusx/voyager/use_case_voyager.py +344 -0
- nexusx/voyager/voyager_context.py +282 -0
- nexusx/voyager/web/component/demo.js +17 -0
- nexusx/voyager/web/component/loader-code-display.js +135 -0
- nexusx/voyager/web/component/render-graph.js +86 -0
- nexusx/voyager/web/component/route-code-display.js +123 -0
- nexusx/voyager/web/component/schema-code-display.js +203 -0
- nexusx/voyager/web/graph-ui.js +467 -0
- nexusx/voyager/web/graphviz.svg.css +64 -0
- nexusx/voyager/web/graphviz.svg.js +638 -0
- nexusx/voyager/web/icon/android-chrome-192x192.png +0 -0
- nexusx/voyager/web/icon/android-chrome-512x512.png +0 -0
- nexusx/voyager/web/icon/apple-touch-icon.png +0 -0
- nexusx/voyager/web/icon/favicon-16x16.png +0 -0
- nexusx/voyager/web/icon/favicon-32x32.png +0 -0
- nexusx/voyager/web/icon/favicon.ico +0 -0
- nexusx/voyager/web/icon/site.webmanifest +16 -0
- nexusx/voyager/web/index.html +708 -0
- nexusx/voyager/web/magnifying-glass.js +447 -0
- nexusx/voyager/web/manifest.webmanifest +5 -0
- nexusx/voyager/web/quasar.min.css +1 -0
- nexusx/voyager/web/quasar.min.js +127 -0
- nexusx/voyager/web/store.js +624 -0
- nexusx/voyager/web/sw.js +142 -0
- nexusx/voyager/web/vue-main.js +397 -0
- nexusx-2.0.0.dist-info/METADATA +686 -0
- nexusx-2.0.0.dist-info/RECORD +111 -0
- nexusx-2.0.0.dist-info/WHEEL +4 -0
- nexusx-2.0.0.dist-info/licenses/LICENSE +21 -0
nexusx/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""nexusx - GraphQL SDL generation and Core API response building.
|
|
2
|
+
|
|
3
|
+
This package provides:
|
|
4
|
+
- Automatic GraphQL SDL generation from SQLModel classes
|
|
5
|
+
- @query/@mutation decorators for defining GraphQL operations
|
|
6
|
+
- DataLoader-based relationship resolution
|
|
7
|
+
- Per-relationship pagination support
|
|
8
|
+
- DefineSubset for creating independent DTO models from SQLModel entities
|
|
9
|
+
- ErManager for entity-relationship management and Resolver creation
|
|
10
|
+
|
|
11
|
+
Example (GraphQL mode):
|
|
12
|
+
```python
|
|
13
|
+
from sqlmodel import SQLModel, Field, Relationship, select
|
|
14
|
+
from nexusx import query, mutation, GraphQLHandler
|
|
15
|
+
|
|
16
|
+
class User(SQLModel, table=True):
|
|
17
|
+
id: int = Field(primary_key=True)
|
|
18
|
+
name: str
|
|
19
|
+
posts: list["Post"] = Relationship(back_populates="author", order_by="id")
|
|
20
|
+
|
|
21
|
+
@query
|
|
22
|
+
async def get_users(cls, limit: int = 10) -> list['User']:
|
|
23
|
+
stmt = select(cls).limit(limit)
|
|
24
|
+
result = await session.exec(stmt)
|
|
25
|
+
return list(result.all())
|
|
26
|
+
|
|
27
|
+
handler = GraphQLHandler(
|
|
28
|
+
base=User,
|
|
29
|
+
session_factory=async_session,
|
|
30
|
+
enable_pagination=True,
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Example (Core API mode):
|
|
35
|
+
```python
|
|
36
|
+
from sqlmodel import SQLModel
|
|
37
|
+
from nexusx import DefineSubset, ErManager, Loader
|
|
38
|
+
|
|
39
|
+
class UserDTO(DefineSubset):
|
|
40
|
+
__subset__ = (User, ('id', 'name'))
|
|
41
|
+
|
|
42
|
+
class PostDTO(DefineSubset):
|
|
43
|
+
__subset__ = (Post, ('id', 'title', 'author_id'))
|
|
44
|
+
author: UserDTO | None = None
|
|
45
|
+
|
|
46
|
+
def resolve_author(self, loader=Loader('author')):
|
|
47
|
+
return loader.load(self.author_id)
|
|
48
|
+
|
|
49
|
+
er = ErManager(base=SQLModel, session_factory=async_session)
|
|
50
|
+
Resolver = er.create_resolver()
|
|
51
|
+
result = await Resolver().resolve([PostDTO(...) for p in posts])
|
|
52
|
+
```
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
from __future__ import annotations
|
|
56
|
+
|
|
57
|
+
__version__ = "1.9.0"
|
|
58
|
+
|
|
59
|
+
from nexusx.context import Collector, ExposeAs, SendTo
|
|
60
|
+
from nexusx.decorator import mutation, query
|
|
61
|
+
from nexusx.er_diagram import ErDiagram
|
|
62
|
+
from nexusx.handler import GraphQLHandler
|
|
63
|
+
from nexusx.loader import ErManager
|
|
64
|
+
from nexusx.query_parser import FieldSelection, QueryParser
|
|
65
|
+
from nexusx.relationship import Relationship
|
|
66
|
+
from nexusx.resolver import Loader
|
|
67
|
+
from nexusx.sdl_generator import SDLGenerator
|
|
68
|
+
from nexusx.standard_queries import AutoQueryConfig, add_standard_queries
|
|
69
|
+
from nexusx.subset import DefineSubset, SubsetConfig, build_dto_select
|
|
70
|
+
from nexusx.use_case import (
|
|
71
|
+
FromContext,
|
|
72
|
+
UseCaseAppConfig,
|
|
73
|
+
UseCaseService,
|
|
74
|
+
create_use_case_mcp_server,
|
|
75
|
+
)
|
|
76
|
+
from nexusx.use_case import (
|
|
77
|
+
create_router as create_use_case_router,
|
|
78
|
+
)
|
|
79
|
+
from nexusx.voyager import create_use_case_voyager
|
|
80
|
+
|
|
81
|
+
__all__ = [
|
|
82
|
+
# Version
|
|
83
|
+
"__version__",
|
|
84
|
+
# Decorators
|
|
85
|
+
"query",
|
|
86
|
+
"mutation",
|
|
87
|
+
# Core classes
|
|
88
|
+
"SDLGenerator",
|
|
89
|
+
"QueryParser",
|
|
90
|
+
"GraphQLHandler",
|
|
91
|
+
"ErManager",
|
|
92
|
+
# Types
|
|
93
|
+
"FieldSelection",
|
|
94
|
+
# Standard queries
|
|
95
|
+
"AutoQueryConfig",
|
|
96
|
+
"add_standard_queries",
|
|
97
|
+
# Core API mode (use case response building)
|
|
98
|
+
"DefineSubset",
|
|
99
|
+
"SubsetConfig",
|
|
100
|
+
"Loader",
|
|
101
|
+
"ExposeAs",
|
|
102
|
+
"SendTo",
|
|
103
|
+
"Collector",
|
|
104
|
+
# Custom relationships
|
|
105
|
+
"Relationship",
|
|
106
|
+
"ErDiagram",
|
|
107
|
+
# Query builder
|
|
108
|
+
"build_dto_select",
|
|
109
|
+
# UseCase MCP mode
|
|
110
|
+
"UseCaseService",
|
|
111
|
+
"UseCaseAppConfig",
|
|
112
|
+
"FromContext",
|
|
113
|
+
"create_use_case_mcp_server",
|
|
114
|
+
"create_use_case_router",
|
|
115
|
+
# Voyager visualization
|
|
116
|
+
"create_use_case_voyager",
|
|
117
|
+
]
|
nexusx/context.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Cross-layer data flow: ExposeAs, SendTo, Collector.
|
|
2
|
+
|
|
3
|
+
Enables parent nodes to pass context down to descendants (ExposeAs)
|
|
4
|
+
and descendants to aggregate values up to ancestors (SendTo + Collector).
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from nexusx import ExposeAs, SendTo, Collector
|
|
9
|
+
|
|
10
|
+
class SprintDTO(DefineSubset):
|
|
11
|
+
__subset__ = (Sprint, ('id', 'name'))
|
|
12
|
+
name: Annotated[str, ExposeAs('sprint_name')]
|
|
13
|
+
tasks: list[TaskDTO] = []
|
|
14
|
+
contributors: list[UserDTO] = []
|
|
15
|
+
|
|
16
|
+
def post_contributors(self, collector=Collector('contributors')):
|
|
17
|
+
return collector.values()
|
|
18
|
+
|
|
19
|
+
class TaskDTO(DefineSubset):
|
|
20
|
+
__subset__ = (Task, ('id', 'title'))
|
|
21
|
+
full_title: str = ""
|
|
22
|
+
owner: Annotated[UserDTO | None, SendTo('contributors')] = None
|
|
23
|
+
|
|
24
|
+
def post_full_title(self, ancestor_context):
|
|
25
|
+
return f"{ancestor_context['sprint_name']} / {self.title}"
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import abc
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel
|
|
35
|
+
|
|
36
|
+
# ──────────────────────────────────────────────────────────
|
|
37
|
+
# ExposeAs — expose field value to descendant nodes
|
|
38
|
+
# ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ExposeInfo:
|
|
42
|
+
"""Metadata for ExposeAs annotation."""
|
|
43
|
+
alias: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ExposeAs(alias: str) -> ExposeInfo:
|
|
47
|
+
"""Mark a field to be exposed to descendant nodes via ancestor_context.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
alias: The key name under which the value appears in ancestor_context.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
name: Annotated[str, ExposeAs('sprint_name')]
|
|
54
|
+
|
|
55
|
+
# In descendant:
|
|
56
|
+
def post_full_title(self, ancestor_context):
|
|
57
|
+
return ancestor_context['sprint_name']
|
|
58
|
+
"""
|
|
59
|
+
return ExposeInfo(alias=alias)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ──────────────────────────────────────────────────────────
|
|
63
|
+
# SendTo — send field value to ancestor's Collector
|
|
64
|
+
# ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class SendToInfo:
|
|
68
|
+
"""Metadata for SendTo annotation."""
|
|
69
|
+
collector_name: str | tuple[str, ...]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def SendTo(name: str | tuple[str, ...]) -> SendToInfo:
|
|
73
|
+
"""Mark a field to be collected by an ancestor's Collector.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
name: Collector alias name (or tuple of names for multi-collector).
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
owner: Annotated[UserDTO | None, SendTo('contributors')]
|
|
80
|
+
"""
|
|
81
|
+
return SendToInfo(collector_name=name)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ──────────────────────────────────────────────────────────
|
|
85
|
+
# Collector — aggregate values from descendants
|
|
86
|
+
# ──────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
class ICollector(metaclass=abc.ABCMeta):
|
|
89
|
+
"""Abstract base class for collectors."""
|
|
90
|
+
|
|
91
|
+
@abc.abstractmethod
|
|
92
|
+
def __init__(self, alias: str):
|
|
93
|
+
self.alias = alias
|
|
94
|
+
|
|
95
|
+
@abc.abstractmethod
|
|
96
|
+
def add(self, val: Any) -> None:
|
|
97
|
+
"""Add a value to the collection."""
|
|
98
|
+
|
|
99
|
+
@abc.abstractmethod
|
|
100
|
+
def values(self) -> Any:
|
|
101
|
+
"""Get collected values."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Collector(ICollector):
|
|
105
|
+
"""Collect values from descendant nodes marked with SendTo.
|
|
106
|
+
|
|
107
|
+
Used as a parameter in post_* methods:
|
|
108
|
+
|
|
109
|
+
def post_contributors(self, collector=Collector('contributors')):
|
|
110
|
+
return collector.values()
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
alias: The collector name, matching SendTo's target.
|
|
114
|
+
flat: If True, flatten list values (for list fields with SendTo).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, alias: str, flat: bool = False):
|
|
118
|
+
super().__init__(alias)
|
|
119
|
+
self.flat = flat
|
|
120
|
+
self.val: list[Any] = []
|
|
121
|
+
|
|
122
|
+
def add(self, val: Any | list[Any]) -> None:
|
|
123
|
+
if self.flat:
|
|
124
|
+
if isinstance(val, list):
|
|
125
|
+
self.val.extend(val)
|
|
126
|
+
else:
|
|
127
|
+
raise TypeError("flat mode requires list values")
|
|
128
|
+
else:
|
|
129
|
+
self.val.append(val)
|
|
130
|
+
|
|
131
|
+
def values(self) -> list[Any]:
|
|
132
|
+
return self.val
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ──────────────────────────────────────────────────────────
|
|
136
|
+
# Metadata scanning helpers (cached per class)
|
|
137
|
+
# ──────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
_expose_cache: dict[type, dict[str, str]] = {}
|
|
140
|
+
_send_to_cache: dict[type, dict[str, str | tuple[str, ...]]] = {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def scan_expose_fields(kls: type[BaseModel]) -> dict[str, str]:
|
|
144
|
+
"""Scan a class for fields with ExposeAs annotation.
|
|
145
|
+
|
|
146
|
+
Results are cached per class since field metadata doesn't change.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict mapping field_name -> alias for all ExposeAs-annotated fields.
|
|
150
|
+
"""
|
|
151
|
+
cached = _expose_cache.get(kls)
|
|
152
|
+
if cached is not None:
|
|
153
|
+
return cached
|
|
154
|
+
result: dict[str, str] = {}
|
|
155
|
+
for field_name, field_info in kls.model_fields.items():
|
|
156
|
+
for meta in field_info.metadata:
|
|
157
|
+
if isinstance(meta, ExposeInfo):
|
|
158
|
+
result[field_name] = meta.alias
|
|
159
|
+
break
|
|
160
|
+
_expose_cache[kls] = result
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def scan_send_to_fields(kls: type[BaseModel]) -> dict[str, str | tuple[str, ...]]:
|
|
165
|
+
"""Scan a class for fields with SendTo annotation.
|
|
166
|
+
|
|
167
|
+
Results are cached per class since field metadata doesn't change.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Dict mapping field_name -> collector_name(s).
|
|
171
|
+
"""
|
|
172
|
+
cached = _send_to_cache.get(kls)
|
|
173
|
+
if cached is not None:
|
|
174
|
+
return cached
|
|
175
|
+
result: dict[str, str | tuple[str, ...]] = {}
|
|
176
|
+
for field_name, field_info in kls.model_fields.items():
|
|
177
|
+
for meta in field_info.metadata:
|
|
178
|
+
if isinstance(meta, SendToInfo):
|
|
179
|
+
result[field_name] = meta.collector_name
|
|
180
|
+
break
|
|
181
|
+
_send_to_cache[kls] = result
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ──────────────────────────────────────────────────────────
|
|
186
|
+
# AutoLoad — automatic relationship loading
|
|
187
|
+
# ──────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
@dataclass
|
|
190
|
+
class AutoLoadInfo:
|
|
191
|
+
"""Metadata for AutoLoad annotation."""
|
|
192
|
+
origin: str | None = None # Override relationship name, defaults to field name
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def AutoLoad(origin: str | None = None) -> AutoLoadInfo:
|
|
196
|
+
"""Mark a field for automatic relationship loading via LoaderRegistry.
|
|
197
|
+
|
|
198
|
+
When used in a DefineSubset DTO, the Resolver will automatically:
|
|
199
|
+
1. Look up the relationship in the LoaderRegistry
|
|
200
|
+
2. Load the data via DataLoader
|
|
201
|
+
3. Convert ORM results to the annotated DTO type
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
origin: Override relationship name. Defaults to the field name.
|
|
205
|
+
|
|
206
|
+
Example::
|
|
207
|
+
|
|
208
|
+
class TaskSummary(DefineSubset):
|
|
209
|
+
__subset__ = (Task, ('id', 'title', 'owner_id'))
|
|
210
|
+
owner: Annotated[UserSummary | None, AutoLoad()] = None
|
|
211
|
+
"""
|
|
212
|
+
return AutoLoadInfo(origin=origin)
|
nexusx/decorator.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Decorators for marking SQLModel methods as GraphQL queries and mutations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def query(func: Callable) -> classmethod:
|
|
9
|
+
"""Mark a method as a GraphQL query.
|
|
10
|
+
|
|
11
|
+
This decorator automatically converts the method to a classmethod.
|
|
12
|
+
The GraphQL field name is generated as: `{entityName}{methodName}`.
|
|
13
|
+
The description is taken from the method's docstring.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
func: The method to decorate.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A classmethod decorator.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
```python
|
|
23
|
+
from sqlmodel import SQLModel
|
|
24
|
+
from nexusx import query
|
|
25
|
+
|
|
26
|
+
class User(SQLModel, table=True):
|
|
27
|
+
id: int
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
@query
|
|
31
|
+
async def get_all(cls, limit: int = 10) -> list['User']:
|
|
32
|
+
\"\"\"Get all users with optional limit.\"\"\"
|
|
33
|
+
return await fetch_users(limit)
|
|
34
|
+
|
|
35
|
+
@query
|
|
36
|
+
async def get_by_id(cls, id: int) -> Optional['User']:
|
|
37
|
+
\"\"\"Get a user by ID.\"\"\"
|
|
38
|
+
return await fetch_user(id)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This generates the following GraphQL Schema:
|
|
42
|
+
```graphql
|
|
43
|
+
type Query {
|
|
44
|
+
\"\"\"Get all users with optional limit.\"\"\"
|
|
45
|
+
userGetAll(limit: Int): [User!]!
|
|
46
|
+
\"\"\"Get a user by ID.\"\"\"
|
|
47
|
+
userGetById(id: Int!): User
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
"""
|
|
51
|
+
func._graphql_query = True # type: ignore[attr-defined]
|
|
52
|
+
func._graphql_query_description = (func.__doc__.strip() if func.__doc__ else "") # type: ignore[attr-defined]
|
|
53
|
+
return classmethod(func)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def mutation(func: Callable) -> classmethod:
|
|
57
|
+
"""Mark a method as a GraphQL mutation.
|
|
58
|
+
|
|
59
|
+
This decorator automatically converts the method to a classmethod.
|
|
60
|
+
The GraphQL field name is generated as: `{entityName}{methodName}`.
|
|
61
|
+
The description is taken from the method's docstring.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
func: The method to decorate.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A classmethod decorator.
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
```python
|
|
71
|
+
from sqlmodel import SQLModel
|
|
72
|
+
from nexusx import mutation
|
|
73
|
+
|
|
74
|
+
class User(SQLModel, table=True):
|
|
75
|
+
id: int
|
|
76
|
+
name: str
|
|
77
|
+
email: str
|
|
78
|
+
|
|
79
|
+
@mutation
|
|
80
|
+
async def create(cls, name: str, email: str) -> 'User':
|
|
81
|
+
\"\"\"Create a new user.\"\"\"
|
|
82
|
+
return await create_user(name, email)
|
|
83
|
+
|
|
84
|
+
@mutation
|
|
85
|
+
async def update_email(cls, id: int, email: str) -> 'User':
|
|
86
|
+
\"\"\"Update user's email.\"\"\"
|
|
87
|
+
return await update_user_email(id, email)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This generates the following GraphQL Schema:
|
|
91
|
+
```graphql
|
|
92
|
+
type Mutation {
|
|
93
|
+
\"\"\"Create a new user.\"\"\"
|
|
94
|
+
userCreate(name: String!, email: String!): User!
|
|
95
|
+
\"\"\"Update user's email.\"\"\"
|
|
96
|
+
userUpdateEmail(id: Int!, email: String!): User!
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
"""
|
|
100
|
+
func._graphql_mutation = True # type: ignore[attr-defined]
|
|
101
|
+
func._graphql_mutation_description = (func.__doc__.strip() if func.__doc__ else "") # type: ignore[attr-defined]
|
|
102
|
+
return classmethod(func)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Entity discovery module for GraphQL handlers.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to discover SQLModel entities
|
|
4
|
+
that have @query/@mutation decorators, and traverse relationships to find related entities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from nexusx.response_builder import (
|
|
13
|
+
get_relation_entity,
|
|
14
|
+
get_relationship_names,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EntityDiscovery:
|
|
22
|
+
"""Discovers SQLModel entities with @query/@mutation decorators.
|
|
23
|
+
|
|
24
|
+
Traverses relationships to include related entities even without decorators.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, base: type):
|
|
28
|
+
"""Initialize the entity discovery.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
base: The base class to scan for subclasses.
|
|
32
|
+
"""
|
|
33
|
+
self._base = base
|
|
34
|
+
self._all_subclasses = self._collect_all_subclasses(base)
|
|
35
|
+
|
|
36
|
+
def discover(self, include_all: bool = False) -> list[type]:
|
|
37
|
+
"""Discover all entities with decorators and their related entities.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
include_all: If True, return all subclasses of the base entity.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of discovered entity classes.
|
|
44
|
+
"""
|
|
45
|
+
if include_all:
|
|
46
|
+
return list(self._all_subclasses)
|
|
47
|
+
|
|
48
|
+
root_entities = self._find_root_entities()
|
|
49
|
+
return self._traverse_relationships(root_entities)
|
|
50
|
+
|
|
51
|
+
def _collect_all_subclasses(self, base: type) -> set[type]:
|
|
52
|
+
"""Collect all subclasses recursively."""
|
|
53
|
+
discovered: set[type] = set()
|
|
54
|
+
queue = deque(base.__subclasses__())
|
|
55
|
+
|
|
56
|
+
while queue:
|
|
57
|
+
subclass = queue.popleft()
|
|
58
|
+
if subclass in discovered:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
discovered.add(subclass)
|
|
62
|
+
queue.extend(subclass.__subclasses__())
|
|
63
|
+
|
|
64
|
+
return discovered
|
|
65
|
+
|
|
66
|
+
def _find_root_entities(self) -> set[type]:
|
|
67
|
+
"""Find entities with @query/@mutation decorators.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Set of entities that have decorators.
|
|
71
|
+
"""
|
|
72
|
+
root_entities: set[type] = set()
|
|
73
|
+
|
|
74
|
+
for subclass in self._all_subclasses:
|
|
75
|
+
for name in dir(subclass):
|
|
76
|
+
attr = getattr(subclass, name, None)
|
|
77
|
+
if hasattr(attr, "_graphql_query") or hasattr(attr, "_graphql_mutation"):
|
|
78
|
+
root_entities.add(subclass)
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
return root_entities
|
|
82
|
+
|
|
83
|
+
def _traverse_relationships(self, root_entities: set[type]) -> list[type]:
|
|
84
|
+
"""Traverse relationships to find all related entities.
|
|
85
|
+
|
|
86
|
+
Uses BFS to discover entities connected through relationships.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
root_entities: Starting entities with decorators.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of all discovered entities (roots + related).
|
|
93
|
+
"""
|
|
94
|
+
discovered: set[type] = set()
|
|
95
|
+
queue = deque(root_entities)
|
|
96
|
+
|
|
97
|
+
while queue:
|
|
98
|
+
current = queue.popleft()
|
|
99
|
+
if current in discovered:
|
|
100
|
+
continue
|
|
101
|
+
discovered.add(current)
|
|
102
|
+
|
|
103
|
+
# Traverse all relationships of current entity
|
|
104
|
+
for rel_name in get_relationship_names(current):
|
|
105
|
+
target_entity = get_relation_entity(
|
|
106
|
+
current, rel_name, self._all_subclasses
|
|
107
|
+
)
|
|
108
|
+
if target_entity and target_entity not in discovered:
|
|
109
|
+
if target_entity in self._all_subclasses:
|
|
110
|
+
queue.append(target_entity)
|
|
111
|
+
|
|
112
|
+
return list(discovered)
|