fastapi-voyager 0.4.1__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.
- fastapi_voyager/__init__.py +5 -0
- fastapi_voyager/cli.py +289 -0
- fastapi_voyager/filter.py +105 -0
- fastapi_voyager/graph.py +396 -0
- fastapi_voyager/module.py +44 -0
- fastapi_voyager/server.py +107 -0
- fastapi_voyager/type.py +48 -0
- fastapi_voyager/type_helper.py +232 -0
- fastapi_voyager/version.py +2 -0
- fastapi_voyager/web/component/route-code-display.js +73 -0
- fastapi_voyager/web/component/schema-code-display.js +152 -0
- fastapi_voyager/web/component/schema-field-filter.js +189 -0
- fastapi_voyager/web/graph-ui.js +137 -0
- fastapi_voyager/web/graphviz.svg.css +42 -0
- fastapi_voyager/web/graphviz.svg.js +580 -0
- fastapi_voyager/web/index.html +224 -0
- fastapi_voyager/web/quasar.min.css +1 -0
- fastapi_voyager/web/quasar.min.js +127 -0
- fastapi_voyager/web/vue-main.js +213 -0
- fastapi_voyager-0.4.1.dist-info/METADATA +175 -0
- fastapi_voyager-0.4.1.dist-info/RECORD +24 -0
- fastapi_voyager-0.4.1.dist-info/WHEEL +4 -0
- fastapi_voyager-0.4.1.dist-info/entry_points.txt +2 -0
- fastapi_voyager-0.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import os
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from typing import get_origin, get_args, Union, Annotated, Any
|
|
5
|
+
from fastapi_voyager.type import FieldInfo
|
|
6
|
+
from types import UnionType
|
|
7
|
+
|
|
8
|
+
# Python <3.12 compatibility: TypeAliasType exists only from 3.12 (PEP 695)
|
|
9
|
+
try: # pragma: no cover - import guard
|
|
10
|
+
from typing import TypeAliasType # type: ignore
|
|
11
|
+
except Exception: # pragma: no cover
|
|
12
|
+
class _DummyTypeAliasType: # minimal sentinel so isinstance checks are safe
|
|
13
|
+
pass
|
|
14
|
+
TypeAliasType = _DummyTypeAliasType # type: ignore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_optional(annotation):
|
|
18
|
+
origin = get_origin(annotation)
|
|
19
|
+
args = get_args(annotation)
|
|
20
|
+
if origin is Union and type(None) in args:
|
|
21
|
+
return True
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_list(annotation):
|
|
26
|
+
return getattr(annotation, "__origin__", None) == list
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def shelling_type(type):
|
|
30
|
+
while _is_optional(type) or _is_list(type):
|
|
31
|
+
type = type.__args__[0]
|
|
32
|
+
return type
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def full_class_name(cls):
|
|
36
|
+
return f"{cls.__module__}.{cls.__qualname__}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_core_types(tp):
|
|
40
|
+
"""
|
|
41
|
+
- get the core type
|
|
42
|
+
- always return a tuple of core types
|
|
43
|
+
"""
|
|
44
|
+
if tp is type(None):
|
|
45
|
+
return tuple()
|
|
46
|
+
|
|
47
|
+
# Unwrap PEP 695 type aliases (they wrap the actual annotation in __value__)
|
|
48
|
+
# Repeat in case of nested aliasing.
|
|
49
|
+
def _unwrap_alias(t):
|
|
50
|
+
while isinstance(t, TypeAliasType) or (
|
|
51
|
+
t.__class__.__name__ == 'TypeAliasType' and hasattr(t, '__value__')
|
|
52
|
+
):
|
|
53
|
+
try:
|
|
54
|
+
t = t.__value__
|
|
55
|
+
except Exception: # pragma: no cover - defensive
|
|
56
|
+
break
|
|
57
|
+
return t
|
|
58
|
+
|
|
59
|
+
tp = _unwrap_alias(tp)
|
|
60
|
+
|
|
61
|
+
# 1. Unwrap list layers
|
|
62
|
+
def _shell_list(_tp):
|
|
63
|
+
while _is_list(_tp):
|
|
64
|
+
args = getattr(_tp, "__args__", ())
|
|
65
|
+
if args:
|
|
66
|
+
_tp = args[0]
|
|
67
|
+
else:
|
|
68
|
+
break
|
|
69
|
+
return _tp
|
|
70
|
+
|
|
71
|
+
tp = _shell_list(tp)
|
|
72
|
+
|
|
73
|
+
# Alias could wrap a list element, unwrap again
|
|
74
|
+
tp = _unwrap_alias(tp)
|
|
75
|
+
|
|
76
|
+
if tp is type(None): # check again
|
|
77
|
+
return tuple()
|
|
78
|
+
|
|
79
|
+
while True:
|
|
80
|
+
orig = get_origin(tp)
|
|
81
|
+
|
|
82
|
+
if orig in (Union, UnionType):
|
|
83
|
+
args = list(get_args(tp))
|
|
84
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
85
|
+
has_none = len(non_none) != len(args)
|
|
86
|
+
# Optional[T] case -> keep unwrapping (exactly one real type + None)
|
|
87
|
+
if has_none and len(non_none) == 1:
|
|
88
|
+
tp = non_none[0]
|
|
89
|
+
tp = _unwrap_alias(tp)
|
|
90
|
+
tp = _shell_list(tp)
|
|
91
|
+
continue
|
|
92
|
+
# General union: return all non-None members (order preserved)
|
|
93
|
+
if non_none:
|
|
94
|
+
return tuple(non_none)
|
|
95
|
+
return tuple()
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
# single concrete type
|
|
99
|
+
return (tp,)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_type_name(anno):
|
|
103
|
+
def name_of(tp):
|
|
104
|
+
origin = get_origin(tp)
|
|
105
|
+
args = get_args(tp)
|
|
106
|
+
|
|
107
|
+
# Annotated[T, ...] -> T
|
|
108
|
+
if origin is Annotated:
|
|
109
|
+
return name_of(args[0]) if args else 'Annotated'
|
|
110
|
+
|
|
111
|
+
# Union / Optional
|
|
112
|
+
if origin is Union:
|
|
113
|
+
non_none = [a for a in args if a is not type(None)]
|
|
114
|
+
if len(non_none) == 1 and len(args) == 2:
|
|
115
|
+
return f"Optional[{name_of(non_none[0])}]"
|
|
116
|
+
return f"Union[{', '.join(name_of(a) for a in args)}]"
|
|
117
|
+
|
|
118
|
+
# Parametrized generics
|
|
119
|
+
if origin is not None:
|
|
120
|
+
origin_name_map = {
|
|
121
|
+
list: 'List',
|
|
122
|
+
dict: 'Dict',
|
|
123
|
+
set: 'Set',
|
|
124
|
+
tuple: 'Tuple',
|
|
125
|
+
frozenset: 'FrozenSet',
|
|
126
|
+
}
|
|
127
|
+
origin_name = origin_name_map.get(origin)
|
|
128
|
+
if origin_name is None:
|
|
129
|
+
origin_name = getattr(origin, '__name__', None) or str(origin).replace('typing.', '')
|
|
130
|
+
if args:
|
|
131
|
+
return f"{origin_name}[{', '.join(name_of(a) for a in args)}]"
|
|
132
|
+
return origin_name
|
|
133
|
+
|
|
134
|
+
# Non-generic leaf types
|
|
135
|
+
if tp is Any:
|
|
136
|
+
return 'Any'
|
|
137
|
+
if tp is None or tp is type(None):
|
|
138
|
+
return 'None'
|
|
139
|
+
if isinstance(tp, type):
|
|
140
|
+
return tp.__name__
|
|
141
|
+
|
|
142
|
+
# ForwardRef
|
|
143
|
+
fwd = getattr(tp, '__forward_arg__', None) or getattr(tp, 'arg', None)
|
|
144
|
+
if fwd:
|
|
145
|
+
return str(fwd)
|
|
146
|
+
|
|
147
|
+
# Fallback clean string
|
|
148
|
+
return str(tp).replace('typing.', '').replace('<class ', '').replace('>', '').replace("'", '')
|
|
149
|
+
|
|
150
|
+
return name_of(anno)
|
|
151
|
+
|
|
152
|
+
def is_inheritance_of_pydantic_base(cls):
|
|
153
|
+
return issubclass(cls, BaseModel) and cls is not BaseModel
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_bases_fields(schemas: list[type[BaseModel]]) -> set[str]:
|
|
157
|
+
"""Collect field names from a list of BaseModel subclasses (their model_fields keys)."""
|
|
158
|
+
fields: set[str] = set()
|
|
159
|
+
for schema in schemas:
|
|
160
|
+
for k, _ in getattr(schema, 'model_fields', {}).items():
|
|
161
|
+
fields.add(k)
|
|
162
|
+
return fields
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_pydantic_fields(schema: type[BaseModel], bases_fields: set[str]) -> list[FieldInfo]:
|
|
166
|
+
"""Extract pydantic model fields with metadata.
|
|
167
|
+
|
|
168
|
+
Parameters:
|
|
169
|
+
schema: The pydantic BaseModel subclass to inspect.
|
|
170
|
+
bases_fields: Set of field names that come from base classes (for from_base marking).
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A list of FieldInfo objects describing the schema's direct fields.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def _is_object(anno): # internal helper, previously a method on Analytics
|
|
177
|
+
_types = get_core_types(anno)
|
|
178
|
+
return any(is_inheritance_of_pydantic_base(t) for t in _types if t)
|
|
179
|
+
|
|
180
|
+
fields: list[FieldInfo] = []
|
|
181
|
+
for k, v in schema.model_fields.items():
|
|
182
|
+
anno = v.annotation
|
|
183
|
+
fields.append(FieldInfo(
|
|
184
|
+
is_object=_is_object(anno),
|
|
185
|
+
name=k,
|
|
186
|
+
from_base=k in bases_fields,
|
|
187
|
+
type_name=get_type_name(anno),
|
|
188
|
+
is_exclude=bool(v.exclude)
|
|
189
|
+
))
|
|
190
|
+
return fields
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_vscode_link(kls):
|
|
194
|
+
"""Build a VSCode deep link to the class definition.
|
|
195
|
+
|
|
196
|
+
Priority:
|
|
197
|
+
1. If running inside WSL and WSL_DISTRO_NAME is present, return a remote link:
|
|
198
|
+
vscode://vscode-remote/wsl+<distro>/<absolute/path>:<line>
|
|
199
|
+
(This opens directly in the VSCode WSL remote window.)
|
|
200
|
+
2. Else, if path is /mnt/<drive>/..., translate to Windows drive and return vscode://file/C:\\...:line
|
|
201
|
+
3. Else, fallback to vscode://file/<unix-absolute-path>:line
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
source_file = inspect.getfile(kls)
|
|
205
|
+
_lines, start_line = inspect.getsourcelines(kls)
|
|
206
|
+
|
|
207
|
+
distro = os.environ.get("WSL_DISTRO_NAME")
|
|
208
|
+
if distro:
|
|
209
|
+
# Ensure absolute path (it should already be under /) and build remote link
|
|
210
|
+
return f"vscode://vscode-remote/wsl+{distro}{source_file}:{start_line}"
|
|
211
|
+
|
|
212
|
+
# Non-remote scenario: maybe user wants to open via translated Windows path
|
|
213
|
+
if source_file.startswith('/mnt/') and len(source_file) > 6:
|
|
214
|
+
parts = source_file.split('/')
|
|
215
|
+
if len(parts) >= 4 and len(parts[2]) == 1: # drive letter
|
|
216
|
+
drive = parts[2].upper()
|
|
217
|
+
rest = parts[3:]
|
|
218
|
+
win_path = drive + ':\\' + '\\'.join(rest)
|
|
219
|
+
return f"vscode://file/{win_path}:{start_line}"
|
|
220
|
+
|
|
221
|
+
# Fallback plain unix path
|
|
222
|
+
return f"vscode://file/{source_file}:{start_line}"
|
|
223
|
+
except Exception:
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_source(kls):
|
|
228
|
+
try:
|
|
229
|
+
source = inspect.getsource(kls)
|
|
230
|
+
return source
|
|
231
|
+
except Exception:
|
|
232
|
+
return "failed to get source"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { defineComponent, ref, watch, onMounted } = window.Vue;
|
|
2
|
+
|
|
3
|
+
// Component: RouteCodeDisplay
|
|
4
|
+
// Props:
|
|
5
|
+
// routeId: route id key in routeItems
|
|
6
|
+
// modelValue: dialog visibility
|
|
7
|
+
// routes: object map { id: { id, name, source_code } }
|
|
8
|
+
export default defineComponent({
|
|
9
|
+
name: 'RouteCodeDisplay',
|
|
10
|
+
props: {
|
|
11
|
+
routeId: { type: String, required: true },
|
|
12
|
+
modelValue: { type: Boolean, default: false },
|
|
13
|
+
routes: { type: Object, default: () => ({}) },
|
|
14
|
+
},
|
|
15
|
+
emits: ['close'],
|
|
16
|
+
setup(props, { emit }) {
|
|
17
|
+
const code = ref('');
|
|
18
|
+
const error = ref(null);
|
|
19
|
+
const link = ref('');
|
|
20
|
+
|
|
21
|
+
function close() { emit('close'); }
|
|
22
|
+
|
|
23
|
+
function highlightLater() {
|
|
24
|
+
requestAnimationFrame(() => {
|
|
25
|
+
try {
|
|
26
|
+
if (window.hljs) {
|
|
27
|
+
const block = document.querySelector('.frv-route-code-display pre code.language-python');
|
|
28
|
+
if (block) {
|
|
29
|
+
window.hljs.highlightElement(block);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.warn('highlight failed', e);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function load() {
|
|
39
|
+
error.value = null;
|
|
40
|
+
if (!props.routeId) { code.value=''; return; }
|
|
41
|
+
const item = props.routes[props.routeId];
|
|
42
|
+
if (item && item.source_code) {
|
|
43
|
+
code.value = item.source_code;
|
|
44
|
+
link.value = item.vscode_link || '';
|
|
45
|
+
highlightLater();
|
|
46
|
+
} else if (item) {
|
|
47
|
+
code.value = '// no source code available';
|
|
48
|
+
link.value = item.vscode_link || '';
|
|
49
|
+
} else {
|
|
50
|
+
error.value = 'Route not found';
|
|
51
|
+
link.value = '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
watch(() => props.modelValue, (v) => { if (v) load(); });
|
|
56
|
+
watch(() => props.routeId, () => { if (props.modelValue) load(); });
|
|
57
|
+
|
|
58
|
+
onMounted(() => { if (props.modelValue) load(); });
|
|
59
|
+
|
|
60
|
+
return { code, error, close, link };
|
|
61
|
+
},
|
|
62
|
+
template: `
|
|
63
|
+
<div class="frv-route-code-display" style="border:1px solid #ccc; position:relative; width:50vw; max-width:50vw; height:100%; background:#fff;">
|
|
64
|
+
<q-btn dense flat round icon="close" @click="close" aria-label="Close" style="position:absolute; top:6px; right:6px; z-index:10; background:rgba(255,255,255,0.85)" />
|
|
65
|
+
<div v-if="link" class="q-ml-md q-mt-md" style="padding-top:4px;">
|
|
66
|
+
<a :href="link" target="_blank" rel="noopener" style="font-size:12px; color:#3b82f6;">Open in VSCode</a>
|
|
67
|
+
</div>
|
|
68
|
+
<div style="padding:40px 16px 16px 16px; height:100%; box-sizing:border-box; overflow:auto;">
|
|
69
|
+
<div v-if="error" style="color:#c10015; font-family:Menlo, monospace; font-size:12px;">{{ error }}</div>
|
|
70
|
+
<pre v-else style="margin:0;"><code class="language-python">{{ code }}</code></pre>
|
|
71
|
+
</div>
|
|
72
|
+
</div>`
|
|
73
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const { defineComponent, ref, watch, onMounted } = window.Vue;
|
|
2
|
+
|
|
3
|
+
// Component: SchemaCodeDisplay
|
|
4
|
+
// Props:
|
|
5
|
+
// schemaName: full qualified schema id (module.Class)
|
|
6
|
+
// modelValue: boolean (dialog visibility from parent)
|
|
7
|
+
// source: optional direct source code (if already resolved client side)
|
|
8
|
+
// schemas: list of schema meta objects (each containing fullname & source_code)
|
|
9
|
+
// Behavior:
|
|
10
|
+
// - When dialog opens and schemaName changes, search schemas prop and display its source_code.
|
|
11
|
+
// - No network / global cache side effects.
|
|
12
|
+
export default defineComponent({
|
|
13
|
+
name: "SchemaCodeDisplay",
|
|
14
|
+
props: {
|
|
15
|
+
schemaName: { type: String, required: true },
|
|
16
|
+
modelValue: { type: Boolean, default: false },
|
|
17
|
+
source: { type: String, default: null },
|
|
18
|
+
schemas: { type: Array, default: () => [] },
|
|
19
|
+
},
|
|
20
|
+
emits: ["close"],
|
|
21
|
+
setup(props, { emit }) {
|
|
22
|
+
const loading = ref(false);
|
|
23
|
+
const code = ref("");
|
|
24
|
+
const link = ref("");
|
|
25
|
+
const error = ref(null);
|
|
26
|
+
const fields = ref([]); // schema fields list
|
|
27
|
+
const tab = ref('source');
|
|
28
|
+
|
|
29
|
+
function close() {
|
|
30
|
+
emit("close");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function highlightLater() {
|
|
34
|
+
// wait a tick for DOM update
|
|
35
|
+
requestAnimationFrame(() => {
|
|
36
|
+
try {
|
|
37
|
+
if (window.hljs) {
|
|
38
|
+
const block = document.querySelector(
|
|
39
|
+
".frv-code-display pre code.language-python"
|
|
40
|
+
);
|
|
41
|
+
if (block) {
|
|
42
|
+
window.hljs.highlightElement(block);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn("highlight failed", e);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function loadSource() {
|
|
52
|
+
if (!props.schemaName) return;
|
|
53
|
+
if (props.source) {
|
|
54
|
+
code.value = props.source;
|
|
55
|
+
highlightLater();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
loading.value = true;
|
|
59
|
+
error.value = null;
|
|
60
|
+
try {
|
|
61
|
+
const item = props.schemas.find((s) => s.fullname === props.schemaName);
|
|
62
|
+
if (item) {
|
|
63
|
+
link.value = item.vscode_link || "";
|
|
64
|
+
code.value = item.source_code || "// no source code available";
|
|
65
|
+
// capture fields if provided
|
|
66
|
+
fields.value = Array.isArray(item.fields) ? item.fields : [];
|
|
67
|
+
highlightLater();
|
|
68
|
+
} else {
|
|
69
|
+
error.value = "Schema not found";
|
|
70
|
+
}
|
|
71
|
+
} catch (e) {
|
|
72
|
+
error.value = "Failed to load source";
|
|
73
|
+
} finally {
|
|
74
|
+
loading.value = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// re-highlight when switching back to source tab
|
|
79
|
+
watch(
|
|
80
|
+
() => tab.value,
|
|
81
|
+
(val) => {
|
|
82
|
+
if (val === 'source') {
|
|
83
|
+
highlightLater();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
watch(
|
|
89
|
+
() => props.modelValue,
|
|
90
|
+
(val) => {
|
|
91
|
+
if (val) {
|
|
92
|
+
loadSource();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
onMounted(() => {
|
|
98
|
+
if (props.modelValue) loadSource();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { loading, link, code, error, close, fields, tab };
|
|
102
|
+
},
|
|
103
|
+
template: `
|
|
104
|
+
<div class="frv-code-display" style="border: 1px solid #ccc; border-left: none; position:relative; width:40vw; max-width:40vw; height:100%; background:#fff;">
|
|
105
|
+
<q-btn dense flat round icon="close" @click="close" aria-label="Close"
|
|
106
|
+
style="position:absolute; top:6px; right:6px; z-index:10; background:rgba(255,255,255,0.85)" />
|
|
107
|
+
<div v-if="link" class="q-ml-md q-mt-md">
|
|
108
|
+
<a :href="link" target="_blank" rel="noopener" style="font-size:12px; color:#3b82f6;">
|
|
109
|
+
Open in VSCode
|
|
110
|
+
</a>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div style="padding:8px 12px 0 12px; box-sizing:border-box;">
|
|
114
|
+
<q-tabs v-model="tab" align="left" dense active-color="primary" indicator-color="primary" class="text-grey-8">
|
|
115
|
+
<q-tab name="source" label="Source Code" />
|
|
116
|
+
<q-tab name="fields" label="Fields" />
|
|
117
|
+
</q-tabs>
|
|
118
|
+
</div>
|
|
119
|
+
<q-separator />
|
|
120
|
+
<div style="padding:8px 16px 16px 16px; height:75%; box-sizing:border-box; overflow:auto;">
|
|
121
|
+
<div v-if="loading" style="font-family:Menlo, monospace; font-size:12px;">Loading source...</div>
|
|
122
|
+
<div v-else-if="error" style="color:#c10015; font-family:Menlo, monospace; font-size:12px;">{{ error }}</div>
|
|
123
|
+
<template v-else>
|
|
124
|
+
<div v-show="tab === 'source'">
|
|
125
|
+
<pre style="margin:0;"><code class="language-python">{{ code }}</code></pre>
|
|
126
|
+
</div>
|
|
127
|
+
<div v-show="tab === 'fields'">
|
|
128
|
+
<table style="border-collapse:collapse; width:100%; font-size:12px; font-family:Menlo, monospace;">
|
|
129
|
+
<thead>
|
|
130
|
+
<tr>
|
|
131
|
+
<th style="text-align:left; border-bottom:1px solid #ddd; padding:4px 6px;">Field</th>
|
|
132
|
+
<th style="text-align:left; border-bottom:1px solid #ddd; padding:4px 6px;">Type</th>
|
|
133
|
+
<th style="text-align:left; border-bottom:1px solid #ddd; padding:4px 6px;">From Base</th>
|
|
134
|
+
</tr>
|
|
135
|
+
</thead>
|
|
136
|
+
<tbody>
|
|
137
|
+
<tr v-for="f in fields" :key="f.name">
|
|
138
|
+
<td style="padding:4px 6px; border-bottom:1px solid #f0f0f0;">{{ f.name }}</td>
|
|
139
|
+
<td style="padding:4px 6px; border-bottom:1px solid #f0f0f0; white-space:nowrap;">{{ f.type_name }}</td>
|
|
140
|
+
<td style="padding:4px 6px; border-bottom:1px solid #f0f0f0; text-align:left;">{{ f.from_base ? '✔︎' : '' }}</td>
|
|
141
|
+
</tr>
|
|
142
|
+
<tr v-if="!fields.length">
|
|
143
|
+
<td colspan="3" style="padding:8px 6px; color:#666; font-style:italic;">No fields</td>
|
|
144
|
+
</tr>
|
|
145
|
+
</tbody>
|
|
146
|
+
</table>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
`,
|
|
152
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { GraphUI } from "../graph-ui.js";
|
|
2
|
+
const { defineComponent, reactive, ref, onMounted, nextTick, watch } =
|
|
3
|
+
window.Vue;
|
|
4
|
+
|
|
5
|
+
// SchemaFieldFilter component
|
|
6
|
+
// Features:
|
|
7
|
+
// - Fetch initial schemas list (GET /dot) and build schema options
|
|
8
|
+
// - Second selector lists fields of the chosen schema
|
|
9
|
+
// - Query button disabled until a schema is selected
|
|
10
|
+
// - On query: POST /dot with schema_name + optional schema_field; render returned DOT in #graph-schema-field
|
|
11
|
+
// - Uses GraphUI once and re-renders
|
|
12
|
+
// - Emits 'queried' event after successful render (payload: { schemaName, fieldName })
|
|
13
|
+
export default defineComponent({
|
|
14
|
+
name: "SchemaFieldFilter",
|
|
15
|
+
props: {
|
|
16
|
+
schemaName: { type: String, default: null }, // external injection triggers auto-query
|
|
17
|
+
schemas: { type: Array, default: () => [] }, // externally provided schemas (state.rawSchemasFull or similar)
|
|
18
|
+
},
|
|
19
|
+
emits: ["queried", "close"],
|
|
20
|
+
setup(props, { emit }) {
|
|
21
|
+
const state = reactive({
|
|
22
|
+
loadingSchemas: false,
|
|
23
|
+
querying: false,
|
|
24
|
+
schemas: [], // [{ name, fullname, fields: [{name,...}] }]
|
|
25
|
+
schemaOptions: [], // [{ label, value }]
|
|
26
|
+
fieldOptions: [], // [ field.name ]
|
|
27
|
+
schemaFullname: null,
|
|
28
|
+
fieldName: null,
|
|
29
|
+
error: null,
|
|
30
|
+
showFields: "object",
|
|
31
|
+
showFieldOptions: [
|
|
32
|
+
{ label: "No fields", value: "single" },
|
|
33
|
+
{ label: "Object fields", value: "object" },
|
|
34
|
+
{ label: "All fields", value: "all" },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let graphInstance = null;
|
|
39
|
+
let lastAppliedExternal = null;
|
|
40
|
+
|
|
41
|
+
async function loadSchemas() {
|
|
42
|
+
// Refactored: use externally provided props.schemas directly; no network call.
|
|
43
|
+
state.error = null;
|
|
44
|
+
state.schemas = Array.isArray(props.schemas) ? props.schemas : [];
|
|
45
|
+
state.schemaOptions = state.schemas.map((s) => ({
|
|
46
|
+
label: `${s.name} (${s.fullname})`,
|
|
47
|
+
value: s.fullname,
|
|
48
|
+
}));
|
|
49
|
+
// Maintain compatibility: loadingSchemas flag toggled quickly (no async work)
|
|
50
|
+
state.loadingSchemas = false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onFilterSchemas(val, update) {
|
|
54
|
+
const needle = (val || "").toLowerCase();
|
|
55
|
+
update(() => {
|
|
56
|
+
let opts = state.schemas.map((s) => ({
|
|
57
|
+
label: `${s.name} (${s.fullname})`,
|
|
58
|
+
value: s.fullname,
|
|
59
|
+
}));
|
|
60
|
+
if (needle) {
|
|
61
|
+
opts = opts.filter((o) => o.label.toLowerCase().includes(needle));
|
|
62
|
+
}
|
|
63
|
+
state.schemaOptions = opts;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onSchemaChange(val) {
|
|
68
|
+
state.schemaFullname = val;
|
|
69
|
+
state.fieldName = null;
|
|
70
|
+
const schema = state.schemas.find((s) => s.fullname === val);
|
|
71
|
+
state.fieldOptions = schema ? schema.fields.map((f) => f.name) : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function onQuery() {
|
|
75
|
+
if (!state.schemaFullname) return;
|
|
76
|
+
state.querying = true;
|
|
77
|
+
state.error = null;
|
|
78
|
+
try {
|
|
79
|
+
const payload = {
|
|
80
|
+
schema_name: state.schemaFullname,
|
|
81
|
+
schema_field: state.fieldName || null,
|
|
82
|
+
show_fields: state.showFields,
|
|
83
|
+
};
|
|
84
|
+
const res = await fetch("/dot", {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify(payload),
|
|
88
|
+
});
|
|
89
|
+
const dotText = await res.text();
|
|
90
|
+
if (!graphInstance) {
|
|
91
|
+
graphInstance = new GraphUI("#graph-schema-field");
|
|
92
|
+
}
|
|
93
|
+
await graphInstance.render(dotText);
|
|
94
|
+
emit("queried", {
|
|
95
|
+
schemaName: state.schemaFullname,
|
|
96
|
+
fieldName: state.fieldName,
|
|
97
|
+
});
|
|
98
|
+
} catch (e) {
|
|
99
|
+
state.error = "Query failed";
|
|
100
|
+
console.error("SchemaFieldFilter query failed", e);
|
|
101
|
+
} finally {
|
|
102
|
+
state.querying = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function applyExternalSchema(name) {
|
|
107
|
+
if (!name || !state.schemas.length) return;
|
|
108
|
+
if (lastAppliedExternal === name) return; // avoid duplicate
|
|
109
|
+
const schema = state.schemas.find((s) => s.fullname === name);
|
|
110
|
+
if (!schema) return;
|
|
111
|
+
state.schemaFullname = schema.fullname;
|
|
112
|
+
state.fieldOptions = schema.fields.map((f) => f.name);
|
|
113
|
+
state.fieldName = null; // reset field for external injection
|
|
114
|
+
lastAppliedExternal = name;
|
|
115
|
+
// auto query
|
|
116
|
+
onQuery();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onMounted(async () => {
|
|
120
|
+
await nextTick();
|
|
121
|
+
await loadSchemas();
|
|
122
|
+
if (props.schemaName) {
|
|
123
|
+
applyExternalSchema(props.schemaName);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
function close() {
|
|
129
|
+
emit("close");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { state, onSchemaChange, onQuery, close, onFilterSchemas };
|
|
133
|
+
},
|
|
134
|
+
template: `
|
|
135
|
+
<div style="height:100%; position:relative; background:#fff;">
|
|
136
|
+
<div style="position:absolute; top:8px; left:8px; z-index:10; background:rgba(255,255,255,0.95); padding:8px 10px; border-radius:4px; box-shadow:0 1px 3px rgba(0,0,0,0.15);" class="q-gutter-sm row items-center">
|
|
137
|
+
<q-select
|
|
138
|
+
dense outlined use-input input-debounce="0"
|
|
139
|
+
v-model="state.schemaFullname"
|
|
140
|
+
:options="state.schemaOptions"
|
|
141
|
+
option-label="label"
|
|
142
|
+
option-value="value"
|
|
143
|
+
emit-value
|
|
144
|
+
map-options
|
|
145
|
+
:loading="state.loadingSchemas"
|
|
146
|
+
style="min-width:220px"
|
|
147
|
+
clearable
|
|
148
|
+
label="Select schema"
|
|
149
|
+
@update:model-value="onSchemaChange"
|
|
150
|
+
@filter="onFilterSchemas"
|
|
151
|
+
/>
|
|
152
|
+
<q-select
|
|
153
|
+
dense outlined
|
|
154
|
+
v-model="state.fieldName"
|
|
155
|
+
:disable="!state.schemaFullname || state.fieldOptions.length===0"
|
|
156
|
+
:options="state.fieldOptions"
|
|
157
|
+
style="min-width:180px"
|
|
158
|
+
clearable
|
|
159
|
+
label="Select field (optional)"
|
|
160
|
+
/>
|
|
161
|
+
<q-option-group
|
|
162
|
+
v-model="state.showFields"
|
|
163
|
+
:options="state.showFieldOptions"
|
|
164
|
+
type="radio"
|
|
165
|
+
inline
|
|
166
|
+
dense
|
|
167
|
+
color="primary"
|
|
168
|
+
style="min-width:260px"
|
|
169
|
+
/>
|
|
170
|
+
<q-btn
|
|
171
|
+
class="q-ml-md"
|
|
172
|
+
icon="search"
|
|
173
|
+
label="Search"
|
|
174
|
+
outline
|
|
175
|
+
:disable="!state.schemaFullname"
|
|
176
|
+
:loading="state.querying"
|
|
177
|
+
@click="onQuery" />
|
|
178
|
+
</div>
|
|
179
|
+
<q-btn
|
|
180
|
+
flat dense round icon="close"
|
|
181
|
+
aria-label="Close"
|
|
182
|
+
@click="close"
|
|
183
|
+
style="position:absolute; top:6px; right:6px; z-index:11; background:rgba(255,255,255,0.85);"
|
|
184
|
+
/>
|
|
185
|
+
<div v-if="state.error" style="position:absolute; top:52px; left:8px; z-index:10; color:#c10015; font-size:12px;">{{ state.error }}</div>
|
|
186
|
+
<div id="graph-schema-field" style="width:100%; height:100%; overflow:auto; background:#fafafa"></div>
|
|
187
|
+
</div>
|
|
188
|
+
`,
|
|
189
|
+
});
|