fastapi-voyager 0.12.12__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/PKG-INFO +2 -2
  2. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/docs/changelog.md +2 -2
  3. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/pyproject.toml +1 -1
  4. fastapi_voyager-0.13.0/src/fastapi_voyager/er_diagram.py +119 -0
  5. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/server.py +22 -1
  6. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/version.py +1 -1
  7. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/voyager.py +0 -5
  8. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/index.html +16 -3
  9. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/store.js +6 -1
  10. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/vue-main.js +105 -33
  11. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/demo.py +5 -1
  12. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/programatic.py +2 -1
  13. fastapi_voyager-0.13.0/tests/service/schema/base_entity.py +3 -0
  14. fastapi_voyager-0.13.0/tests/service/schema/schema.py +40 -0
  15. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/uv.lock +4 -4
  16. fastapi_voyager-0.12.12/tests/service/schema/schema.py +0 -28
  17. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/.github/workflows/publish.yml +0 -0
  20. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/.gitignore +0 -0
  21. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/.python-version +0 -0
  22. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/CONTRIBUTING.md +0 -0
  23. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/LICENSE +0 -0
  24. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/README.md +0 -0
  25. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/docs/idea.md +0 -0
  26. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/release.md +0 -0
  27. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/__init__.py +0 -0
  28. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/cli.py +0 -0
  29. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/filter.py +0 -0
  30. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/module.py +0 -0
  31. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/render.py +0 -0
  32. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/type.py +0 -0
  33. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/type_helper.py +0 -0
  34. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/component/demo.js +0 -0
  35. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/component/render-graph.js +0 -0
  36. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/component/route-code-display.js +0 -0
  37. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/component/schema-code-display.js +0 -0
  38. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/graph-ui.js +0 -0
  39. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/graphviz.svg.css +0 -0
  40. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/graphviz.svg.js +0 -0
  41. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/icon/android-chrome-192x192.png +0 -0
  42. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/icon/android-chrome-512x512.png +0 -0
  43. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/icon/apple-touch-icon.png +0 -0
  44. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/icon/favicon-16x16.png +0 -0
  45. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/icon/favicon-32x32.png +0 -0
  46. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/icon/favicon.ico +0 -0
  47. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/icon/site.webmanifest +0 -0
  48. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/quasar.min.css +0 -0
  49. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/src/fastapi_voyager/web/quasar.min.js +0 -0
  50. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/__init__.py +0 -0
  51. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/demo_anno.py +0 -0
  52. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/service/__init__.py +0 -0
  53. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/service/schema/__init__.py +0 -0
  54. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/service/schema/extra.py +0 -0
  55. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/test_analysis.py +0 -0
  56. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/test_filter.py +0 -0
  57. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/test_generic.py +0 -0
  58. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/test_import.py +0 -0
  59. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/test_module.py +0 -0
  60. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/tests/test_type_helper.py +0 -0
  61. {fastapi_voyager-0.12.12 → fastapi_voyager-0.13.0}/voyager.jpg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-voyager
3
- Version: 0.12.12
3
+ Version: 0.13.0
4
4
  Summary: Visualize FastAPI application's routing tree and dependencies
5
5
  Project-URL: Homepage, https://github.com/allmonday/fastapi-voyager
6
6
  Project-URL: Source, https://github.com/allmonday/fastapi-voyager
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Requires-Python: >=3.10
21
21
  Requires-Dist: fastapi>=0.110
22
- Requires-Dist: pydantic-resolve>=1.13.2
22
+ Requires-Dist: pydantic-resolve>=2.2.3
23
23
  Provides-Extra: dev
24
24
  Requires-Dist: pytest; extra == 'dev'
25
25
  Requires-Dist: ruff; extra == 'dev'
@@ -136,8 +136,8 @@
136
136
  - [x] disable `show module cluster` by default
137
137
 
138
138
  ## 0.13
139
- - 0.13.2
140
- - [ ] if er diagram is provided, show it first.
139
+ - 0.13.0
140
+ - [x] if er diagram is provided, show it first.
141
141
  - 0.13.1
142
142
  - [ ] integration with pydantic-resolve
143
143
  - [ ] show hint for resolve, post fields
@@ -9,7 +9,7 @@ requires-python = ">=3.10"
9
9
  keywords = ["fastapi", "visualization", "routing", "openapi"]
10
10
  dependencies = [
11
11
  "fastapi>=0.110",
12
- "pydantic-resolve>=1.13.2"
12
+ "pydantic-resolve>=2.2.3"
13
13
  ]
14
14
  classifiers = [
15
15
  "Programming Language :: Python :: 3",
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi_voyager.type import PK, FieldType, Link, LinkType, SchemaNode
4
+ from fastapi_voyager.type_helper import (
5
+ update_forward_refs,
6
+ full_class_name,
7
+ get_core_types,
8
+ get_type_name
9
+ )
10
+ from fastapi_voyager.render import Renderer
11
+ from fastapi_voyager.type import FieldInfo
12
+ from pydantic import BaseModel
13
+ from pydantic_resolve import ErDiagram, Entity
14
+
15
+ class VoyagerErDiagram:
16
+ def __init__(self,
17
+ er_diagram: ErDiagram,
18
+ show_fields: FieldType = 'single',
19
+ show_module: bool = False):
20
+ self.er_diagram = er_diagram
21
+ self.nodes: list[SchemaNode] = []
22
+ self.node_set: dict[str, SchemaNode] = {}
23
+
24
+ self.links: list[Link] = []
25
+ self.link_set: set[tuple[str, str]] = set()
26
+
27
+ self.fk_set: dict[str, set[str]] = {}
28
+
29
+ self.show_field = show_fields
30
+ self.show_module = show_module
31
+
32
+ def generate_node_head(self, link_name: str):
33
+ return f'{link_name}::{PK}'
34
+
35
+ def analysis_entity(self, entity: Entity):
36
+ schema = entity.kls
37
+ update_forward_refs(schema)
38
+ self.add_to_node_set(schema, fk_set=self.fk_set.get(full_class_name(schema)))
39
+
40
+ for relationship in entity.relationships:
41
+ annos = get_core_types(relationship.target_kls)
42
+ for anno in annos:
43
+ self.add_to_node_set(anno, fk_set=self.fk_set.get(full_class_name(anno)))
44
+ source_name = f'{full_class_name(schema)}::f{relationship.field}'
45
+ self.add_to_link_set(
46
+ source=source_name,
47
+ source_origin=full_class_name(schema),
48
+ target=self.generate_node_head(full_class_name(anno)),
49
+ target_origin=full_class_name(anno),
50
+ type='schema')
51
+
52
+ def add_to_node_set(self, schema, fk_set: set[str] | None = None) -> str:
53
+ """
54
+ 1. calc full_path, add to node_set
55
+ 2. if duplicated, do nothing, else insert
56
+ 2. return the full_path
57
+ """
58
+ full_name = full_class_name(schema)
59
+
60
+ if full_name not in self.node_set:
61
+ # skip meta info for normal queries
62
+ self.node_set[full_name] = SchemaNode(
63
+ id=full_name,
64
+ module=schema.__module__,
65
+ name=schema.__name__,
66
+ fields=get_fields(schema, fk_set)
67
+ )
68
+ return full_name
69
+
70
+ def add_to_link_set(
71
+ self,
72
+ source: str,
73
+ source_origin: str,
74
+ target: str,
75
+ target_origin: str,
76
+ type: LinkType
77
+ ) -> bool:
78
+ """
79
+ 1. add link to link_set
80
+ 2. if duplicated, do nothing, else insert
81
+ """
82
+ pair = (source, target)
83
+ if result := pair not in self.link_set:
84
+ self.link_set.add(pair)
85
+ self.links.append(Link(
86
+ source=source,
87
+ source_origin=source_origin,
88
+ target=target,
89
+ target_origin=target_origin,
90
+ type=type
91
+ ))
92
+ return result
93
+
94
+
95
+ def render_dot(self):
96
+ self.fk_set = {
97
+ full_class_name(entity.kls): set([rel.field for rel in entity.relationships])
98
+ for entity in self.er_diagram.configs
99
+ }
100
+
101
+ for entity in self.er_diagram.configs:
102
+ self.analysis_entity(entity)
103
+ renderer = Renderer(show_fields=self.show_field, show_module=self.show_module)
104
+ return renderer.render_dot([], [], list(self.node_set.values()), self.links)
105
+
106
+
107
+ def get_fields(schema: type[BaseModel], fk_set: set[str] | None = None) -> list[FieldInfo]:
108
+
109
+ fields: list[FieldInfo] = []
110
+ for k, v in schema.model_fields.items():
111
+ anno = v.annotation
112
+ fields.append(FieldInfo(
113
+ is_object=k in fk_set if fk_set is not None else False,
114
+ name=k,
115
+ from_base=False,
116
+ type_name=get_type_name(anno),
117
+ is_exclude=bool(v.exclude)
118
+ ))
119
+ return fields
@@ -12,6 +12,8 @@ from fastapi_voyager.type import CoreData, SchemaNode, Tag
12
12
  from fastapi_voyager.type_helper import get_source, get_vscode_link
13
13
  from fastapi_voyager.version import __version__
14
14
  from fastapi_voyager.voyager import Voyager
15
+ from pydantic_resolve import ErDiagram
16
+ from fastapi_voyager.er_diagram import VoyagerErDiagram
15
17
 
16
18
  WEB_DIR = Path(__file__).parent / "web"
17
19
  WEB_DIR.mkdir(exist_ok=True)
@@ -44,6 +46,7 @@ class OptionParam(BaseModel):
44
46
  version: str
45
47
  initial_page_policy: INITIAL_PAGE_POLICY
46
48
  swagger_url: str | None = None
49
+ has_er_diagram: bool = False
47
50
 
48
51
  class Payload(BaseModel):
49
52
  tags: list[str] | None = None
@@ -67,6 +70,12 @@ class SchemaSearchPayload(BaseModel): # leave tag, route out
67
70
  hide_primitive_route: bool = False
68
71
  show_module: bool = True
69
72
 
73
+
74
+ # ---------- er diagram ----------
75
+ class ErDiagramPayload(BaseModel):
76
+ show_fields: str = 'object'
77
+ show_module: bool = True
78
+
70
79
  def create_voyager(
71
80
  target_app: FastAPI,
72
81
  module_color: dict[str, str] | None = None,
@@ -76,9 +85,19 @@ def create_voyager(
76
85
  online_repo_url: str | None = None,
77
86
  initial_page_policy: INITIAL_PAGE_POLICY = 'first',
78
87
  ga_id: str | None = None,
88
+ er_diagram: ErDiagram | None = None,
79
89
  ) -> FastAPI:
80
90
  router = APIRouter(tags=['fastapi-voyager'])
81
91
 
92
+ @router.post("/er-diagram", response_class=PlainTextResponse)
93
+ def get_er_diagram(payload: ErDiagramPayload) -> str:
94
+ if er_diagram:
95
+ return VoyagerErDiagram(
96
+ er_diagram,
97
+ show_fields=payload.show_fields,
98
+ show_module=payload.show_module ).render_dot()
99
+ return ''
100
+
82
101
  @router.get("/dot", response_model=OptionParam)
83
102
  def get_dot() -> str:
84
103
  voyager = Voyager(module_color=module_color)
@@ -101,7 +120,9 @@ def create_voyager(
101
120
  enable_brief_mode=bool(module_prefix),
102
121
  version=__version__,
103
122
  swagger_url=swagger_url,
104
- initial_page_policy=initial_page_policy)
123
+ initial_page_policy=initial_page_policy,
124
+ has_er_diagram=er_diagram is not None)
125
+
105
126
 
106
127
  @router.post("/dot-search", response_model=SearchResultOptionParam)
107
128
  def get_search_dot(payload: SchemaSearchPayload):
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "0.12.12"
2
+ __version__ = "0.13.0"
@@ -62,11 +62,6 @@ class Voyager:
62
62
  if isinstance(route, routing.APIRoute):
63
63
  yield route
64
64
 
65
- def analysis_route(self, route: routing.APIRoute):
66
- ...
67
-
68
- def analysis_tags(self, tag: str):
69
- ...
70
65
 
71
66
  def analysis(self, app: FastAPI):
72
67
  """
@@ -181,6 +181,18 @@
181
181
  label="Select field (optional)"
182
182
  ></q-select>
183
183
  </div>
184
+ <div class="col-auto" v-if="store.state.config.has_er_diagram">
185
+ <q-btn-toggle
186
+ class="q-ml-lg"
187
+ v-model="store.state.mode"
188
+ push
189
+ toggle-color="primary"
190
+ :options="[
191
+ {label: 'Voyager', value: 'voyager'},
192
+ {label: 'ER diagram', value: 'er-diagram'},
193
+ ]"
194
+ />
195
+ </div>
184
196
  <div class="col-auto">
185
197
  <q-btn
186
198
  dense
@@ -259,7 +271,7 @@
259
271
  <q-splitter
260
272
  v-model="store.state.leftPanel.width"
261
273
  unit="px"
262
- :limits="[200, 800]"
274
+ :limits="store.state.mode === 'voyager' ? [200, 800] : [0, 0]"
263
275
  class="adjust-fit"
264
276
  >
265
277
  <template #before>
@@ -272,6 +284,7 @@
272
284
  minHeight: 0,
273
285
  height: '100%'
274
286
  }"
287
+ v-show="store.state.mode === 'voyager'"
275
288
  >
276
289
  <q-scroll-area class="fit">
277
290
  <q-list dense separator>
@@ -339,7 +352,7 @@
339
352
  <div style="position: relative; width: 100%; height: 100%;">
340
353
  <div id="graph" class="adjust-fit"></div>
341
354
  <div style="position: absolute; left: 8px; top: 8px; z-index: 10; background: rgba(255,255,255,0.85); border-radius: 4px; padding: 2px 8px;">
342
- <div class="q-mt-sm" v-if="store.state.modeControl.briefModeEnabled && store.state.search.mode === false">
355
+ <div class="q-mt-sm" v-if="store.state.modeControl.briefModeEnabled && store.state.search.mode === false && store.state.mode === 'voyager'">
343
356
  <q-toggle
344
357
  v-if="store.state.modeControl.briefModeEnabled"
345
358
  dense
@@ -349,7 +362,7 @@
349
362
  title="skip middle classes, config module_prefix to enable it"
350
363
  />
351
364
  </div>
352
- <div class="q-mt-sm" v-if="store.state.modeControl.briefModeEnabled && store.state.search.mode === false">
365
+ <div class="q-mt-sm" v-if="store.state.search.mode === false && store.state.mode === 'voyager'">
353
366
  <q-toggle
354
367
  v-model="store.state.filter.hidePrimitiveRoute"
355
368
  @update:model-value="(val) => toggleHidePrimitiveRoute(val)"
@@ -7,8 +7,11 @@ const state = reactive({
7
7
 
8
8
  version: '',
9
9
  config: {
10
- initial_page_policy: 'first'
10
+ initial_page_policy: 'first',
11
+ has_er_diagram: false,
11
12
  },
13
+
14
+ mode: 'voyager', // voyager / er-diagram
12
15
 
13
16
  previousTagRoute: { // for shift + click, store previous tag/route, and populate back when needed
14
17
  hasValue: false,
@@ -34,6 +37,7 @@ const state = reactive({
34
37
  // tags and routes
35
38
  leftPanel: {
36
39
  width: 300,
40
+ previousWidth: 300,
37
41
  tags: null,
38
42
  tag: null,
39
43
  _tag: null,
@@ -98,6 +102,7 @@ const state = reactive({
98
102
 
99
103
  })
100
104
 
105
+
101
106
  const mutations = {
102
107
  increment() {
103
108
  state.item.count += 1
@@ -11,6 +11,40 @@ const app = createApp({
11
11
  setup() {
12
12
  let graphUI = null;
13
13
  const allSchemaOptions = ref([]);
14
+ const erDiagramLoading = ref(false);
15
+ const erDiagramCache = ref("");
16
+
17
+ function initGraphUI() {
18
+ if (graphUI) {
19
+ return;
20
+ }
21
+ graphUI = new GraphUI("#graph", {
22
+ onSchemaShiftClick: (id) => {
23
+ if (store.state.graph.schemaKeys.has(id)) {
24
+ store.state.previousTagRoute.tag = store.state.leftPanel.tag;
25
+ store.state.previousTagRoute.routeId = store.state.leftPanel.routeId;
26
+ store.state.previousTagRoute.hasValue = true;
27
+ store.state.search.mode = true;
28
+ store.state.search.schemaName = id;
29
+ onSearch();
30
+ }
31
+ },
32
+ onSchemaClick: (id) => {
33
+ resetDetailPanels();
34
+ if (store.state.graph.schemaKeys.has(id)) {
35
+ store.state.schemaDetail.schemaCodeName = id;
36
+ store.state.rightDrawer.drawer = true;
37
+ }
38
+ if (id in store.state.graph.routeItems) {
39
+ store.state.routeDetail.routeCodeId = id;
40
+ store.state.routeDetail.show = true;
41
+ }
42
+ },
43
+ resetCb: () => {
44
+ resetDetailPanels();
45
+ },
46
+ });
47
+ }
14
48
 
15
49
  function rebuildSchemaOptions() {
16
50
  const dict = store.state.graph.schemaMap || {};
@@ -209,6 +243,7 @@ const app = createApp({
209
243
  data.enable_brief_mode || false;
210
244
  store.state.version = data.version || "";
211
245
  store.state.swagger.url = data.swagger_url || null;
246
+ store.state.config.has_er_diagram = data.has_er_diagram || false;
212
247
 
213
248
  rebuildSchemaOptions();
214
249
 
@@ -251,6 +286,17 @@ const app = createApp({
251
286
  }
252
287
 
253
288
  async function onGenerate(resetZoom = true) {
289
+ switch (store.state.mode) {
290
+ case "voyager":
291
+ await renderVoyager(resetZoom);
292
+ break;
293
+ case "er-diagram":
294
+ await renderErDiagram(resetZoom);
295
+ break;
296
+ }
297
+ }
298
+
299
+ async function renderVoyager(resetZoom = true) {
254
300
  const activeSchema = store.state.search.mode
255
301
  ? store.state.search.schemaName
256
302
  : null;
@@ -269,6 +315,7 @@ const app = createApp({
269
315
  hide_primitive_route: store.state.filter.hidePrimitiveRoute,
270
316
  show_module: store.state.filter.showModule,
271
317
  };
318
+ initGraphUI();
272
319
  const res = await fetch("dot", {
273
320
  method: "POST",
274
321
  headers: { "Content-Type": "application/json" },
@@ -276,39 +323,6 @@ const app = createApp({
276
323
  });
277
324
  const dotText = await res.text();
278
325
 
279
- // create graph instance once
280
- if (!graphUI) {
281
- graphUI = new GraphUI("#graph", {
282
- onSchemaShiftClick: (id) => {
283
- if (store.state.graph.schemaKeys.has(id)) {
284
-
285
- console.log(store.state.leftPanel)
286
- store.state.previousTagRoute.tag = store.state.leftPanel.tag;
287
- store.state.previousTagRoute.routeId = store.state.leftPanel.routeId;
288
- store.state.previousTagRoute.hasValue = true;
289
-
290
- store.state.search.mode = true;
291
- store.state.search.schemaName = id;
292
- onSearch();
293
- }
294
- },
295
- onSchemaClick: (id) => {
296
- console.log("schema clicked:", id);
297
- resetDetailPanels();
298
- if (store.state.graph.schemaKeys.has(id)) {
299
- store.state.schemaDetail.schemaCodeName = id;
300
- store.state.rightDrawer.drawer = true;
301
- }
302
- if (id in store.state.graph.routeItems) {
303
- store.state.routeDetail.routeCodeId = id;
304
- store.state.routeDetail.show = true;
305
- }
306
- },
307
- resetCb: () => {
308
- resetDetailPanels();
309
- },
310
- });
311
- }
312
326
  await graphUI.render(dotText, resetZoom);
313
327
  } catch (e) {
314
328
  console.error("Generate failed", e);
@@ -331,6 +345,46 @@ const app = createApp({
331
345
  onGenerate()
332
346
  }
333
347
 
348
+ async function renderErDiagram(resetZoom = true) {
349
+ initGraphUI();
350
+ erDiagramLoading.value = true;
351
+ const payload = {
352
+ show_fields: store.state.filter.showFields,
353
+ show_module: store.state.filter.showModule,
354
+ };
355
+ try {
356
+ const res = await fetch("er-diagram", {
357
+ method: "POST",
358
+ headers: { "Content-Type": "application/json" },
359
+ body: JSON.stringify(payload),
360
+ });
361
+ if (!res.ok) {
362
+ throw new Error(`failed with status ${res.status}`);
363
+ }
364
+ const dot = await res.text();
365
+ erDiagramCache.value = dot;
366
+ await graphUI.render(dot, resetZoom);
367
+ } catch (err) {
368
+ console.error(err)
369
+ } finally {
370
+ erDiagramLoading.value = false;
371
+ }
372
+ }
373
+
374
+ async function onModeChange(val) {
375
+ if (val === "er-diagram") {
376
+ if (store.state.leftPanel.width > 0) {
377
+ store.state.leftPanel.previousWidth = store.state.leftPanel.width;
378
+ }
379
+ store.state.leftPanel.width = 0;
380
+ await renderErDiagram();
381
+ } else {
382
+ const fallbackWidth = store.state.leftPanel.previousWidth || 300;
383
+ store.state.leftPanel.width = fallbackWidth;
384
+ await onGenerate();
385
+ }
386
+ }
387
+
334
388
  function toggleTag(tagName, expanded = null) {
335
389
  if (expanded === true || store.state.search.mode === true) {
336
390
  store.state.leftPanel._tag = tagName;
@@ -421,6 +475,22 @@ const app = createApp({
421
475
  { deep: false }
422
476
  );
423
477
 
478
+ watch(
479
+ () => store.state.leftPanel.width,
480
+ (val) => {
481
+ if (store.state.mode === "voyager" && typeof val === "number" && val > 0) {
482
+ store.state.leftPanel.previousWidth = val;
483
+ }
484
+ }
485
+ );
486
+
487
+ watch(
488
+ () => store.state.mode,
489
+ (mode) => {
490
+ onModeChange(mode);
491
+ }
492
+ );
493
+
424
494
  watch(
425
495
  () => store.state.search.schemaName,
426
496
  (schemaId) => {
@@ -453,6 +523,8 @@ const app = createApp({
453
523
  toggleShowField,
454
524
  startDragDrawer,
455
525
  toggleShowModule,
526
+ onModeChange,
527
+ renderErDiagram,
456
528
  };
457
529
  },
458
530
  });
@@ -5,8 +5,12 @@ from fastapi import FastAPI
5
5
  from pydantic import BaseModel, Field
6
6
  from pydantic_resolve import Resolver, ensure_subset
7
7
 
8
+
8
9
  from tests.service.schema.schema import Member, Sprint, Story, Task
9
- from tests.service.schema.extra import A, B
10
+ from tests.service.schema.extra import A
11
+ from tests.service.schema.base_entity import BaseEntity
12
+
13
+ diagram = BaseEntity.get_diagram()
10
14
 
11
15
  app = FastAPI(title="Demo API", description="A demo FastAPI application for router visualization")
12
16
 
@@ -1,11 +1,12 @@
1
1
  from fastapi_voyager import create_voyager
2
2
  # from tests.demo_anno import app
3
- from tests.demo import app
3
+ from tests.demo import app, diagram
4
4
 
5
5
  app.mount(
6
6
  '/voyager',
7
7
  create_voyager(
8
8
  app,
9
+ er_diagram=diagram,
9
10
  module_color={"tests.service": "purple"},
10
11
  module_prefix="tests.service",
11
12
  swagger_url="/docs",
@@ -0,0 +1,3 @@
1
+ from pydantic_resolve import base_entity
2
+
3
+ BaseEntity = base_entity()
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+ from typing import Literal
3
+ from pydantic import BaseModel
4
+ from pydantic_resolve import Relationship
5
+ from .base_entity import BaseEntity
6
+
7
+
8
+
9
+ class Member(BaseModel):
10
+ id: int
11
+ first_name: str
12
+ last_name: str
13
+
14
+ class Task(BaseModel, BaseEntity):
15
+ __pydantic_resolve_relationships__ = [
16
+ Relationship(field='owner_id', target_kls=Member),
17
+ Relationship(field='story_id', target_kls='Story'),
18
+ ]
19
+ id: int
20
+ story_id: int
21
+ description: str
22
+ owner_id: int
23
+
24
+ class Story(BaseModel, BaseEntity):
25
+ __pydantic_resolve_relationships__ = [
26
+ Relationship(field='id', target_kls=list[Task]),
27
+ ]
28
+ id: int
29
+ type: Literal['feature', 'bugfix']
30
+ dct: dict
31
+ sprint_id: int
32
+ title: str
33
+ description: str
34
+
35
+ class Sprint(BaseModel, BaseEntity):
36
+ __pydantic_resolve_relationships__ = [
37
+ Relationship(field='id', target_kls=list[Story])
38
+ ]
39
+ id: int
40
+ name: str
@@ -103,7 +103,7 @@ dev = [
103
103
  [package.metadata]
104
104
  requires-dist = [
105
105
  { name = "fastapi", specifier = ">=0.110" },
106
- { name = "pydantic-resolve", specifier = ">=1.13.2" },
106
+ { name = "pydantic-resolve", specifier = ">=2.2.3" },
107
107
  { name = "pytest", marker = "extra == 'dev'" },
108
108
  { name = "ruff", marker = "extra == 'dev'" },
109
109
  { name = "uvicorn", marker = "extra == 'dev'" },
@@ -259,14 +259,14 @@ wheels = [
259
259
 
260
260
  [[package]]
261
261
  name = "pydantic-resolve"
262
- version = "1.13.2"
262
+ version = "2.2.3"
263
263
  source = { registry = "https://pypi.org/simple" }
264
264
  dependencies = [
265
265
  { name = "aiodataloader" },
266
266
  ]
267
- sdist = { url = "https://files.pythonhosted.org/packages/e5/8d/755cbd972c2113360b03f452a486694b17b04333d1a6af8d3083bb2631d9/pydantic_resolve-1.13.2.tar.gz", hash = "sha256:48d3ba00c766fa8bd7c86ff0e77a6fb149f9819a124bff8783835628ce35106a", size = 24512, upload-time = "2025-09-04T12:14:52.413Z" }
267
+ sdist = { url = "https://files.pythonhosted.org/packages/6d/9f/56e1960a479c63b572146826806bf0c408e9e094402a56cb5743627e76ea/pydantic_resolve-2.2.3.tar.gz", hash = "sha256:969ca1541930dd0395a31b8bd8939cc18ea6503f73a9ac96a2394ad423445e35", size = 33144, upload-time = "2025-12-07T12:37:38.841Z" }
268
268
  wheels = [
269
- { url = "https://files.pythonhosted.org/packages/d3/11/96461b82307ace63faeefb3651401aba7cb8515448ff2ac7ce19fa54ae8b/pydantic_resolve-1.13.2-py3-none-any.whl", hash = "sha256:305ee2ab1d96ea1ae55678ffb2c554cc5d6029e38a6f7104d93f74a2b94c9c00", size = 26504, upload-time = "2025-09-04T12:14:49.939Z" },
269
+ { url = "https://files.pythonhosted.org/packages/09/b5/10204c33c2b3f9555ca21d6ded56baf7b9b0bb4733687cb3e0bde42e5f19/pydantic_resolve-2.2.3-py3-none-any.whl", hash = "sha256:05aaf82f4e403dd0cef4e45bb42d59719ccf903b029102730a32a80fa47e6ac2", size = 35149, upload-time = "2025-12-07T12:37:37.146Z" },
270
270
  ]
271
271
 
272
272
  [[package]]
@@ -1,28 +0,0 @@
1
- from typing import Literal
2
-
3
- from pydantic import BaseModel
4
-
5
-
6
- class Sprint(BaseModel):
7
- id: int
8
- name: str
9
-
10
- class Story(BaseModel):
11
- id: int
12
- type: Literal['feature', 'bugfix']
13
- dct: dict
14
- sprint_id: int
15
- title: str
16
- description: str
17
-
18
- class Task(BaseModel):
19
- id: int
20
- story_id: int
21
- description: str
22
- owner_id: int
23
-
24
- class Member(BaseModel):
25
- id: int
26
- first_name: str
27
- last_name: str
28
-