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.
Files changed (111) hide show
  1. nexusx/__init__.py +117 -0
  2. nexusx/context.py +212 -0
  3. nexusx/decorator.py +102 -0
  4. nexusx/discovery/__init__.py +5 -0
  5. nexusx/discovery/entity_discovery.py +112 -0
  6. nexusx/er_diagram.py +222 -0
  7. nexusx/execution/__init__.py +6 -0
  8. nexusx/execution/argument_builder.py +199 -0
  9. nexusx/execution/field_tree_builder.py +37 -0
  10. nexusx/execution/query_executor.py +486 -0
  11. nexusx/graphiql.py +68 -0
  12. nexusx/handler.py +183 -0
  13. nexusx/introspection.py +805 -0
  14. nexusx/loader/__init__.py +19 -0
  15. nexusx/loader/factories.py +570 -0
  16. nexusx/loader/pagination.py +107 -0
  17. nexusx/loader/query_meta.py +162 -0
  18. nexusx/loader/registry.py +513 -0
  19. nexusx/mcp/__init__.py +56 -0
  20. nexusx/mcp/builders/__init__.py +7 -0
  21. nexusx/mcp/builders/schema_formatter.py +264 -0
  22. nexusx/mcp/builders/type_tracer.py +163 -0
  23. nexusx/mcp/managers/__init__.py +7 -0
  24. nexusx/mcp/managers/app_resources.py +46 -0
  25. nexusx/mcp/managers/multi_app_manager.py +129 -0
  26. nexusx/mcp/managers/single_app_manager.py +82 -0
  27. nexusx/mcp/server.py +213 -0
  28. nexusx/mcp/tools/__init__.py +15 -0
  29. nexusx/mcp/tools/get_operation_schema.py +202 -0
  30. nexusx/mcp/tools/graphql_mutation.py +87 -0
  31. nexusx/mcp/tools/graphql_query.py +88 -0
  32. nexusx/mcp/tools/list_operations.py +89 -0
  33. nexusx/mcp/tools/multi_app_tools.py +467 -0
  34. nexusx/mcp/tools/simple_tools.py +190 -0
  35. nexusx/mcp/types/__init__.py +15 -0
  36. nexusx/mcp/types/app_config.py +29 -0
  37. nexusx/mcp/types/errors.py +97 -0
  38. nexusx/query_parser.py +150 -0
  39. nexusx/relationship.py +98 -0
  40. nexusx/resolver.py +735 -0
  41. nexusx/response_builder.py +350 -0
  42. nexusx/scanning/__init__.py +5 -0
  43. nexusx/scanning/method_scanner.py +76 -0
  44. nexusx/sdl_generator.py +706 -0
  45. nexusx/standard_queries.py +237 -0
  46. nexusx/subset.py +676 -0
  47. nexusx/type_converter.py +163 -0
  48. nexusx/use_case/__init__.py +19 -0
  49. nexusx/use_case/business.py +129 -0
  50. nexusx/use_case/context.py +30 -0
  51. nexusx/use_case/introspector.py +566 -0
  52. nexusx/use_case/manager.py +149 -0
  53. nexusx/use_case/router.py +329 -0
  54. nexusx/use_case/server.py +433 -0
  55. nexusx/use_case/types.py +35 -0
  56. nexusx/utils/__init__.py +5 -0
  57. nexusx/utils/naming.py +57 -0
  58. nexusx/utils/schema_helpers.py +131 -0
  59. nexusx/utils/type_compat.py +112 -0
  60. nexusx/utils/type_utils.py +72 -0
  61. nexusx/voyager/__init__.py +8 -0
  62. nexusx/voyager/create_voyager.py +189 -0
  63. nexusx/voyager/er_diagram_dot.py +231 -0
  64. nexusx/voyager/filter.py +275 -0
  65. nexusx/voyager/module.py +98 -0
  66. nexusx/voyager/render.py +581 -0
  67. nexusx/voyager/render_style.py +112 -0
  68. nexusx/voyager/templates/dot/cluster.j2 +10 -0
  69. nexusx/voyager/templates/dot/cluster_container.j2 +9 -0
  70. nexusx/voyager/templates/dot/digraph.j2 +25 -0
  71. nexusx/voyager/templates/dot/er_diagram.j2 +29 -0
  72. nexusx/voyager/templates/dot/link.j2 +1 -0
  73. nexusx/voyager/templates/dot/route_node.j2 +5 -0
  74. nexusx/voyager/templates/dot/schema_node.j2 +5 -0
  75. nexusx/voyager/templates/dot/tag_node.j2 +5 -0
  76. nexusx/voyager/templates/html/colored_text.j2 +1 -0
  77. nexusx/voyager/templates/html/pydantic_meta.j2 +1 -0
  78. nexusx/voyager/templates/html/schema_field_row.j2 +1 -0
  79. nexusx/voyager/templates/html/schema_header.j2 +1 -0
  80. nexusx/voyager/templates/html/schema_table.j2 +4 -0
  81. nexusx/voyager/type.py +107 -0
  82. nexusx/voyager/type_helper.py +324 -0
  83. nexusx/voyager/use_case_voyager.py +344 -0
  84. nexusx/voyager/voyager_context.py +282 -0
  85. nexusx/voyager/web/component/demo.js +17 -0
  86. nexusx/voyager/web/component/loader-code-display.js +135 -0
  87. nexusx/voyager/web/component/render-graph.js +86 -0
  88. nexusx/voyager/web/component/route-code-display.js +123 -0
  89. nexusx/voyager/web/component/schema-code-display.js +203 -0
  90. nexusx/voyager/web/graph-ui.js +467 -0
  91. nexusx/voyager/web/graphviz.svg.css +64 -0
  92. nexusx/voyager/web/graphviz.svg.js +638 -0
  93. nexusx/voyager/web/icon/android-chrome-192x192.png +0 -0
  94. nexusx/voyager/web/icon/android-chrome-512x512.png +0 -0
  95. nexusx/voyager/web/icon/apple-touch-icon.png +0 -0
  96. nexusx/voyager/web/icon/favicon-16x16.png +0 -0
  97. nexusx/voyager/web/icon/favicon-32x32.png +0 -0
  98. nexusx/voyager/web/icon/favicon.ico +0 -0
  99. nexusx/voyager/web/icon/site.webmanifest +16 -0
  100. nexusx/voyager/web/index.html +708 -0
  101. nexusx/voyager/web/magnifying-glass.js +447 -0
  102. nexusx/voyager/web/manifest.webmanifest +5 -0
  103. nexusx/voyager/web/quasar.min.css +1 -0
  104. nexusx/voyager/web/quasar.min.js +127 -0
  105. nexusx/voyager/web/store.js +624 -0
  106. nexusx/voyager/web/sw.js +142 -0
  107. nexusx/voyager/web/vue-main.js +397 -0
  108. nexusx-2.0.0.dist-info/METADATA +686 -0
  109. nexusx-2.0.0.dist-info/RECORD +111 -0
  110. nexusx-2.0.0.dist-info/WHEEL +4 -0
  111. 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,5 @@
1
+ """Entity discovery module."""
2
+
3
+ from nexusx.discovery.entity_discovery import EntityDiscovery
4
+
5
+ __all__ = ["EntityDiscovery"]
@@ -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)