dwf-platform-cli 0.2.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.
- dwf_cli/__init__.py +3 -0
- dwf_cli/__main__.py +4 -0
- dwf_cli/api/__init__.py +3 -0
- dwf_cli/api/auth.py +46 -0
- dwf_cli/api/client.py +113 -0
- dwf_cli/api/datamodel.py +458 -0
- dwf_cli/api/formmodel.py +197 -0
- dwf_cli/api/funcmodel.py +436 -0
- dwf_cli/cli/__init__.py +69 -0
- dwf_cli/cli/_common.py +152 -0
- dwf_cli/cli/auth.py +197 -0
- dwf_cli/cli/config.py +200 -0
- dwf_cli/cli/datamodel.py +2270 -0
- dwf_cli/cli/formmodel.py +1007 -0
- dwf_cli/cli/funcmodel.py +2055 -0
- dwf_cli/cli/schema.py +210 -0
- dwf_cli/core/__init__.py +0 -0
- dwf_cli/core/config.py +177 -0
- dwf_cli/core/crypto.py +22 -0
- dwf_cli/core/errors.py +63 -0
- dwf_cli/core/output.py +6 -0
- dwf_cli/core/validator.py +129 -0
- dwf_cli/mcp/__init__.py +3 -0
- dwf_cli/mcp/server.py +411 -0
- dwf_cli/schemas/__init__.py +37 -0
- dwf_cli/schemas/datamodel/attribute_bind.schema.json +21 -0
- dwf_cli/schemas/datamodel/attribute_create.schema.json +24 -0
- dwf_cli/schemas/datamodel/attribute_update.schema.json +21 -0
- dwf_cli/schemas/datamodel/create.schema.json +27 -0
- dwf_cli/schemas/datamodel/excel_confirm.schema.json +65 -0
- dwf_cli/schemas/datamodel/external_create.schema.json +47 -0
- dwf_cli/schemas/datamodel/external_update.schema.json +47 -0
- dwf_cli/schemas/datamodel/object_create.schema.json +24 -0
- dwf_cli/schemas/datamodel/object_update.schema.json +17 -0
- dwf_cli/schemas/datamodel/relation_create.schema.json +34 -0
- dwf_cli/schemas/datamodel/relation_update.schema.json +34 -0
- dwf_cli/schemas/datamodel/update.schema.json +26 -0
- dwf_cli/schemas/funcmodel/app_create.schema.json +150 -0
- dwf_cli/schemas/funcmodel/app_update.schema.json +153 -0
- dwf_cli/schemas/funcmodel/language-package_create.schema.json +15 -0
- dwf_cli/schemas/funcmodel/operations_create.schema.json +77 -0
- dwf_cli/schemas/funcmodel/operations_update.schema.json +76 -0
- dwf_platform_cli-0.2.0.dist-info/METADATA +347 -0
- dwf_platform_cli-0.2.0.dist-info/RECORD +47 -0
- dwf_platform_cli-0.2.0.dist-info/WHEEL +4 -0
- dwf_platform_cli-0.2.0.dist-info/entry_points.txt +3 -0
- dwf_platform_cli-0.2.0.dist-info/licenses/LICENSE +190 -0
dwf_cli/cli/datamodel.py
ADDED
|
@@ -0,0 +1,2270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as json_mod
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from dwf_cli.api import datamodel as datamodel_api
|
|
13
|
+
from dwf_cli.api.datamodel import ModelType
|
|
14
|
+
from dwf_cli.cli._common import (
|
|
15
|
+
get_client,
|
|
16
|
+
get_console,
|
|
17
|
+
handle_error,
|
|
18
|
+
is_tty,
|
|
19
|
+
resolve_format,
|
|
20
|
+
validate_path,
|
|
21
|
+
)
|
|
22
|
+
from dwf_cli.core.errors import ConflictError, DWFError
|
|
23
|
+
from dwf_cli.core.output import OutputFormat
|
|
24
|
+
from dwf_cli.core.validator import validate_body
|
|
25
|
+
from dwf_cli.schemas import lookup as lookup_schema
|
|
26
|
+
|
|
27
|
+
_HELP_CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
help="Data model and object management.\n\n"
|
|
31
|
+
"Examples:\n"
|
|
32
|
+
" dwf-cli datamodel list -t entity --search User\n"
|
|
33
|
+
" dwf-cli datamodel show User\n"
|
|
34
|
+
' dwf-cli datamodel create --data \'[{"className":"TestClass"}]\'\n'
|
|
35
|
+
" dwf-cli datamodel create -t relation --data '[...]'\n"
|
|
36
|
+
" dwf-cli datamodel create -t external --data '[...]'\n"
|
|
37
|
+
" dwf-cli datamodel object list -c User -p 0 -s 10\n"
|
|
38
|
+
' dwf-cli datamodel object create -c User --data \'{"name":"Alice"}\'\n'
|
|
39
|
+
' dwf-cli datamodel attribute create --data \'{"attributeName":"age","valueType":"Integer"}\'',
|
|
40
|
+
context_settings=_HELP_CONTEXT_SETTINGS,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_object_sub = typer.Typer(
|
|
44
|
+
help="Object CRUD operations.", context_settings=_HELP_CONTEXT_SETTINGS
|
|
45
|
+
)
|
|
46
|
+
_attribute_sub = typer.Typer(
|
|
47
|
+
help="Attribute CRUD and binding operations.",
|
|
48
|
+
context_settings=_HELP_CONTEXT_SETTINGS,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
app.add_typer(_object_sub, name="object")
|
|
52
|
+
app.add_typer(_attribute_sub, name="attribute")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
DRY_RUN_EXIT_CODE = 10
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_DATAMODEL_COLUMNS: list[tuple[str, str]] = [
|
|
59
|
+
("className", "ClassName"),
|
|
60
|
+
("displayName", "DisplayName"),
|
|
61
|
+
("classType", "ClassType"),
|
|
62
|
+
("isSystem", "System"),
|
|
63
|
+
("zoneName", "Zone"),
|
|
64
|
+
("state", "State"),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_response(result: dict[str, Any]) -> tuple[list[dict], dict | None]:
|
|
69
|
+
payload = result.get("data", result)
|
|
70
|
+
if isinstance(payload, dict):
|
|
71
|
+
raw = payload.get("data", payload.get("items", []))
|
|
72
|
+
page_info = payload.get("pageInfo")
|
|
73
|
+
if isinstance(raw, dict):
|
|
74
|
+
items = list(raw.values())
|
|
75
|
+
elif isinstance(raw, list):
|
|
76
|
+
items = raw
|
|
77
|
+
else:
|
|
78
|
+
items = []
|
|
79
|
+
else:
|
|
80
|
+
items = payload if isinstance(payload, list) else []
|
|
81
|
+
page_info = result.get("pageInfo")
|
|
82
|
+
return items, page_info
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _print_table(
|
|
86
|
+
items: list[dict[str, Any]],
|
|
87
|
+
page_info: dict[str, Any] | None,
|
|
88
|
+
console: Console,
|
|
89
|
+
columns: list[tuple[str, str]] | None = None,
|
|
90
|
+
title: str | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
cols = columns or _DATAMODEL_COLUMNS
|
|
93
|
+
table = Table(
|
|
94
|
+
title=title,
|
|
95
|
+
show_header=True,
|
|
96
|
+
header_style="bold cyan",
|
|
97
|
+
show_lines=False,
|
|
98
|
+
)
|
|
99
|
+
table.add_column("#", style="dim", width=4)
|
|
100
|
+
for _, header in cols:
|
|
101
|
+
table.add_column(header)
|
|
102
|
+
|
|
103
|
+
for idx, item in enumerate(items, 1):
|
|
104
|
+
row: list[str] = [str(idx)]
|
|
105
|
+
for key, _ in cols:
|
|
106
|
+
val = item.get(key, "")
|
|
107
|
+
if key == "isSystem":
|
|
108
|
+
val = "[green]Yes[/green]" if val else "[dim]No[/dim]"
|
|
109
|
+
elif key == "state":
|
|
110
|
+
val = "[green]Active[/green]" if val == 1 else "[yellow]Draft[/yellow]"
|
|
111
|
+
else:
|
|
112
|
+
val = str(val)
|
|
113
|
+
row.append(val)
|
|
114
|
+
table.add_row(*row)
|
|
115
|
+
|
|
116
|
+
console.print(table)
|
|
117
|
+
|
|
118
|
+
if page_info:
|
|
119
|
+
total = page_info.get("totalCount", "?")
|
|
120
|
+
page_idx = page_info.get("pageIndex", "?")
|
|
121
|
+
page_sz = page_info.get("pageSize", "?")
|
|
122
|
+
console.print(f"[dim]Page {page_idx}, Size {page_sz}, Total {total}[/dim]")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _read_payload(data: str, file: Path | None) -> Any:
|
|
126
|
+
if file:
|
|
127
|
+
file = validate_path(file)
|
|
128
|
+
return json_mod.loads(file.read_text(encoding="utf-8"))
|
|
129
|
+
if data:
|
|
130
|
+
return json_mod.loads(data)
|
|
131
|
+
raise DWFError("Provide --data or --file")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _do_validate(command_path: str, payload: Any) -> None:
|
|
135
|
+
schema = lookup_schema(command_path)
|
|
136
|
+
if schema is None:
|
|
137
|
+
typer.echo(
|
|
138
|
+
f"No schema found for '{command_path}', skipping validation.", err=True
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
errors = validate_body(schema, payload)
|
|
142
|
+
if errors:
|
|
143
|
+
error_json = json_mod.dumps(
|
|
144
|
+
{"error": "validation_failed", "errors": errors},
|
|
145
|
+
ensure_ascii=False,
|
|
146
|
+
indent=2,
|
|
147
|
+
)
|
|
148
|
+
typer.echo(error_json, err=True)
|
|
149
|
+
raise typer.Exit(code=2)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _empty_ok(ctx: typer.Context, msg: str = "No results.") -> None:
|
|
153
|
+
if is_tty():
|
|
154
|
+
get_console().print(f"[dim]{msg}[/dim]")
|
|
155
|
+
else:
|
|
156
|
+
typer.echo("[]")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command(name="list")
|
|
160
|
+
def list_models(
|
|
161
|
+
ctx: typer.Context,
|
|
162
|
+
model_type: Annotated[
|
|
163
|
+
ModelType,
|
|
164
|
+
typer.Option(
|
|
165
|
+
"--type",
|
|
166
|
+
"-t",
|
|
167
|
+
help="Model type: entity(e), relation(r), external(x)",
|
|
168
|
+
case_sensitive=False,
|
|
169
|
+
),
|
|
170
|
+
] = ModelType.entity,
|
|
171
|
+
page: Annotated[int, typer.Option("--page", "-p", help="Page index (0-based)")] = 0,
|
|
172
|
+
page_size: Annotated[
|
|
173
|
+
int, typer.Option("--page-size", "-s", help="Items per page")
|
|
174
|
+
] = 25,
|
|
175
|
+
page_all: Annotated[
|
|
176
|
+
bool,
|
|
177
|
+
typer.Option("--page-all", help="Fetch all pages automatically"),
|
|
178
|
+
] = False,
|
|
179
|
+
system: Annotated[
|
|
180
|
+
bool,
|
|
181
|
+
typer.Option("--system", help="Include system models (entity only)"),
|
|
182
|
+
] = False,
|
|
183
|
+
search: Annotated[
|
|
184
|
+
str | None,
|
|
185
|
+
typer.Option("--search", help="Fuzzy search keyword"),
|
|
186
|
+
] = None,
|
|
187
|
+
search_fields: Annotated[
|
|
188
|
+
str | None,
|
|
189
|
+
typer.Option(
|
|
190
|
+
"--search-fields",
|
|
191
|
+
help="Comma-separated fields to search (default: className,displayName,zoneName,note)",
|
|
192
|
+
),
|
|
193
|
+
] = None,
|
|
194
|
+
fmt: Annotated[
|
|
195
|
+
OutputFormat,
|
|
196
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
197
|
+
] = OutputFormat.table,
|
|
198
|
+
) -> None:
|
|
199
|
+
try:
|
|
200
|
+
client = get_client(ctx)
|
|
201
|
+
sf = [s.strip() for s in search_fields.split(",")] if search_fields else None
|
|
202
|
+
if page_all:
|
|
203
|
+
all_items: list[dict[str, Any]] = []
|
|
204
|
+
page_idx = 0
|
|
205
|
+
while True:
|
|
206
|
+
r = datamodel_api.list_models(
|
|
207
|
+
client,
|
|
208
|
+
model_type,
|
|
209
|
+
page=page_idx,
|
|
210
|
+
page_size=page_size,
|
|
211
|
+
with_page_info=True,
|
|
212
|
+
is_system=system,
|
|
213
|
+
search=search,
|
|
214
|
+
search_fields=sf,
|
|
215
|
+
)
|
|
216
|
+
p_items, p_info = _extract_response(r)
|
|
217
|
+
all_items.extend(p_items)
|
|
218
|
+
total = p_info.get("totalCount", 0) if p_info else 0
|
|
219
|
+
if not p_items or (total > 0 and len(all_items) >= total):
|
|
220
|
+
break
|
|
221
|
+
page_idx += 1
|
|
222
|
+
result = {
|
|
223
|
+
"data": {
|
|
224
|
+
"data": all_items,
|
|
225
|
+
"pageInfo": {"totalCount": len(all_items)},
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else:
|
|
229
|
+
result = datamodel_api.list_models(
|
|
230
|
+
client,
|
|
231
|
+
model_type,
|
|
232
|
+
page=page,
|
|
233
|
+
page_size=page_size,
|
|
234
|
+
with_page_info=True,
|
|
235
|
+
is_system=system,
|
|
236
|
+
search=search,
|
|
237
|
+
search_fields=sf,
|
|
238
|
+
)
|
|
239
|
+
items, page_info = _extract_response(result)
|
|
240
|
+
|
|
241
|
+
if not items:
|
|
242
|
+
_empty_ok(ctx, "No data models found.")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
fmt = resolve_format(fmt)
|
|
246
|
+
|
|
247
|
+
if fmt == OutputFormat.json:
|
|
248
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
_print_table(items, page_info, get_console())
|
|
252
|
+
except DWFError as exc:
|
|
253
|
+
handle_error(exc)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@app.command()
|
|
257
|
+
def show(
|
|
258
|
+
ctx: typer.Context,
|
|
259
|
+
class_name: Annotated[str, typer.Argument(help="Class name (e.g. User, Quartz)")],
|
|
260
|
+
with_sys_attr: Annotated[
|
|
261
|
+
bool,
|
|
262
|
+
typer.Option("--with-sys-attr", help="Include system attributes"),
|
|
263
|
+
] = False,
|
|
264
|
+
fmt: Annotated[
|
|
265
|
+
OutputFormat,
|
|
266
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
267
|
+
] = OutputFormat.table,
|
|
268
|
+
) -> None:
|
|
269
|
+
try:
|
|
270
|
+
client = get_client(ctx)
|
|
271
|
+
result = datamodel_api.get_attributes(
|
|
272
|
+
client,
|
|
273
|
+
class_name,
|
|
274
|
+
need_sys_attr=with_sys_attr,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if isinstance(result, dict):
|
|
278
|
+
payload = result.get("data", result)
|
|
279
|
+
items = payload if isinstance(payload, list) else [payload]
|
|
280
|
+
elif isinstance(result, list):
|
|
281
|
+
items = result
|
|
282
|
+
else:
|
|
283
|
+
items = []
|
|
284
|
+
|
|
285
|
+
if not items:
|
|
286
|
+
_empty_ok(ctx, f"No attributes found for '{class_name}'.")
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
fmt = resolve_format(fmt)
|
|
290
|
+
|
|
291
|
+
if fmt == OutputFormat.json:
|
|
292
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
_ATTR_COLUMNS: list[tuple[str, str]] = [
|
|
296
|
+
("attributeName", "Name"),
|
|
297
|
+
("displayName", "DisplayName"),
|
|
298
|
+
("valueType", "DataType"),
|
|
299
|
+
("valueLength", "Length"),
|
|
300
|
+
("nullable", "Nullable"),
|
|
301
|
+
("visible", "Visible"),
|
|
302
|
+
("editable", "Editable"),
|
|
303
|
+
]
|
|
304
|
+
table = Table(
|
|
305
|
+
title=f"Attributes of [bold]{class_name}[/bold]",
|
|
306
|
+
show_header=True,
|
|
307
|
+
header_style="bold cyan",
|
|
308
|
+
show_lines=False,
|
|
309
|
+
)
|
|
310
|
+
table.add_column("#", style="dim", width=4)
|
|
311
|
+
for _, header in _ATTR_COLUMNS:
|
|
312
|
+
table.add_column(header)
|
|
313
|
+
|
|
314
|
+
for idx, item in enumerate(items, 1):
|
|
315
|
+
row: list[str] = [str(idx)]
|
|
316
|
+
for key, _ in _ATTR_COLUMNS:
|
|
317
|
+
val = item.get(key, "")
|
|
318
|
+
if key in ("nullable", "visible", "editable"):
|
|
319
|
+
val = "[green]Yes[/green]" if val else "[dim]No[/dim]"
|
|
320
|
+
elif key == "valueLength":
|
|
321
|
+
val = str(val) if val else "-"
|
|
322
|
+
else:
|
|
323
|
+
val = str(val)
|
|
324
|
+
row.append(val)
|
|
325
|
+
table.add_row(*row)
|
|
326
|
+
|
|
327
|
+
get_console().print(table)
|
|
328
|
+
get_console().print(f"[dim]Total {len(items)} attributes[/dim]")
|
|
329
|
+
except DWFError as exc:
|
|
330
|
+
handle_error(exc)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.command()
|
|
334
|
+
def get(
|
|
335
|
+
ctx: typer.Context,
|
|
336
|
+
class_name: Annotated[str, typer.Argument(help="Class name")],
|
|
337
|
+
fmt: Annotated[
|
|
338
|
+
OutputFormat,
|
|
339
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
340
|
+
] = OutputFormat.table,
|
|
341
|
+
) -> None:
|
|
342
|
+
try:
|
|
343
|
+
client = get_client(ctx)
|
|
344
|
+
result = datamodel_api.get_class(client, class_name)
|
|
345
|
+
|
|
346
|
+
fmt = resolve_format(fmt)
|
|
347
|
+
|
|
348
|
+
if fmt == OutputFormat.json:
|
|
349
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
console = get_console()
|
|
353
|
+
data = result.get("data", result) if isinstance(result, dict) else result
|
|
354
|
+
if isinstance(data, dict):
|
|
355
|
+
for key, value in data.items():
|
|
356
|
+
console.print(f"[bold]{key}[/bold]: {value}")
|
|
357
|
+
except DWFError as exc:
|
|
358
|
+
handle_error(exc)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class ExternalMode(str, Enum):
|
|
362
|
+
sql = "sql"
|
|
363
|
+
view = "view"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command()
|
|
367
|
+
def create(
|
|
368
|
+
ctx: typer.Context,
|
|
369
|
+
data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
|
|
370
|
+
file: Annotated[
|
|
371
|
+
Path | None, typer.Option("--file", help="Path to JSON data file")
|
|
372
|
+
] = None,
|
|
373
|
+
model_type: Annotated[
|
|
374
|
+
ModelType,
|
|
375
|
+
typer.Option(
|
|
376
|
+
"--type",
|
|
377
|
+
"-t",
|
|
378
|
+
help="Create type: entity(e), relation(r), external(x)",
|
|
379
|
+
case_sensitive=False,
|
|
380
|
+
),
|
|
381
|
+
] = ModelType.entity,
|
|
382
|
+
with_parent_oid: Annotated[
|
|
383
|
+
bool,
|
|
384
|
+
typer.Option("--with-parent-oid", help="Append withParentOid query param"),
|
|
385
|
+
] = False,
|
|
386
|
+
external_mode: Annotated[
|
|
387
|
+
ExternalMode,
|
|
388
|
+
typer.Option(
|
|
389
|
+
"--external-mode",
|
|
390
|
+
help="External create mode: view (default) or sql",
|
|
391
|
+
case_sensitive=False,
|
|
392
|
+
),
|
|
393
|
+
] = ExternalMode.view,
|
|
394
|
+
if_not_exists: Annotated[
|
|
395
|
+
bool,
|
|
396
|
+
typer.Option("--if-not-exists", help="Skip if class already exists"),
|
|
397
|
+
] = False,
|
|
398
|
+
validate: Annotated[
|
|
399
|
+
bool,
|
|
400
|
+
typer.Option(
|
|
401
|
+
"--validate", help="Validate input against schema before executing"
|
|
402
|
+
),
|
|
403
|
+
] = False,
|
|
404
|
+
dry_run: Annotated[
|
|
405
|
+
bool,
|
|
406
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
407
|
+
] = False,
|
|
408
|
+
fmt: Annotated[
|
|
409
|
+
OutputFormat,
|
|
410
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
411
|
+
] = OutputFormat.table,
|
|
412
|
+
) -> None:
|
|
413
|
+
try:
|
|
414
|
+
raw_payload = _read_payload(data, file)
|
|
415
|
+
|
|
416
|
+
resolved_type = model_type.value
|
|
417
|
+
if resolved_type in ("e",):
|
|
418
|
+
resolved_type = "entity"
|
|
419
|
+
elif resolved_type in ("r",):
|
|
420
|
+
resolved_type = "relation"
|
|
421
|
+
elif resolved_type in ("x",):
|
|
422
|
+
resolved_type = "external"
|
|
423
|
+
|
|
424
|
+
if resolved_type == "entity":
|
|
425
|
+
payload = raw_payload if isinstance(raw_payload, list) else [raw_payload]
|
|
426
|
+
|
|
427
|
+
if validate:
|
|
428
|
+
_do_validate("datamodel create", payload)
|
|
429
|
+
|
|
430
|
+
if dry_run:
|
|
431
|
+
typer.echo(
|
|
432
|
+
json_mod.dumps(
|
|
433
|
+
{
|
|
434
|
+
"action": "create",
|
|
435
|
+
"resource": "entity",
|
|
436
|
+
"data": payload,
|
|
437
|
+
"with_parent_oid": with_parent_oid,
|
|
438
|
+
"if_not_exists": if_not_exists,
|
|
439
|
+
"reversible": False,
|
|
440
|
+
},
|
|
441
|
+
ensure_ascii=False,
|
|
442
|
+
indent=2,
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
446
|
+
|
|
447
|
+
client = get_client(ctx)
|
|
448
|
+
try:
|
|
449
|
+
result = datamodel_api.create_entity(
|
|
450
|
+
client, payload, with_parent_oid=with_parent_oid
|
|
451
|
+
)
|
|
452
|
+
if fmt == OutputFormat.json:
|
|
453
|
+
typer.echo(
|
|
454
|
+
json_mod.dumps(
|
|
455
|
+
{
|
|
456
|
+
"success": True,
|
|
457
|
+
"action": "create",
|
|
458
|
+
"resource": "entity",
|
|
459
|
+
"data": result,
|
|
460
|
+
},
|
|
461
|
+
ensure_ascii=False,
|
|
462
|
+
indent=2,
|
|
463
|
+
)
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
typer.echo(f"Created: {result}", err=True)
|
|
467
|
+
except ConflictError:
|
|
468
|
+
if if_not_exists:
|
|
469
|
+
typer.echo(
|
|
470
|
+
"Entity already exists, skipped (--if-not-exists).", err=True
|
|
471
|
+
)
|
|
472
|
+
else:
|
|
473
|
+
raise
|
|
474
|
+
|
|
475
|
+
elif resolved_type == "relation":
|
|
476
|
+
payload = raw_payload if isinstance(raw_payload, list) else [raw_payload]
|
|
477
|
+
|
|
478
|
+
if validate:
|
|
479
|
+
_do_validate("datamodel relation create", payload)
|
|
480
|
+
|
|
481
|
+
if dry_run:
|
|
482
|
+
typer.echo(
|
|
483
|
+
json_mod.dumps(
|
|
484
|
+
{
|
|
485
|
+
"action": "create",
|
|
486
|
+
"resource": "relation",
|
|
487
|
+
"data": payload,
|
|
488
|
+
"if_not_exists": if_not_exists,
|
|
489
|
+
"reversible": False,
|
|
490
|
+
},
|
|
491
|
+
ensure_ascii=False,
|
|
492
|
+
indent=2,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
496
|
+
|
|
497
|
+
client = get_client(ctx)
|
|
498
|
+
try:
|
|
499
|
+
result = datamodel_api.create_relations(client, payload)
|
|
500
|
+
if fmt == OutputFormat.json:
|
|
501
|
+
typer.echo(
|
|
502
|
+
json_mod.dumps(
|
|
503
|
+
{
|
|
504
|
+
"success": True,
|
|
505
|
+
"action": "create",
|
|
506
|
+
"resource": "relation",
|
|
507
|
+
"data": result,
|
|
508
|
+
},
|
|
509
|
+
ensure_ascii=False,
|
|
510
|
+
indent=2,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
else:
|
|
514
|
+
typer.echo(f"Created: {result}", err=True)
|
|
515
|
+
except ConflictError:
|
|
516
|
+
if if_not_exists:
|
|
517
|
+
typer.echo(
|
|
518
|
+
"Relation already exists, skipped (--if-not-exists).", err=True
|
|
519
|
+
)
|
|
520
|
+
else:
|
|
521
|
+
raise
|
|
522
|
+
|
|
523
|
+
elif resolved_type == "external":
|
|
524
|
+
if external_mode == ExternalMode.view:
|
|
525
|
+
class_list = (
|
|
526
|
+
raw_payload if isinstance(raw_payload, list) else [raw_payload]
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
if validate:
|
|
530
|
+
_do_validate("datamodel create", class_list)
|
|
531
|
+
|
|
532
|
+
if dry_run:
|
|
533
|
+
typer.echo(
|
|
534
|
+
json_mod.dumps(
|
|
535
|
+
{
|
|
536
|
+
"action": "create",
|
|
537
|
+
"resource": "external-class",
|
|
538
|
+
"external_mode": "view",
|
|
539
|
+
"data": class_list,
|
|
540
|
+
"with_parent_oid": with_parent_oid,
|
|
541
|
+
"if_not_exists": if_not_exists,
|
|
542
|
+
},
|
|
543
|
+
ensure_ascii=False,
|
|
544
|
+
indent=2,
|
|
545
|
+
)
|
|
546
|
+
)
|
|
547
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
548
|
+
|
|
549
|
+
client = get_client(ctx)
|
|
550
|
+
try:
|
|
551
|
+
result = datamodel_api.create_entity(
|
|
552
|
+
client, class_list, with_parent_oid=with_parent_oid
|
|
553
|
+
)
|
|
554
|
+
if fmt == OutputFormat.json:
|
|
555
|
+
typer.echo(
|
|
556
|
+
json_mod.dumps(
|
|
557
|
+
{
|
|
558
|
+
"success": True,
|
|
559
|
+
"action": "create",
|
|
560
|
+
"resource": "external-class",
|
|
561
|
+
"external_mode": "view",
|
|
562
|
+
"data": result,
|
|
563
|
+
},
|
|
564
|
+
ensure_ascii=False,
|
|
565
|
+
indent=2,
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
typer.echo(f"Created: {result}", err=True)
|
|
570
|
+
except ConflictError:
|
|
571
|
+
if if_not_exists:
|
|
572
|
+
typer.echo(
|
|
573
|
+
"External class already exists, skipped (--if-not-exists).",
|
|
574
|
+
err=True,
|
|
575
|
+
)
|
|
576
|
+
else:
|
|
577
|
+
raise
|
|
578
|
+
else:
|
|
579
|
+
if not isinstance(raw_payload, dict):
|
|
580
|
+
raise DWFError(
|
|
581
|
+
"External SQL mode requires a JSON object with 'classList' and optional 'customSqlMap'"
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
class_list = raw_payload.get("classList", [])
|
|
585
|
+
sql_map = raw_payload.get("customSqlMap")
|
|
586
|
+
|
|
587
|
+
if validate:
|
|
588
|
+
_do_validate("datamodel external create", raw_payload)
|
|
589
|
+
|
|
590
|
+
if dry_run:
|
|
591
|
+
typer.echo(
|
|
592
|
+
json_mod.dumps(
|
|
593
|
+
{
|
|
594
|
+
"action": "create",
|
|
595
|
+
"resource": "external-class",
|
|
596
|
+
"external_mode": "sql",
|
|
597
|
+
"classList": class_list,
|
|
598
|
+
"customSqlMap": sql_map,
|
|
599
|
+
"if_not_exists": if_not_exists,
|
|
600
|
+
},
|
|
601
|
+
ensure_ascii=False,
|
|
602
|
+
indent=2,
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
606
|
+
|
|
607
|
+
client = get_client(ctx)
|
|
608
|
+
try:
|
|
609
|
+
result = datamodel_api.create_external_class(
|
|
610
|
+
client, class_list, custom_sql_map=sql_map
|
|
611
|
+
)
|
|
612
|
+
if fmt == OutputFormat.json:
|
|
613
|
+
typer.echo(
|
|
614
|
+
json_mod.dumps(
|
|
615
|
+
{
|
|
616
|
+
"success": True,
|
|
617
|
+
"action": "create",
|
|
618
|
+
"resource": "external-class",
|
|
619
|
+
"external_mode": "sql",
|
|
620
|
+
"data": result,
|
|
621
|
+
},
|
|
622
|
+
ensure_ascii=False,
|
|
623
|
+
indent=2,
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
else:
|
|
627
|
+
typer.echo(f"Created: {result}", err=True)
|
|
628
|
+
except ConflictError:
|
|
629
|
+
if if_not_exists:
|
|
630
|
+
typer.echo(
|
|
631
|
+
"External class already exists, skipped (--if-not-exists).",
|
|
632
|
+
err=True,
|
|
633
|
+
)
|
|
634
|
+
else:
|
|
635
|
+
raise
|
|
636
|
+
except DWFError as exc:
|
|
637
|
+
handle_error(exc)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@app.command()
|
|
641
|
+
def update(
|
|
642
|
+
ctx: typer.Context,
|
|
643
|
+
data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
|
|
644
|
+
file: Annotated[
|
|
645
|
+
Path | None, typer.Option("--file", help="Path to JSON data file")
|
|
646
|
+
] = None,
|
|
647
|
+
model_type: Annotated[
|
|
648
|
+
ModelType,
|
|
649
|
+
typer.Option(
|
|
650
|
+
"--type",
|
|
651
|
+
"-t",
|
|
652
|
+
help="Update type: entity(e), relation(r), external(x)",
|
|
653
|
+
case_sensitive=False,
|
|
654
|
+
),
|
|
655
|
+
] = ModelType.entity,
|
|
656
|
+
external_mode: Annotated[
|
|
657
|
+
ExternalMode,
|
|
658
|
+
typer.Option(
|
|
659
|
+
"--external-mode",
|
|
660
|
+
help="External update mode: view (default) or sql",
|
|
661
|
+
case_sensitive=False,
|
|
662
|
+
),
|
|
663
|
+
] = ExternalMode.view,
|
|
664
|
+
validate: Annotated[
|
|
665
|
+
bool,
|
|
666
|
+
typer.Option(
|
|
667
|
+
"--validate", help="Validate input against schema before executing"
|
|
668
|
+
),
|
|
669
|
+
] = False,
|
|
670
|
+
dry_run: Annotated[
|
|
671
|
+
bool,
|
|
672
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
673
|
+
] = False,
|
|
674
|
+
fmt: Annotated[
|
|
675
|
+
OutputFormat,
|
|
676
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
677
|
+
] = OutputFormat.table,
|
|
678
|
+
) -> None:
|
|
679
|
+
try:
|
|
680
|
+
raw_payload = _read_payload(data, file)
|
|
681
|
+
|
|
682
|
+
resolved_type = model_type.value
|
|
683
|
+
if resolved_type in ("e",):
|
|
684
|
+
resolved_type = "entity"
|
|
685
|
+
elif resolved_type in ("r",):
|
|
686
|
+
resolved_type = "relation"
|
|
687
|
+
elif resolved_type in ("x",):
|
|
688
|
+
resolved_type = "external"
|
|
689
|
+
|
|
690
|
+
if resolved_type == "external":
|
|
691
|
+
if external_mode == ExternalMode.view:
|
|
692
|
+
if validate:
|
|
693
|
+
_do_validate("datamodel update", raw_payload)
|
|
694
|
+
|
|
695
|
+
if dry_run:
|
|
696
|
+
typer.echo(
|
|
697
|
+
json_mod.dumps(
|
|
698
|
+
{
|
|
699
|
+
"action": "update",
|
|
700
|
+
"resource": "external-class",
|
|
701
|
+
"external_mode": "view",
|
|
702
|
+
"data": raw_payload,
|
|
703
|
+
},
|
|
704
|
+
ensure_ascii=False,
|
|
705
|
+
indent=2,
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
709
|
+
|
|
710
|
+
client = get_client(ctx)
|
|
711
|
+
result = datamodel_api.update_entity(client, raw_payload)
|
|
712
|
+
if fmt == OutputFormat.json:
|
|
713
|
+
typer.echo(
|
|
714
|
+
json_mod.dumps(
|
|
715
|
+
{
|
|
716
|
+
"success": True,
|
|
717
|
+
"action": "update",
|
|
718
|
+
"resource": "external-class",
|
|
719
|
+
"external_mode": "view",
|
|
720
|
+
"data": result,
|
|
721
|
+
},
|
|
722
|
+
ensure_ascii=False,
|
|
723
|
+
indent=2,
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
else:
|
|
727
|
+
typer.echo("Updated.", err=True)
|
|
728
|
+
else:
|
|
729
|
+
if not isinstance(raw_payload, dict):
|
|
730
|
+
raise DWFError(
|
|
731
|
+
"External SQL mode requires a JSON object with 'classList' and optional 'customSqlMap'"
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
class_list = raw_payload.get("classList", [])
|
|
735
|
+
sql_map = raw_payload.get("customSqlMap")
|
|
736
|
+
|
|
737
|
+
if validate:
|
|
738
|
+
_do_validate("datamodel update --type external", raw_payload)
|
|
739
|
+
|
|
740
|
+
if dry_run:
|
|
741
|
+
typer.echo(
|
|
742
|
+
json_mod.dumps(
|
|
743
|
+
{
|
|
744
|
+
"action": "update",
|
|
745
|
+
"resource": "external-class",
|
|
746
|
+
"external_mode": "sql",
|
|
747
|
+
"classList": class_list,
|
|
748
|
+
"customSqlMap": sql_map,
|
|
749
|
+
},
|
|
750
|
+
ensure_ascii=False,
|
|
751
|
+
indent=2,
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
755
|
+
|
|
756
|
+
client = get_client(ctx)
|
|
757
|
+
result = datamodel_api.update_external_class(
|
|
758
|
+
client, class_list, custom_sql_map=sql_map
|
|
759
|
+
)
|
|
760
|
+
if fmt == OutputFormat.json:
|
|
761
|
+
typer.echo(
|
|
762
|
+
json_mod.dumps(
|
|
763
|
+
{
|
|
764
|
+
"success": True,
|
|
765
|
+
"action": "update",
|
|
766
|
+
"resource": "external-class",
|
|
767
|
+
"external_mode": "sql",
|
|
768
|
+
"data": result,
|
|
769
|
+
},
|
|
770
|
+
ensure_ascii=False,
|
|
771
|
+
indent=2,
|
|
772
|
+
)
|
|
773
|
+
)
|
|
774
|
+
else:
|
|
775
|
+
typer.echo(f"Updated: {result}", err=True)
|
|
776
|
+
else:
|
|
777
|
+
schema_key = (
|
|
778
|
+
"datamodel update"
|
|
779
|
+
if resolved_type == "entity"
|
|
780
|
+
else "datamodel relation update"
|
|
781
|
+
)
|
|
782
|
+
resource = "class" if resolved_type == "entity" else "relation"
|
|
783
|
+
|
|
784
|
+
if validate:
|
|
785
|
+
_do_validate(schema_key, raw_payload)
|
|
786
|
+
|
|
787
|
+
if dry_run:
|
|
788
|
+
typer.echo(
|
|
789
|
+
json_mod.dumps(
|
|
790
|
+
{
|
|
791
|
+
"action": "update",
|
|
792
|
+
"resource": resource,
|
|
793
|
+
"data": raw_payload,
|
|
794
|
+
"reversible": True,
|
|
795
|
+
},
|
|
796
|
+
ensure_ascii=False,
|
|
797
|
+
indent=2,
|
|
798
|
+
)
|
|
799
|
+
)
|
|
800
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
801
|
+
|
|
802
|
+
client = get_client(ctx)
|
|
803
|
+
if resolved_type == "relation":
|
|
804
|
+
result = datamodel_api.update_relation(client, raw_payload)
|
|
805
|
+
else:
|
|
806
|
+
result = datamodel_api.update_entity(client, raw_payload)
|
|
807
|
+
if fmt == OutputFormat.json:
|
|
808
|
+
typer.echo(
|
|
809
|
+
json_mod.dumps(
|
|
810
|
+
{
|
|
811
|
+
"success": True,
|
|
812
|
+
"action": "update",
|
|
813
|
+
"resource": resolved_type,
|
|
814
|
+
"data": result,
|
|
815
|
+
},
|
|
816
|
+
ensure_ascii=False,
|
|
817
|
+
indent=2,
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
else:
|
|
821
|
+
typer.echo("Updated.", err=True)
|
|
822
|
+
except DWFError as exc:
|
|
823
|
+
handle_error(exc)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@app.command()
|
|
827
|
+
def delete(
|
|
828
|
+
ctx: typer.Context,
|
|
829
|
+
class_name: Annotated[str, typer.Argument(help="Class name to delete")],
|
|
830
|
+
cascade: Annotated[
|
|
831
|
+
bool,
|
|
832
|
+
typer.Option("--cascade", help="Cascade delete relations, operations, views"),
|
|
833
|
+
] = False,
|
|
834
|
+
dry_run: Annotated[
|
|
835
|
+
bool,
|
|
836
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
837
|
+
] = False,
|
|
838
|
+
yes: Annotated[
|
|
839
|
+
bool,
|
|
840
|
+
typer.Option("--force", help="Skip confirmation prompt"),
|
|
841
|
+
] = False,
|
|
842
|
+
fmt: Annotated[
|
|
843
|
+
OutputFormat,
|
|
844
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
845
|
+
] = OutputFormat.table,
|
|
846
|
+
) -> None:
|
|
847
|
+
try:
|
|
848
|
+
if dry_run:
|
|
849
|
+
typer.echo(
|
|
850
|
+
json_mod.dumps(
|
|
851
|
+
{
|
|
852
|
+
"action": "delete",
|
|
853
|
+
"resource": "class",
|
|
854
|
+
"target": class_name,
|
|
855
|
+
"cascade": cascade,
|
|
856
|
+
"reversible": False,
|
|
857
|
+
},
|
|
858
|
+
ensure_ascii=False,
|
|
859
|
+
indent=2,
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
863
|
+
|
|
864
|
+
if is_tty() and not yes:
|
|
865
|
+
confirm = typer.confirm(f"Delete class '{class_name}'?", default=False)
|
|
866
|
+
if not confirm:
|
|
867
|
+
typer.echo("Cancelled.", err=True)
|
|
868
|
+
raise typer.Exit(code=0)
|
|
869
|
+
|
|
870
|
+
client = get_client(ctx)
|
|
871
|
+
result = datamodel_api.delete_class(client, class_name, cascade=cascade)
|
|
872
|
+
if fmt == OutputFormat.json:
|
|
873
|
+
typer.echo(
|
|
874
|
+
json_mod.dumps(
|
|
875
|
+
{
|
|
876
|
+
"success": True,
|
|
877
|
+
"action": "delete",
|
|
878
|
+
"resource": "class",
|
|
879
|
+
"target": class_name,
|
|
880
|
+
"data": result,
|
|
881
|
+
},
|
|
882
|
+
ensure_ascii=False,
|
|
883
|
+
indent=2,
|
|
884
|
+
)
|
|
885
|
+
)
|
|
886
|
+
else:
|
|
887
|
+
typer.echo(f"Deleted: {class_name}", err=True)
|
|
888
|
+
except DWFError as exc:
|
|
889
|
+
handle_error(exc)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
@_attribute_sub.command(name="list")
|
|
893
|
+
def list_attributes(
|
|
894
|
+
ctx: typer.Context,
|
|
895
|
+
keyword: Annotated[
|
|
896
|
+
str | None,
|
|
897
|
+
typer.Option("--keyword", "-k", help="Search keyword"),
|
|
898
|
+
] = None,
|
|
899
|
+
fmt: Annotated[
|
|
900
|
+
OutputFormat,
|
|
901
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
902
|
+
] = OutputFormat.table,
|
|
903
|
+
) -> None:
|
|
904
|
+
try:
|
|
905
|
+
client = get_client(ctx)
|
|
906
|
+
result = datamodel_api.list_attributes(client, keyword=keyword)
|
|
907
|
+
|
|
908
|
+
if isinstance(result, dict):
|
|
909
|
+
payload = result.get("data", result)
|
|
910
|
+
items = payload if isinstance(payload, list) else [payload]
|
|
911
|
+
elif isinstance(result, list):
|
|
912
|
+
items = result
|
|
913
|
+
else:
|
|
914
|
+
items = []
|
|
915
|
+
|
|
916
|
+
if not items:
|
|
917
|
+
_empty_ok(ctx, "No attributes found.")
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
fmt = resolve_format(fmt)
|
|
921
|
+
|
|
922
|
+
if fmt == OutputFormat.json:
|
|
923
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
_ATTR_COLUMNS: list[tuple[str, str]] = [
|
|
927
|
+
("attributeName", "Name"),
|
|
928
|
+
("displayName", "DisplayName"),
|
|
929
|
+
("valueType", "DataType"),
|
|
930
|
+
("valueLength", "Length"),
|
|
931
|
+
("nullable", "Nullable"),
|
|
932
|
+
]
|
|
933
|
+
table = Table(
|
|
934
|
+
title="All Attributes",
|
|
935
|
+
show_header=True,
|
|
936
|
+
header_style="bold cyan",
|
|
937
|
+
show_lines=False,
|
|
938
|
+
)
|
|
939
|
+
table.add_column("#", style="dim", width=4)
|
|
940
|
+
for _, header in _ATTR_COLUMNS:
|
|
941
|
+
table.add_column(header)
|
|
942
|
+
for idx, item in enumerate(items, 1):
|
|
943
|
+
row: list[str] = [str(idx)]
|
|
944
|
+
for key, _ in _ATTR_COLUMNS:
|
|
945
|
+
val = item.get(key, "")
|
|
946
|
+
if key == "nullable":
|
|
947
|
+
val = "[green]Yes[/green]" if val else "[dim]No[/dim]"
|
|
948
|
+
elif key == "valueLength":
|
|
949
|
+
val = str(val) if val else "-"
|
|
950
|
+
else:
|
|
951
|
+
val = str(val)
|
|
952
|
+
row.append(val)
|
|
953
|
+
table.add_row(*row)
|
|
954
|
+
get_console().print(table)
|
|
955
|
+
get_console().print(f"[dim]Total {len(items)} attributes[/dim]")
|
|
956
|
+
except DWFError as exc:
|
|
957
|
+
handle_error(exc)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
# ── Object CRUD subgroup ──────────────────────────────────────────
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@_object_sub.command(name="list")
|
|
964
|
+
def list_objects(
|
|
965
|
+
ctx: typer.Context,
|
|
966
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
967
|
+
page: Annotated[int, typer.Option("--page", "-p", help="Page index (0-based)")] = 0,
|
|
968
|
+
page_size: Annotated[
|
|
969
|
+
int, typer.Option("--page-size", "-s", help="Items per page")
|
|
970
|
+
] = 25,
|
|
971
|
+
page_all: Annotated[
|
|
972
|
+
bool,
|
|
973
|
+
typer.Option("--page-all", help="Fetch all pages automatically"),
|
|
974
|
+
] = False,
|
|
975
|
+
condition: Annotated[
|
|
976
|
+
str | None,
|
|
977
|
+
typer.Option("--condition", help="SQL WHERE clause (simple mode only)"),
|
|
978
|
+
] = None,
|
|
979
|
+
data: Annotated[
|
|
980
|
+
str, typer.Option("--data", "-d", help="Full QueryObjReq JSON (advanced mode)")
|
|
981
|
+
] = "",
|
|
982
|
+
file: Annotated[
|
|
983
|
+
Path | None,
|
|
984
|
+
typer.Option("--file", help="Path to QueryObjReq JSON file (advanced mode)"),
|
|
985
|
+
] = None,
|
|
986
|
+
fmt: Annotated[
|
|
987
|
+
OutputFormat,
|
|
988
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
989
|
+
] = OutputFormat.table,
|
|
990
|
+
) -> None:
|
|
991
|
+
try:
|
|
992
|
+
if (data or file) and condition:
|
|
993
|
+
raise DWFError("--condition is incompatible with --data/--file")
|
|
994
|
+
|
|
995
|
+
client = get_client(ctx)
|
|
996
|
+
|
|
997
|
+
if data or file:
|
|
998
|
+
body = _read_payload(data, file)
|
|
999
|
+
if not isinstance(body, dict):
|
|
1000
|
+
raise DWFError("--data/--file must contain a JSON object")
|
|
1001
|
+
|
|
1002
|
+
if page_all:
|
|
1003
|
+
all_items: list[dict[str, Any]] = []
|
|
1004
|
+
page_idx = 0
|
|
1005
|
+
while True:
|
|
1006
|
+
r = datamodel_api.query_objects(
|
|
1007
|
+
client,
|
|
1008
|
+
class_name,
|
|
1009
|
+
body=dict(body),
|
|
1010
|
+
page=page_idx,
|
|
1011
|
+
page_size=page_size,
|
|
1012
|
+
)
|
|
1013
|
+
p_items, p_info = _extract_response(r)
|
|
1014
|
+
all_items.extend(p_items)
|
|
1015
|
+
total = p_info.get("totalCount", 0) if p_info else 0
|
|
1016
|
+
if not p_items or (total > 0 and len(all_items) >= total):
|
|
1017
|
+
break
|
|
1018
|
+
page_idx += 1
|
|
1019
|
+
result = {
|
|
1020
|
+
"data": {
|
|
1021
|
+
"data": all_items,
|
|
1022
|
+
"pageInfo": {"totalCount": len(all_items)},
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
else:
|
|
1026
|
+
result = datamodel_api.query_objects(
|
|
1027
|
+
client,
|
|
1028
|
+
class_name,
|
|
1029
|
+
body=dict(body),
|
|
1030
|
+
page=page,
|
|
1031
|
+
page_size=page_size,
|
|
1032
|
+
)
|
|
1033
|
+
else:
|
|
1034
|
+
if page_all:
|
|
1035
|
+
all_items = []
|
|
1036
|
+
page_idx = 0
|
|
1037
|
+
while True:
|
|
1038
|
+
r = datamodel_api.list_objects(
|
|
1039
|
+
client,
|
|
1040
|
+
class_name,
|
|
1041
|
+
page=page_idx,
|
|
1042
|
+
page_size=page_size,
|
|
1043
|
+
condition=condition,
|
|
1044
|
+
)
|
|
1045
|
+
p_items, p_info = _extract_response(r)
|
|
1046
|
+
all_items.extend(p_items)
|
|
1047
|
+
total = p_info.get("totalCount", 0) if p_info else 0
|
|
1048
|
+
if not p_items or (total > 0 and len(all_items) >= total):
|
|
1049
|
+
break
|
|
1050
|
+
page_idx += 1
|
|
1051
|
+
result = {
|
|
1052
|
+
"data": {
|
|
1053
|
+
"data": all_items,
|
|
1054
|
+
"pageInfo": {"totalCount": len(all_items)},
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
else:
|
|
1058
|
+
result = datamodel_api.list_objects(
|
|
1059
|
+
client,
|
|
1060
|
+
class_name,
|
|
1061
|
+
page=page,
|
|
1062
|
+
page_size=page_size,
|
|
1063
|
+
condition=condition,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
items, page_info = _extract_response(result)
|
|
1067
|
+
|
|
1068
|
+
if not items:
|
|
1069
|
+
_empty_ok(ctx, f"No objects found for '{class_name}'.")
|
|
1070
|
+
return
|
|
1071
|
+
|
|
1072
|
+
fmt = resolve_format(fmt)
|
|
1073
|
+
|
|
1074
|
+
if fmt == OutputFormat.json:
|
|
1075
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
1076
|
+
return
|
|
1077
|
+
|
|
1078
|
+
if items and not isinstance(items[0], dict):
|
|
1079
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
1080
|
+
return
|
|
1081
|
+
|
|
1082
|
+
cols = list(items[0].keys()) if items else []
|
|
1083
|
+
col_tuples = [(c, c) for c in cols]
|
|
1084
|
+
_print_table(items, page_info, get_console(), columns=col_tuples)
|
|
1085
|
+
except DWFError as exc:
|
|
1086
|
+
handle_error(exc)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@_object_sub.command(name="get")
|
|
1090
|
+
def get_object(
|
|
1091
|
+
ctx: typer.Context,
|
|
1092
|
+
oid: Annotated[str, typer.Argument(help="Object OID")],
|
|
1093
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
1094
|
+
fmt: Annotated[
|
|
1095
|
+
OutputFormat,
|
|
1096
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
1097
|
+
] = OutputFormat.table,
|
|
1098
|
+
) -> None:
|
|
1099
|
+
try:
|
|
1100
|
+
client = get_client(ctx)
|
|
1101
|
+
result = datamodel_api.get_object(client, class_name, oid)
|
|
1102
|
+
|
|
1103
|
+
fmt = resolve_format(fmt)
|
|
1104
|
+
|
|
1105
|
+
if fmt == OutputFormat.json:
|
|
1106
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
console = get_console()
|
|
1110
|
+
data = result.get("data", result) if isinstance(result, dict) else result
|
|
1111
|
+
if isinstance(data, dict):
|
|
1112
|
+
for key, value in data.items():
|
|
1113
|
+
console.print(f"[bold]{key}[/bold]: {value}")
|
|
1114
|
+
except DWFError as exc:
|
|
1115
|
+
handle_error(exc)
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
@_object_sub.command(name="create")
|
|
1119
|
+
def create_objects(
|
|
1120
|
+
ctx: typer.Context,
|
|
1121
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
1122
|
+
data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
|
|
1123
|
+
file: Annotated[
|
|
1124
|
+
Path | None, typer.Option("--file", help="Path to JSON data file")
|
|
1125
|
+
] = None,
|
|
1126
|
+
if_not_exists: Annotated[
|
|
1127
|
+
bool,
|
|
1128
|
+
typer.Option("--if-not-exists", help="Skip if object already exists"),
|
|
1129
|
+
] = False,
|
|
1130
|
+
validate: Annotated[
|
|
1131
|
+
bool,
|
|
1132
|
+
typer.Option(
|
|
1133
|
+
"--validate", help="Validate input against schema before executing"
|
|
1134
|
+
),
|
|
1135
|
+
] = False,
|
|
1136
|
+
dry_run: Annotated[
|
|
1137
|
+
bool,
|
|
1138
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1139
|
+
] = False,
|
|
1140
|
+
fmt: Annotated[
|
|
1141
|
+
OutputFormat,
|
|
1142
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
1143
|
+
] = OutputFormat.table,
|
|
1144
|
+
) -> None:
|
|
1145
|
+
try:
|
|
1146
|
+
payload = _read_payload(data, file)
|
|
1147
|
+
if isinstance(payload, dict):
|
|
1148
|
+
payload = [payload]
|
|
1149
|
+
|
|
1150
|
+
if validate:
|
|
1151
|
+
_do_validate("datamodel object create", payload)
|
|
1152
|
+
|
|
1153
|
+
if dry_run:
|
|
1154
|
+
typer.echo(
|
|
1155
|
+
json_mod.dumps(
|
|
1156
|
+
{
|
|
1157
|
+
"action": "create",
|
|
1158
|
+
"resource": class_name,
|
|
1159
|
+
"data": payload,
|
|
1160
|
+
"if_not_exists": if_not_exists,
|
|
1161
|
+
"reversible": False,
|
|
1162
|
+
},
|
|
1163
|
+
ensure_ascii=False,
|
|
1164
|
+
indent=2,
|
|
1165
|
+
)
|
|
1166
|
+
)
|
|
1167
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1168
|
+
|
|
1169
|
+
client = get_client(ctx)
|
|
1170
|
+
try:
|
|
1171
|
+
result = datamodel_api.create_objects(client, class_name, payload)
|
|
1172
|
+
if fmt == OutputFormat.json:
|
|
1173
|
+
typer.echo(
|
|
1174
|
+
json_mod.dumps(
|
|
1175
|
+
{
|
|
1176
|
+
"success": True,
|
|
1177
|
+
"action": "create",
|
|
1178
|
+
"resource": class_name,
|
|
1179
|
+
"count": len(payload),
|
|
1180
|
+
"data": result,
|
|
1181
|
+
},
|
|
1182
|
+
ensure_ascii=False,
|
|
1183
|
+
indent=2,
|
|
1184
|
+
)
|
|
1185
|
+
)
|
|
1186
|
+
else:
|
|
1187
|
+
typer.echo(f"Created {len(payload)} object(s).", err=True)
|
|
1188
|
+
except ConflictError:
|
|
1189
|
+
if if_not_exists:
|
|
1190
|
+
typer.echo(
|
|
1191
|
+
"Object already exists, skipped (--if-not-exists).", err=True
|
|
1192
|
+
)
|
|
1193
|
+
else:
|
|
1194
|
+
raise
|
|
1195
|
+
except DWFError as exc:
|
|
1196
|
+
handle_error(exc)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
@_object_sub.command(name="update")
|
|
1200
|
+
def update_objects(
|
|
1201
|
+
ctx: typer.Context,
|
|
1202
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
1203
|
+
data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
|
|
1204
|
+
file: Annotated[
|
|
1205
|
+
Path | None, typer.Option("--file", help="Path to JSON data file")
|
|
1206
|
+
] = None,
|
|
1207
|
+
force_update: Annotated[
|
|
1208
|
+
bool,
|
|
1209
|
+
typer.Option("--force-update", help="Force update"),
|
|
1210
|
+
] = False,
|
|
1211
|
+
validate: Annotated[
|
|
1212
|
+
bool,
|
|
1213
|
+
typer.Option(
|
|
1214
|
+
"--validate", help="Validate input against schema before executing"
|
|
1215
|
+
),
|
|
1216
|
+
] = False,
|
|
1217
|
+
dry_run: Annotated[
|
|
1218
|
+
bool,
|
|
1219
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1220
|
+
] = False,
|
|
1221
|
+
fmt: Annotated[
|
|
1222
|
+
OutputFormat,
|
|
1223
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
1224
|
+
] = OutputFormat.table,
|
|
1225
|
+
) -> None:
|
|
1226
|
+
try:
|
|
1227
|
+
payload = _read_payload(data, file)
|
|
1228
|
+
if isinstance(payload, dict):
|
|
1229
|
+
payload = [payload]
|
|
1230
|
+
|
|
1231
|
+
if validate:
|
|
1232
|
+
_do_validate("datamodel object update", payload)
|
|
1233
|
+
|
|
1234
|
+
if dry_run:
|
|
1235
|
+
typer.echo(
|
|
1236
|
+
json_mod.dumps(
|
|
1237
|
+
{
|
|
1238
|
+
"action": "update",
|
|
1239
|
+
"resource": class_name,
|
|
1240
|
+
"data": payload,
|
|
1241
|
+
"reversible": True,
|
|
1242
|
+
},
|
|
1243
|
+
ensure_ascii=False,
|
|
1244
|
+
indent=2,
|
|
1245
|
+
)
|
|
1246
|
+
)
|
|
1247
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1248
|
+
|
|
1249
|
+
client = get_client(ctx)
|
|
1250
|
+
result = datamodel_api.update_objects(
|
|
1251
|
+
client, class_name, payload, force_update=force_update
|
|
1252
|
+
)
|
|
1253
|
+
if fmt == OutputFormat.json:
|
|
1254
|
+
typer.echo(
|
|
1255
|
+
json_mod.dumps(
|
|
1256
|
+
{
|
|
1257
|
+
"success": True,
|
|
1258
|
+
"action": "update",
|
|
1259
|
+
"resource": class_name,
|
|
1260
|
+
"count": len(payload),
|
|
1261
|
+
"data": result,
|
|
1262
|
+
},
|
|
1263
|
+
ensure_ascii=False,
|
|
1264
|
+
indent=2,
|
|
1265
|
+
)
|
|
1266
|
+
)
|
|
1267
|
+
else:
|
|
1268
|
+
typer.echo(f"Updated {len(payload)} object(s).", err=True)
|
|
1269
|
+
except DWFError as exc:
|
|
1270
|
+
handle_error(exc)
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
@_object_sub.command(name="delete")
|
|
1274
|
+
def delete_objects(
|
|
1275
|
+
ctx: typer.Context,
|
|
1276
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
1277
|
+
oids: Annotated[
|
|
1278
|
+
str | None,
|
|
1279
|
+
typer.Option("--oids", help="Comma-separated OID list"),
|
|
1280
|
+
] = None,
|
|
1281
|
+
file: Annotated[
|
|
1282
|
+
Path | None,
|
|
1283
|
+
typer.Option("--file", help="Path to JSON file containing OID array"),
|
|
1284
|
+
] = None,
|
|
1285
|
+
dry_run: Annotated[
|
|
1286
|
+
bool,
|
|
1287
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1288
|
+
] = False,
|
|
1289
|
+
yes: Annotated[
|
|
1290
|
+
bool,
|
|
1291
|
+
typer.Option("--force", help="Skip confirmation prompt"),
|
|
1292
|
+
] = False,
|
|
1293
|
+
fmt: Annotated[
|
|
1294
|
+
OutputFormat,
|
|
1295
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
1296
|
+
] = OutputFormat.table,
|
|
1297
|
+
) -> None:
|
|
1298
|
+
try:
|
|
1299
|
+
oid_list: list[str] = []
|
|
1300
|
+
if file:
|
|
1301
|
+
file = validate_path(file)
|
|
1302
|
+
oid_list = json_mod.loads(file.read_text(encoding="utf-8"))
|
|
1303
|
+
elif oids:
|
|
1304
|
+
oid_list = [o.strip() for o in oids.split(",") if o.strip()]
|
|
1305
|
+
else:
|
|
1306
|
+
typer.echo("Error: provide --oids or --file", err=True)
|
|
1307
|
+
raise typer.Exit(code=1)
|
|
1308
|
+
|
|
1309
|
+
if dry_run:
|
|
1310
|
+
typer.echo(
|
|
1311
|
+
json_mod.dumps(
|
|
1312
|
+
{
|
|
1313
|
+
"action": "delete",
|
|
1314
|
+
"resource": class_name,
|
|
1315
|
+
"targets": oid_list,
|
|
1316
|
+
"reversible": False,
|
|
1317
|
+
},
|
|
1318
|
+
ensure_ascii=False,
|
|
1319
|
+
indent=2,
|
|
1320
|
+
)
|
|
1321
|
+
)
|
|
1322
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1323
|
+
|
|
1324
|
+
if is_tty() and not yes:
|
|
1325
|
+
confirm = typer.confirm(
|
|
1326
|
+
f"Delete {len(oid_list)} object(s) from '{class_name}'?",
|
|
1327
|
+
default=False,
|
|
1328
|
+
)
|
|
1329
|
+
if not confirm:
|
|
1330
|
+
typer.echo("Cancelled.", err=True)
|
|
1331
|
+
raise typer.Exit(code=0)
|
|
1332
|
+
|
|
1333
|
+
client = get_client(ctx)
|
|
1334
|
+
result = datamodel_api.delete_objects(client, class_name, oid_list)
|
|
1335
|
+
if fmt == OutputFormat.json:
|
|
1336
|
+
typer.echo(
|
|
1337
|
+
json_mod.dumps(
|
|
1338
|
+
{
|
|
1339
|
+
"success": True,
|
|
1340
|
+
"action": "delete",
|
|
1341
|
+
"resource": class_name,
|
|
1342
|
+
"count": len(oid_list),
|
|
1343
|
+
"data": result,
|
|
1344
|
+
},
|
|
1345
|
+
ensure_ascii=False,
|
|
1346
|
+
indent=2,
|
|
1347
|
+
)
|
|
1348
|
+
)
|
|
1349
|
+
else:
|
|
1350
|
+
typer.echo(f"Deleted {len(oid_list)} object(s).", err=True)
|
|
1351
|
+
except DWFError as exc:
|
|
1352
|
+
handle_error(exc)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
@_object_sub.command(name="count")
|
|
1356
|
+
def count_objects(
|
|
1357
|
+
ctx: typer.Context,
|
|
1358
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
1359
|
+
condition: Annotated[
|
|
1360
|
+
str | None,
|
|
1361
|
+
typer.Option("--condition", help="SQL WHERE clause, e.g. \"and name='test'\""),
|
|
1362
|
+
] = None,
|
|
1363
|
+
fmt: Annotated[
|
|
1364
|
+
OutputFormat,
|
|
1365
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
1366
|
+
] = OutputFormat.table,
|
|
1367
|
+
) -> None:
|
|
1368
|
+
try:
|
|
1369
|
+
client = get_client(ctx)
|
|
1370
|
+
result = datamodel_api.count_objects(client, class_name, condition=condition)
|
|
1371
|
+
|
|
1372
|
+
fmt = resolve_format(fmt)
|
|
1373
|
+
|
|
1374
|
+
if fmt == OutputFormat.json:
|
|
1375
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
1376
|
+
return
|
|
1377
|
+
|
|
1378
|
+
data = result.get("data", result) if isinstance(result, dict) else result
|
|
1379
|
+
get_console().print(f"Count for '{class_name}': {data}")
|
|
1380
|
+
except DWFError as exc:
|
|
1381
|
+
handle_error(exc)
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
# ── Import / Export ──────────────────────────────────────────────
|
|
1385
|
+
|
|
1386
|
+
|
|
1387
|
+
@app.command(name="import")
|
|
1388
|
+
def import_data_cmd(
|
|
1389
|
+
ctx: typer.Context,
|
|
1390
|
+
file: Annotated[Path, typer.Argument(help="Path to data file to import")],
|
|
1391
|
+
update_if_exist: Annotated[
|
|
1392
|
+
bool,
|
|
1393
|
+
typer.Option("--update-if-exist", help="Update existing records on conflict"),
|
|
1394
|
+
] = False,
|
|
1395
|
+
dry_run: Annotated[
|
|
1396
|
+
bool,
|
|
1397
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1398
|
+
] = False,
|
|
1399
|
+
) -> None:
|
|
1400
|
+
try:
|
|
1401
|
+
file = validate_path(file)
|
|
1402
|
+
if not file.exists():
|
|
1403
|
+
raise DWFError(f"File not found: {file}")
|
|
1404
|
+
|
|
1405
|
+
if dry_run:
|
|
1406
|
+
typer.echo(
|
|
1407
|
+
json_mod.dumps(
|
|
1408
|
+
{
|
|
1409
|
+
"action": "import",
|
|
1410
|
+
"file": str(file),
|
|
1411
|
+
"update_if_exist": update_if_exist,
|
|
1412
|
+
},
|
|
1413
|
+
ensure_ascii=False,
|
|
1414
|
+
indent=2,
|
|
1415
|
+
)
|
|
1416
|
+
)
|
|
1417
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1418
|
+
|
|
1419
|
+
client = get_client(ctx)
|
|
1420
|
+
uuid_result = datamodel_api.get_import_uuid(
|
|
1421
|
+
client, update_if_exist=update_if_exist
|
|
1422
|
+
)
|
|
1423
|
+
uuid_val = (
|
|
1424
|
+
uuid_result.get("data", uuid_result)
|
|
1425
|
+
if isinstance(uuid_result, dict)
|
|
1426
|
+
else uuid_result
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
result = datamodel_api.import_data(client, str(uuid_val), "", str(file))
|
|
1430
|
+
typer.echo(f"Import started: {result}", err=True)
|
|
1431
|
+
except DWFError as exc:
|
|
1432
|
+
handle_error(exc)
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
@app.command(name="export")
|
|
1436
|
+
def export_data_cmd(
|
|
1437
|
+
ctx: typer.Context,
|
|
1438
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
1439
|
+
attributes: Annotated[
|
|
1440
|
+
str,
|
|
1441
|
+
typer.Option("--attributes", "-a", help="Comma-separated attribute names"),
|
|
1442
|
+
],
|
|
1443
|
+
output: Annotated[
|
|
1444
|
+
Path | None,
|
|
1445
|
+
typer.Option("--output", "-o", help="Output file path (default: stdout)"),
|
|
1446
|
+
] = None,
|
|
1447
|
+
condition: Annotated[
|
|
1448
|
+
str | None,
|
|
1449
|
+
typer.Option("--condition", help="SQL WHERE condition"),
|
|
1450
|
+
] = None,
|
|
1451
|
+
join_to_one: Annotated[
|
|
1452
|
+
bool,
|
|
1453
|
+
typer.Option("--join-to-one", help="Merge into one sheet (relation classes)"),
|
|
1454
|
+
] = False,
|
|
1455
|
+
title_only: Annotated[
|
|
1456
|
+
bool,
|
|
1457
|
+
typer.Option("--title-only", help="Export headers only"),
|
|
1458
|
+
] = False,
|
|
1459
|
+
dry_run: Annotated[
|
|
1460
|
+
bool,
|
|
1461
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1462
|
+
] = False,
|
|
1463
|
+
) -> None:
|
|
1464
|
+
try:
|
|
1465
|
+
attr_list = [a.strip() for a in attributes.split(",") if a.strip()]
|
|
1466
|
+
if not attr_list:
|
|
1467
|
+
raise DWFError("--attributes cannot be empty")
|
|
1468
|
+
|
|
1469
|
+
if dry_run:
|
|
1470
|
+
typer.echo(
|
|
1471
|
+
json_mod.dumps(
|
|
1472
|
+
{
|
|
1473
|
+
"action": "export",
|
|
1474
|
+
"resource": class_name,
|
|
1475
|
+
"attributes": attr_list,
|
|
1476
|
+
"condition": condition,
|
|
1477
|
+
"join_to_one": join_to_one,
|
|
1478
|
+
"title_only": title_only,
|
|
1479
|
+
},
|
|
1480
|
+
ensure_ascii=False,
|
|
1481
|
+
indent=2,
|
|
1482
|
+
)
|
|
1483
|
+
)
|
|
1484
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1485
|
+
|
|
1486
|
+
client = get_client(ctx)
|
|
1487
|
+
response = datamodel_api.export_data(
|
|
1488
|
+
client,
|
|
1489
|
+
class_name,
|
|
1490
|
+
attr_list,
|
|
1491
|
+
condition=condition,
|
|
1492
|
+
join_to_one=join_to_one,
|
|
1493
|
+
title_only=title_only,
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
if output:
|
|
1497
|
+
output = validate_path(output)
|
|
1498
|
+
output.write_bytes(response.content)
|
|
1499
|
+
typer.echo(f"Exported to {output}", err=True)
|
|
1500
|
+
else:
|
|
1501
|
+
typer.echo(response.content, nl=False)
|
|
1502
|
+
except DWFError as exc:
|
|
1503
|
+
handle_error(exc)
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
# ── Excel Quick Start ────────────────────────────────────────────
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
@app.command(name="excel-upload")
|
|
1510
|
+
def excel_upload_cmd(
|
|
1511
|
+
ctx: typer.Context,
|
|
1512
|
+
file: Annotated[Path, typer.Argument(help="Path to Excel file")],
|
|
1513
|
+
dry_run: Annotated[
|
|
1514
|
+
bool,
|
|
1515
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1516
|
+
] = False,
|
|
1517
|
+
) -> None:
|
|
1518
|
+
try:
|
|
1519
|
+
file = validate_path(file)
|
|
1520
|
+
if not file.exists():
|
|
1521
|
+
raise DWFError(f"File not found: {file}")
|
|
1522
|
+
|
|
1523
|
+
if dry_run:
|
|
1524
|
+
typer.echo(
|
|
1525
|
+
json_mod.dumps(
|
|
1526
|
+
{"action": "excel-upload", "file": str(file)},
|
|
1527
|
+
ensure_ascii=False,
|
|
1528
|
+
indent=2,
|
|
1529
|
+
)
|
|
1530
|
+
)
|
|
1531
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1532
|
+
|
|
1533
|
+
client = get_client(ctx)
|
|
1534
|
+
result = datamodel_api.upload_excel(client, str(file))
|
|
1535
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
1536
|
+
except DWFError as exc:
|
|
1537
|
+
handle_error(exc)
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
@app.command(name="excel-confirm")
|
|
1541
|
+
def excel_confirm_cmd(
|
|
1542
|
+
ctx: typer.Context,
|
|
1543
|
+
data: Annotated[
|
|
1544
|
+
str, typer.Option("--data", "-d", help="JSON from excel-upload output")
|
|
1545
|
+
] = "",
|
|
1546
|
+
file: Annotated[
|
|
1547
|
+
Path | None,
|
|
1548
|
+
typer.Option("--file", help="Path to JSON file from excel-upload output"),
|
|
1549
|
+
] = None,
|
|
1550
|
+
skip_validate: Annotated[
|
|
1551
|
+
bool,
|
|
1552
|
+
typer.Option("--skip-validate", help="Skip class/attribute validation"),
|
|
1553
|
+
] = False,
|
|
1554
|
+
skip_cleanup: Annotated[
|
|
1555
|
+
bool,
|
|
1556
|
+
typer.Option("--skip-cleanup", help="Skip temp file deletion after confirm"),
|
|
1557
|
+
] = False,
|
|
1558
|
+
dry_run: Annotated[
|
|
1559
|
+
bool,
|
|
1560
|
+
typer.Option("--dry-run", help="Validate only, do not submit"),
|
|
1561
|
+
] = False,
|
|
1562
|
+
) -> None:
|
|
1563
|
+
try:
|
|
1564
|
+
payload = _read_payload(data, file)
|
|
1565
|
+
|
|
1566
|
+
upload_data = (
|
|
1567
|
+
payload.get("data", payload) if isinstance(payload, dict) else payload
|
|
1568
|
+
)
|
|
1569
|
+
if not isinstance(upload_data, dict):
|
|
1570
|
+
raise DWFError(
|
|
1571
|
+
"Invalid payload: expected object with uuid and allSheetInfo"
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
uuid_val = upload_data.get("uuid")
|
|
1575
|
+
if not uuid_val:
|
|
1576
|
+
raise DWFError("Invalid payload: missing uuid")
|
|
1577
|
+
|
|
1578
|
+
all_sheet_info = upload_data.get("allSheetInfo", [])
|
|
1579
|
+
if not all_sheet_info:
|
|
1580
|
+
raise DWFError("Invalid payload: allSheetInfo is empty")
|
|
1581
|
+
|
|
1582
|
+
sheet = all_sheet_info[0]
|
|
1583
|
+
if not sheet.get("useThisSheet", True):
|
|
1584
|
+
raise DWFError(
|
|
1585
|
+
f"First sheet '{sheet.get('sheetName', '?')}' is marked as unused"
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
class_name = sheet.get("className", "")
|
|
1589
|
+
if not class_name:
|
|
1590
|
+
raise DWFError("Sheet missing className")
|
|
1591
|
+
|
|
1592
|
+
client = get_client(ctx)
|
|
1593
|
+
errors: list[str] = []
|
|
1594
|
+
|
|
1595
|
+
if not skip_validate:
|
|
1596
|
+
typer.echo(f"Validating class '{class_name}'...", err=True)
|
|
1597
|
+
valid = datamodel_api.check_class_name_valid(client, class_name)
|
|
1598
|
+
if not valid:
|
|
1599
|
+
errors.append(
|
|
1600
|
+
f"Class name '{class_name}' is not valid or already exists"
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
col_list = sheet.get("infoFromColumnList", [])
|
|
1604
|
+
for col in col_list:
|
|
1605
|
+
if not col.get("useThisColumn", True):
|
|
1606
|
+
continue
|
|
1607
|
+
attr_name = col.get("firstRowData", "")
|
|
1608
|
+
data_type = col.get("dataType", "")
|
|
1609
|
+
if not attr_name or not data_type:
|
|
1610
|
+
continue
|
|
1611
|
+
typer.echo(
|
|
1612
|
+
f"Validating attribute '{attr_name}:{data_type}'...", err=True
|
|
1613
|
+
)
|
|
1614
|
+
attr_valid = datamodel_api.check_attribute_type_valid(
|
|
1615
|
+
client, attr_name, data_type
|
|
1616
|
+
)
|
|
1617
|
+
if not attr_valid:
|
|
1618
|
+
errors.append(
|
|
1619
|
+
f"Attribute '{attr_name}' with type '{data_type}' is not valid"
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
if errors:
|
|
1623
|
+
raise DWFError(
|
|
1624
|
+
"Validation failed",
|
|
1625
|
+
detail="; ".join(errors),
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
enriched_columns: list[dict[str, Any]] = []
|
|
1629
|
+
for col in sheet.get("infoFromColumnList", []):
|
|
1630
|
+
if not col.get("useThisColumn", True):
|
|
1631
|
+
continue
|
|
1632
|
+
enriched = dict(col)
|
|
1633
|
+
enriched.setdefault("attributeName", col.get("firstRowData", ""))
|
|
1634
|
+
enriched.setdefault("displayName", col.get("secondRowData", ""))
|
|
1635
|
+
enriched_columns.append(enriched)
|
|
1636
|
+
|
|
1637
|
+
confirm_payload = {
|
|
1638
|
+
"uuid": uuid_val,
|
|
1639
|
+
"useFirstRowAsClassName": upload_data.get("useFirstRowAsClassName", True),
|
|
1640
|
+
"useSecondRowAsDisplayName": upload_data.get(
|
|
1641
|
+
"useSecondRowAsDisplayName", True
|
|
1642
|
+
),
|
|
1643
|
+
"allSheetInfo": [
|
|
1644
|
+
{
|
|
1645
|
+
"sheetName": sheet.get("sheetName", ""),
|
|
1646
|
+
"className": class_name,
|
|
1647
|
+
"displayName": sheet.get("displayName", class_name),
|
|
1648
|
+
"zoneName": sheet.get("zoneName", "CUS"),
|
|
1649
|
+
"isTree": sheet.get("isTree", False),
|
|
1650
|
+
"useThisSheet": True,
|
|
1651
|
+
"infoFromColumnList": enriched_columns,
|
|
1652
|
+
}
|
|
1653
|
+
],
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
if dry_run:
|
|
1657
|
+
typer.echo(
|
|
1658
|
+
json_mod.dumps(
|
|
1659
|
+
{
|
|
1660
|
+
"action": "excel-confirm",
|
|
1661
|
+
"dry_run": True,
|
|
1662
|
+
"validated": not skip_validate,
|
|
1663
|
+
"payload": confirm_payload,
|
|
1664
|
+
},
|
|
1665
|
+
ensure_ascii=False,
|
|
1666
|
+
indent=2,
|
|
1667
|
+
)
|
|
1668
|
+
)
|
|
1669
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1670
|
+
|
|
1671
|
+
typer.echo(f"Confirming class '{class_name}'...", err=True)
|
|
1672
|
+
result = datamodel_api.confirm_excel(client, confirm_payload)
|
|
1673
|
+
|
|
1674
|
+
if not skip_cleanup:
|
|
1675
|
+
typer.echo(f"Cleaning up temp file (uuid={uuid_val})...", err=True)
|
|
1676
|
+
try:
|
|
1677
|
+
datamodel_api.delete_temp_excel(client, uuid_val)
|
|
1678
|
+
except DWFError as cleanup_exc:
|
|
1679
|
+
typer.echo(f"Warning: cleanup failed: {cleanup_exc}", err=True)
|
|
1680
|
+
|
|
1681
|
+
typer.echo(
|
|
1682
|
+
json_mod.dumps(
|
|
1683
|
+
{
|
|
1684
|
+
"confirmed": True,
|
|
1685
|
+
"className": class_name,
|
|
1686
|
+
"uuid": uuid_val,
|
|
1687
|
+
"cleanup": not skip_cleanup,
|
|
1688
|
+
"result": result,
|
|
1689
|
+
},
|
|
1690
|
+
ensure_ascii=False,
|
|
1691
|
+
indent=2,
|
|
1692
|
+
)
|
|
1693
|
+
)
|
|
1694
|
+
except DWFError as exc:
|
|
1695
|
+
handle_error(exc)
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
@app.command(name="excel-quickstart")
|
|
1699
|
+
def excel_quickstart_cmd(
|
|
1700
|
+
ctx: typer.Context,
|
|
1701
|
+
file: Annotated[Path, typer.Argument(help="Path to Excel file")],
|
|
1702
|
+
skip_validate: Annotated[
|
|
1703
|
+
bool,
|
|
1704
|
+
typer.Option("--skip-validate", help="Skip class/attribute validation"),
|
|
1705
|
+
] = False,
|
|
1706
|
+
skip_cleanup: Annotated[
|
|
1707
|
+
bool,
|
|
1708
|
+
typer.Option("--skip-cleanup", help="Skip temp file deletion after confirm"),
|
|
1709
|
+
] = False,
|
|
1710
|
+
dry_run: Annotated[
|
|
1711
|
+
bool,
|
|
1712
|
+
typer.Option("--dry-run", help="Upload and validate only, do not confirm"),
|
|
1713
|
+
] = False,
|
|
1714
|
+
) -> None:
|
|
1715
|
+
try:
|
|
1716
|
+
file = validate_path(file)
|
|
1717
|
+
if not file.exists():
|
|
1718
|
+
raise DWFError(f"File not found: {file}")
|
|
1719
|
+
|
|
1720
|
+
client = get_client(ctx)
|
|
1721
|
+
|
|
1722
|
+
typer.echo(f"Uploading {file.name}...", err=True)
|
|
1723
|
+
upload_result = datamodel_api.upload_excel(client, str(file))
|
|
1724
|
+
|
|
1725
|
+
upload_data = (
|
|
1726
|
+
upload_result.get("data", upload_result)
|
|
1727
|
+
if isinstance(upload_result, dict)
|
|
1728
|
+
else upload_result
|
|
1729
|
+
)
|
|
1730
|
+
if not isinstance(upload_data, dict) or not upload_data.get("uuid"):
|
|
1731
|
+
raise DWFError("Upload succeeded but response missing uuid")
|
|
1732
|
+
|
|
1733
|
+
uuid_val = upload_data["uuid"]
|
|
1734
|
+
all_sheet_info = upload_data.get("allSheetInfo", [])
|
|
1735
|
+
if not all_sheet_info:
|
|
1736
|
+
raise DWFError("Upload succeeded but allSheetInfo is empty")
|
|
1737
|
+
|
|
1738
|
+
sheet = all_sheet_info[0]
|
|
1739
|
+
class_name = sheet.get("className", "")
|
|
1740
|
+
if not class_name:
|
|
1741
|
+
raise DWFError("First sheet missing className")
|
|
1742
|
+
|
|
1743
|
+
errors: list[str] = []
|
|
1744
|
+
|
|
1745
|
+
if not skip_validate:
|
|
1746
|
+
typer.echo(f"Validating class '{class_name}'...", err=True)
|
|
1747
|
+
valid = datamodel_api.check_class_name_valid(client, class_name)
|
|
1748
|
+
if not valid:
|
|
1749
|
+
errors.append(
|
|
1750
|
+
f"Class name '{class_name}' is not valid or already exists"
|
|
1751
|
+
)
|
|
1752
|
+
|
|
1753
|
+
for col in sheet.get("infoFromColumnList", []):
|
|
1754
|
+
if not col.get("useThisColumn", True):
|
|
1755
|
+
continue
|
|
1756
|
+
attr_name = col.get("firstRowData", "")
|
|
1757
|
+
data_type = col.get("dataType", "")
|
|
1758
|
+
if not attr_name or not data_type:
|
|
1759
|
+
continue
|
|
1760
|
+
typer.echo(
|
|
1761
|
+
f"Validating attribute '{attr_name}:{data_type}'...", err=True
|
|
1762
|
+
)
|
|
1763
|
+
attr_valid = datamodel_api.check_attribute_type_valid(
|
|
1764
|
+
client, attr_name, data_type
|
|
1765
|
+
)
|
|
1766
|
+
if not attr_valid:
|
|
1767
|
+
errors.append(
|
|
1768
|
+
f"Attribute '{attr_name}' with type '{data_type}' is not valid"
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
if errors:
|
|
1772
|
+
if not skip_cleanup:
|
|
1773
|
+
try:
|
|
1774
|
+
datamodel_api.delete_temp_excel(client, uuid_val)
|
|
1775
|
+
except DWFError:
|
|
1776
|
+
pass
|
|
1777
|
+
raise DWFError(
|
|
1778
|
+
"Validation failed",
|
|
1779
|
+
detail="; ".join(errors),
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
enriched_columns: list[dict[str, Any]] = []
|
|
1783
|
+
for col in sheet.get("infoFromColumnList", []):
|
|
1784
|
+
if not col.get("useThisColumn", True):
|
|
1785
|
+
continue
|
|
1786
|
+
enriched = dict(col)
|
|
1787
|
+
enriched.setdefault("attributeName", col.get("firstRowData", ""))
|
|
1788
|
+
enriched.setdefault("displayName", col.get("secondRowData", ""))
|
|
1789
|
+
enriched_columns.append(enriched)
|
|
1790
|
+
|
|
1791
|
+
confirm_payload = {
|
|
1792
|
+
"uuid": uuid_val,
|
|
1793
|
+
"useFirstRowAsClassName": upload_data.get("useFirstRowAsClassName", True),
|
|
1794
|
+
"useSecondRowAsDisplayName": upload_data.get(
|
|
1795
|
+
"useSecondRowAsDisplayName", True
|
|
1796
|
+
),
|
|
1797
|
+
"allSheetInfo": [
|
|
1798
|
+
{
|
|
1799
|
+
"sheetName": sheet.get("sheetName", ""),
|
|
1800
|
+
"className": class_name,
|
|
1801
|
+
"displayName": sheet.get("displayName", class_name),
|
|
1802
|
+
"zoneName": sheet.get("zoneName", "CUS"),
|
|
1803
|
+
"isTree": sheet.get("isTree", False),
|
|
1804
|
+
"useThisSheet": True,
|
|
1805
|
+
"infoFromColumnList": enriched_columns,
|
|
1806
|
+
}
|
|
1807
|
+
],
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
if dry_run:
|
|
1811
|
+
typer.echo(
|
|
1812
|
+
json_mod.dumps(
|
|
1813
|
+
{
|
|
1814
|
+
"action": "excel-quickstart",
|
|
1815
|
+
"dry_run": True,
|
|
1816
|
+
"uploaded": True,
|
|
1817
|
+
"uuid": uuid_val,
|
|
1818
|
+
"validated": not skip_validate,
|
|
1819
|
+
"payload": confirm_payload,
|
|
1820
|
+
},
|
|
1821
|
+
ensure_ascii=False,
|
|
1822
|
+
indent=2,
|
|
1823
|
+
)
|
|
1824
|
+
)
|
|
1825
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1826
|
+
|
|
1827
|
+
typer.echo(f"Confirming class '{class_name}'...", err=True)
|
|
1828
|
+
result = datamodel_api.confirm_excel(client, confirm_payload)
|
|
1829
|
+
|
|
1830
|
+
if not skip_cleanup:
|
|
1831
|
+
typer.echo(f"Cleaning up temp file (uuid={uuid_val})...", err=True)
|
|
1832
|
+
try:
|
|
1833
|
+
datamodel_api.delete_temp_excel(client, uuid_val)
|
|
1834
|
+
except DWFError as cleanup_exc:
|
|
1835
|
+
typer.echo(f"Warning: cleanup failed: {cleanup_exc}", err=True)
|
|
1836
|
+
|
|
1837
|
+
typer.echo(
|
|
1838
|
+
json_mod.dumps(
|
|
1839
|
+
{
|
|
1840
|
+
"confirmed": True,
|
|
1841
|
+
"className": class_name,
|
|
1842
|
+
"uuid": uuid_val,
|
|
1843
|
+
"cleanup": not skip_cleanup,
|
|
1844
|
+
"result": result,
|
|
1845
|
+
},
|
|
1846
|
+
ensure_ascii=False,
|
|
1847
|
+
indent=2,
|
|
1848
|
+
)
|
|
1849
|
+
)
|
|
1850
|
+
except DWFError as exc:
|
|
1851
|
+
handle_error(exc)
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
# ── Attribute CRUD subgroup ──────────────────────────────────────
|
|
1855
|
+
|
|
1856
|
+
|
|
1857
|
+
@_attribute_sub.command(name="create")
|
|
1858
|
+
def create_attributes(
|
|
1859
|
+
ctx: typer.Context,
|
|
1860
|
+
data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
|
|
1861
|
+
file: Annotated[
|
|
1862
|
+
Path | None, typer.Option("--file", help="Path to JSON data file")
|
|
1863
|
+
] = None,
|
|
1864
|
+
if_not_exists: Annotated[
|
|
1865
|
+
bool,
|
|
1866
|
+
typer.Option("--if-not-exists", help="Skip if attribute already exists"),
|
|
1867
|
+
] = False,
|
|
1868
|
+
validate: Annotated[
|
|
1869
|
+
bool,
|
|
1870
|
+
typer.Option(
|
|
1871
|
+
"--validate", help="Validate input against schema before executing"
|
|
1872
|
+
),
|
|
1873
|
+
] = False,
|
|
1874
|
+
dry_run: Annotated[
|
|
1875
|
+
bool,
|
|
1876
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1877
|
+
] = False,
|
|
1878
|
+
fmt: Annotated[
|
|
1879
|
+
OutputFormat,
|
|
1880
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
1881
|
+
] = OutputFormat.table,
|
|
1882
|
+
) -> None:
|
|
1883
|
+
try:
|
|
1884
|
+
payload = _read_payload(data, file)
|
|
1885
|
+
if isinstance(payload, dict):
|
|
1886
|
+
payload = [payload]
|
|
1887
|
+
|
|
1888
|
+
if validate:
|
|
1889
|
+
_do_validate("datamodel attribute create", payload)
|
|
1890
|
+
|
|
1891
|
+
if dry_run:
|
|
1892
|
+
typer.echo(
|
|
1893
|
+
json_mod.dumps(
|
|
1894
|
+
{
|
|
1895
|
+
"action": "create",
|
|
1896
|
+
"resource": "attribute",
|
|
1897
|
+
"data": payload,
|
|
1898
|
+
"if_not_exists": if_not_exists,
|
|
1899
|
+
},
|
|
1900
|
+
ensure_ascii=False,
|
|
1901
|
+
indent=2,
|
|
1902
|
+
)
|
|
1903
|
+
)
|
|
1904
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1905
|
+
|
|
1906
|
+
client = get_client(ctx)
|
|
1907
|
+
try:
|
|
1908
|
+
result = datamodel_api.create_attributes(client, payload)
|
|
1909
|
+
if fmt == OutputFormat.json:
|
|
1910
|
+
typer.echo(
|
|
1911
|
+
json_mod.dumps(
|
|
1912
|
+
{
|
|
1913
|
+
"success": True,
|
|
1914
|
+
"action": "create",
|
|
1915
|
+
"resource": "attribute",
|
|
1916
|
+
"data": result,
|
|
1917
|
+
},
|
|
1918
|
+
ensure_ascii=False,
|
|
1919
|
+
indent=2,
|
|
1920
|
+
)
|
|
1921
|
+
)
|
|
1922
|
+
else:
|
|
1923
|
+
typer.echo(f"Created: {result}", err=True)
|
|
1924
|
+
except ConflictError:
|
|
1925
|
+
if if_not_exists:
|
|
1926
|
+
typer.echo(
|
|
1927
|
+
"Attribute already exists, skipped (--if-not-exists).", err=True
|
|
1928
|
+
)
|
|
1929
|
+
else:
|
|
1930
|
+
raise
|
|
1931
|
+
except DWFError as exc:
|
|
1932
|
+
handle_error(exc)
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
@_attribute_sub.command(name="update")
|
|
1936
|
+
def update_attribute(
|
|
1937
|
+
ctx: typer.Context,
|
|
1938
|
+
data: Annotated[str, typer.Option("--data", "-d", help="JSON data string")] = "",
|
|
1939
|
+
file: Annotated[
|
|
1940
|
+
Path | None, typer.Option("--file", help="Path to JSON data file")
|
|
1941
|
+
] = None,
|
|
1942
|
+
validate: Annotated[
|
|
1943
|
+
bool,
|
|
1944
|
+
typer.Option(
|
|
1945
|
+
"--validate", help="Validate input against schema before executing"
|
|
1946
|
+
),
|
|
1947
|
+
] = False,
|
|
1948
|
+
dry_run: Annotated[
|
|
1949
|
+
bool,
|
|
1950
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
1951
|
+
] = False,
|
|
1952
|
+
fmt: Annotated[
|
|
1953
|
+
OutputFormat,
|
|
1954
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
1955
|
+
] = OutputFormat.table,
|
|
1956
|
+
) -> None:
|
|
1957
|
+
try:
|
|
1958
|
+
payload = _read_payload(data, file)
|
|
1959
|
+
|
|
1960
|
+
if validate:
|
|
1961
|
+
_do_validate("datamodel attribute update", payload)
|
|
1962
|
+
|
|
1963
|
+
if dry_run:
|
|
1964
|
+
typer.echo(
|
|
1965
|
+
json_mod.dumps(
|
|
1966
|
+
{"action": "update", "resource": "attribute", "data": payload},
|
|
1967
|
+
ensure_ascii=False,
|
|
1968
|
+
indent=2,
|
|
1969
|
+
)
|
|
1970
|
+
)
|
|
1971
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
1972
|
+
|
|
1973
|
+
client = get_client(ctx)
|
|
1974
|
+
result = datamodel_api.update_attribute(client, payload)
|
|
1975
|
+
if fmt == OutputFormat.json:
|
|
1976
|
+
typer.echo(
|
|
1977
|
+
json_mod.dumps(
|
|
1978
|
+
{
|
|
1979
|
+
"success": True,
|
|
1980
|
+
"action": "update",
|
|
1981
|
+
"resource": "attribute",
|
|
1982
|
+
"data": result,
|
|
1983
|
+
},
|
|
1984
|
+
ensure_ascii=False,
|
|
1985
|
+
indent=2,
|
|
1986
|
+
)
|
|
1987
|
+
)
|
|
1988
|
+
else:
|
|
1989
|
+
typer.echo(f"Updated: {result}", err=True)
|
|
1990
|
+
except DWFError as exc:
|
|
1991
|
+
handle_error(exc)
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
@_attribute_sub.command(name="delete")
|
|
1995
|
+
def delete_attribute(
|
|
1996
|
+
ctx: typer.Context,
|
|
1997
|
+
name: Annotated[str, typer.Argument(help="Attribute name")],
|
|
1998
|
+
force_delete: Annotated[
|
|
1999
|
+
bool,
|
|
2000
|
+
typer.Option("--hard-delete", help="Force delete even if bound to classes"),
|
|
2001
|
+
] = False,
|
|
2002
|
+
dry_run: Annotated[
|
|
2003
|
+
bool,
|
|
2004
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
2005
|
+
] = False,
|
|
2006
|
+
yes: Annotated[
|
|
2007
|
+
bool,
|
|
2008
|
+
typer.Option("--force", help="Skip confirmation prompt"),
|
|
2009
|
+
] = False,
|
|
2010
|
+
fmt: Annotated[
|
|
2011
|
+
OutputFormat,
|
|
2012
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
2013
|
+
] = OutputFormat.table,
|
|
2014
|
+
) -> None:
|
|
2015
|
+
try:
|
|
2016
|
+
if dry_run:
|
|
2017
|
+
typer.echo(
|
|
2018
|
+
json_mod.dumps(
|
|
2019
|
+
{
|
|
2020
|
+
"action": "delete",
|
|
2021
|
+
"resource": "attribute",
|
|
2022
|
+
"target": name,
|
|
2023
|
+
"force": force_delete,
|
|
2024
|
+
},
|
|
2025
|
+
ensure_ascii=False,
|
|
2026
|
+
indent=2,
|
|
2027
|
+
)
|
|
2028
|
+
)
|
|
2029
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
2030
|
+
|
|
2031
|
+
if is_tty() and not yes:
|
|
2032
|
+
confirm = typer.confirm(f"Delete attribute '{name}'?", default=False)
|
|
2033
|
+
if not confirm:
|
|
2034
|
+
typer.echo("Cancelled.", err=True)
|
|
2035
|
+
raise typer.Exit(code=0)
|
|
2036
|
+
|
|
2037
|
+
client = get_client(ctx)
|
|
2038
|
+
result = datamodel_api.delete_attribute(client, name, force=force_delete)
|
|
2039
|
+
if fmt == OutputFormat.json:
|
|
2040
|
+
typer.echo(
|
|
2041
|
+
json_mod.dumps(
|
|
2042
|
+
{
|
|
2043
|
+
"success": True,
|
|
2044
|
+
"action": "delete",
|
|
2045
|
+
"resource": "attribute",
|
|
2046
|
+
"target": name,
|
|
2047
|
+
"data": result,
|
|
2048
|
+
},
|
|
2049
|
+
ensure_ascii=False,
|
|
2050
|
+
indent=2,
|
|
2051
|
+
)
|
|
2052
|
+
)
|
|
2053
|
+
else:
|
|
2054
|
+
typer.echo(f"Deleted: {result}", err=True)
|
|
2055
|
+
except DWFError as exc:
|
|
2056
|
+
handle_error(exc)
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
@_attribute_sub.command(name="get")
|
|
2060
|
+
def get_attribute(
|
|
2061
|
+
ctx: typer.Context,
|
|
2062
|
+
name: Annotated[str, typer.Argument(help="Attribute name")],
|
|
2063
|
+
fmt: Annotated[
|
|
2064
|
+
OutputFormat,
|
|
2065
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
2066
|
+
] = OutputFormat.table,
|
|
2067
|
+
) -> None:
|
|
2068
|
+
try:
|
|
2069
|
+
client = get_client(ctx)
|
|
2070
|
+
result = datamodel_api.get_attribute(client, name)
|
|
2071
|
+
|
|
2072
|
+
fmt = resolve_format(fmt)
|
|
2073
|
+
|
|
2074
|
+
if fmt == OutputFormat.json:
|
|
2075
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
2076
|
+
return
|
|
2077
|
+
|
|
2078
|
+
data = result.get("data", result) if isinstance(result, dict) else result
|
|
2079
|
+
console = get_console()
|
|
2080
|
+
if isinstance(data, dict):
|
|
2081
|
+
for key, value in data.items():
|
|
2082
|
+
console.print(f"[bold]{key}[/bold]: {value}")
|
|
2083
|
+
except DWFError as exc:
|
|
2084
|
+
handle_error(exc)
|
|
2085
|
+
|
|
2086
|
+
|
|
2087
|
+
@_attribute_sub.command(name="bind-classes")
|
|
2088
|
+
def get_bind_classes(
|
|
2089
|
+
ctx: typer.Context,
|
|
2090
|
+
name: Annotated[str, typer.Argument(help="Attribute name")],
|
|
2091
|
+
fmt: Annotated[
|
|
2092
|
+
OutputFormat,
|
|
2093
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
2094
|
+
] = OutputFormat.table,
|
|
2095
|
+
) -> None:
|
|
2096
|
+
try:
|
|
2097
|
+
client = get_client(ctx)
|
|
2098
|
+
result = datamodel_api.get_attribute_bind_classes(client, name)
|
|
2099
|
+
|
|
2100
|
+
if isinstance(result, dict):
|
|
2101
|
+
payload = result.get("data", result)
|
|
2102
|
+
items = payload if isinstance(payload, list) else [payload]
|
|
2103
|
+
elif isinstance(result, list):
|
|
2104
|
+
items = result
|
|
2105
|
+
else:
|
|
2106
|
+
items = []
|
|
2107
|
+
|
|
2108
|
+
if not items:
|
|
2109
|
+
_empty_ok(ctx, f"No classes bound to attribute '{name}'.")
|
|
2110
|
+
return
|
|
2111
|
+
|
|
2112
|
+
fmt = resolve_format(fmt)
|
|
2113
|
+
|
|
2114
|
+
if fmt == OutputFormat.json:
|
|
2115
|
+
typer.echo(json_mod.dumps(result, ensure_ascii=False, indent=2))
|
|
2116
|
+
return
|
|
2117
|
+
|
|
2118
|
+
_print_table(items, None, get_console())
|
|
2119
|
+
except DWFError as exc:
|
|
2120
|
+
handle_error(exc)
|
|
2121
|
+
|
|
2122
|
+
|
|
2123
|
+
@_attribute_sub.command(name="bind")
|
|
2124
|
+
def bind_attributes(
|
|
2125
|
+
ctx: typer.Context,
|
|
2126
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
2127
|
+
data: Annotated[
|
|
2128
|
+
str, typer.Option("--data", "-d", help="JSON attributes string")
|
|
2129
|
+
] = "",
|
|
2130
|
+
file: Annotated[
|
|
2131
|
+
Path | None, typer.Option("--file", help="Path to JSON attributes file")
|
|
2132
|
+
] = None,
|
|
2133
|
+
is_relation: Annotated[
|
|
2134
|
+
bool,
|
|
2135
|
+
typer.Option("--is-relation", help="Target is a relation class"),
|
|
2136
|
+
] = False,
|
|
2137
|
+
validate: Annotated[
|
|
2138
|
+
bool,
|
|
2139
|
+
typer.Option(
|
|
2140
|
+
"--validate", help="Validate input against schema before executing"
|
|
2141
|
+
),
|
|
2142
|
+
] = False,
|
|
2143
|
+
dry_run: Annotated[
|
|
2144
|
+
bool,
|
|
2145
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
2146
|
+
] = False,
|
|
2147
|
+
fmt: Annotated[
|
|
2148
|
+
OutputFormat,
|
|
2149
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
2150
|
+
] = OutputFormat.table,
|
|
2151
|
+
) -> None:
|
|
2152
|
+
try:
|
|
2153
|
+
payload = _read_payload(data, file)
|
|
2154
|
+
if isinstance(payload, dict):
|
|
2155
|
+
payload = [payload]
|
|
2156
|
+
|
|
2157
|
+
if validate:
|
|
2158
|
+
_do_validate("datamodel attribute bind", payload)
|
|
2159
|
+
|
|
2160
|
+
if dry_run:
|
|
2161
|
+
typer.echo(
|
|
2162
|
+
json_mod.dumps(
|
|
2163
|
+
{
|
|
2164
|
+
"action": "bind",
|
|
2165
|
+
"resource": "attribute",
|
|
2166
|
+
"class": class_name,
|
|
2167
|
+
"is_relation": is_relation,
|
|
2168
|
+
"data": payload,
|
|
2169
|
+
},
|
|
2170
|
+
ensure_ascii=False,
|
|
2171
|
+
indent=2,
|
|
2172
|
+
)
|
|
2173
|
+
)
|
|
2174
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
2175
|
+
|
|
2176
|
+
client = get_client(ctx)
|
|
2177
|
+
result = datamodel_api.bind_attributes(
|
|
2178
|
+
client, class_name, payload, is_relation=is_relation
|
|
2179
|
+
)
|
|
2180
|
+
if fmt == OutputFormat.json:
|
|
2181
|
+
typer.echo(
|
|
2182
|
+
json_mod.dumps(
|
|
2183
|
+
{
|
|
2184
|
+
"success": True,
|
|
2185
|
+
"action": "bind",
|
|
2186
|
+
"resource": "attribute",
|
|
2187
|
+
"class": class_name,
|
|
2188
|
+
"data": result,
|
|
2189
|
+
},
|
|
2190
|
+
ensure_ascii=False,
|
|
2191
|
+
indent=2,
|
|
2192
|
+
)
|
|
2193
|
+
)
|
|
2194
|
+
else:
|
|
2195
|
+
typer.echo(f"Bound: {result}", err=True)
|
|
2196
|
+
except DWFError as exc:
|
|
2197
|
+
handle_error(exc)
|
|
2198
|
+
|
|
2199
|
+
|
|
2200
|
+
@_attribute_sub.command(name="unbind")
|
|
2201
|
+
def unbind_attribute(
|
|
2202
|
+
ctx: typer.Context,
|
|
2203
|
+
class_name: Annotated[str, typer.Option("--class-name", "-c", help="Class name")],
|
|
2204
|
+
attr_name: Annotated[str, typer.Argument(help="Attribute name to unbind")],
|
|
2205
|
+
is_relation: Annotated[
|
|
2206
|
+
bool,
|
|
2207
|
+
typer.Option("--is-relation", help="Target is a relation class"),
|
|
2208
|
+
] = False,
|
|
2209
|
+
dry_run: Annotated[
|
|
2210
|
+
bool,
|
|
2211
|
+
typer.Option("--dry-run", help="Preview without executing"),
|
|
2212
|
+
] = False,
|
|
2213
|
+
yes: Annotated[
|
|
2214
|
+
bool,
|
|
2215
|
+
typer.Option("--force", help="Skip confirmation prompt"),
|
|
2216
|
+
] = False,
|
|
2217
|
+
fmt: Annotated[
|
|
2218
|
+
OutputFormat,
|
|
2219
|
+
typer.Option("--format", "-f", help="Output format: table/json"),
|
|
2220
|
+
] = OutputFormat.table,
|
|
2221
|
+
) -> None:
|
|
2222
|
+
try:
|
|
2223
|
+
if dry_run:
|
|
2224
|
+
typer.echo(
|
|
2225
|
+
json_mod.dumps(
|
|
2226
|
+
{
|
|
2227
|
+
"action": "unbind",
|
|
2228
|
+
"resource": "attribute",
|
|
2229
|
+
"class": class_name,
|
|
2230
|
+
"attribute": attr_name,
|
|
2231
|
+
"is_relation": is_relation,
|
|
2232
|
+
},
|
|
2233
|
+
ensure_ascii=False,
|
|
2234
|
+
indent=2,
|
|
2235
|
+
)
|
|
2236
|
+
)
|
|
2237
|
+
raise typer.Exit(code=DRY_RUN_EXIT_CODE)
|
|
2238
|
+
|
|
2239
|
+
if is_tty() and not yes:
|
|
2240
|
+
confirm = typer.confirm(
|
|
2241
|
+
f"Unbind attribute '{attr_name}' from '{class_name}'?",
|
|
2242
|
+
default=False,
|
|
2243
|
+
)
|
|
2244
|
+
if not confirm:
|
|
2245
|
+
typer.echo("Cancelled.", err=True)
|
|
2246
|
+
raise typer.Exit(code=0)
|
|
2247
|
+
|
|
2248
|
+
client = get_client(ctx)
|
|
2249
|
+
result = datamodel_api.unbind_attribute(
|
|
2250
|
+
client, class_name, attr_name, is_relation=is_relation
|
|
2251
|
+
)
|
|
2252
|
+
if fmt == OutputFormat.json:
|
|
2253
|
+
typer.echo(
|
|
2254
|
+
json_mod.dumps(
|
|
2255
|
+
{
|
|
2256
|
+
"success": True,
|
|
2257
|
+
"action": "unbind",
|
|
2258
|
+
"resource": "attribute",
|
|
2259
|
+
"class": class_name,
|
|
2260
|
+
"attribute": attr_name,
|
|
2261
|
+
"data": result,
|
|
2262
|
+
},
|
|
2263
|
+
ensure_ascii=False,
|
|
2264
|
+
indent=2,
|
|
2265
|
+
)
|
|
2266
|
+
)
|
|
2267
|
+
else:
|
|
2268
|
+
typer.echo(f"Unbound: {result}", err=True)
|
|
2269
|
+
except DWFError as exc:
|
|
2270
|
+
handle_error(exc)
|