1import json
2
3import click
4
5from ..describe import ModelDescription, describe_models
6
7
8@click.command()
9@click.argument("targets", nargs=-1)
10@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
11@click.option(
12 "--all",
13 "include_packages",
14 is_flag=True,
15 help="Include models from installed packages, not just the app's",
16)
17def models(
18 targets: tuple[str, ...],
19 output_json: bool,
20 include_packages: bool,
21) -> None:
22 """Describe the data model: fields, relations, constraints.
23
24 TARGETS narrow the output to models (`Thread`, `changes.Thread`) or whole
25 packages (`changes`). Read from the model registry, so no database is needed.
26 """
27 if include_packages and targets:
28 raise click.UsageError(
29 "--all widens the default selection; with targets it has nothing to do."
30 )
31
32 try:
33 descriptions = describe_models(targets, include_packages=include_packages)
34 except LookupError as e:
35 raise click.ClickException(str(e)) from e
36
37 if output_json:
38 click.echo(
39 json.dumps({"models": [d.to_dict() for d in descriptions]}, indent=2)
40 )
41 else:
42 for i, description in enumerate(descriptions):
43 if i > 0:
44 click.echo()
45 _render_model(description)
46
47
48def _render_model(description: ModelDescription) -> None:
49 click.secho(description.label, bold=True, nl=False)
50 click.secho(f" → {description.table}", dim=True)
51
52 for model_field in description.fields:
53 type_name = model_field.type.removesuffix("Field")
54 marks = []
55 if model_field.primary_key:
56 marks.append(click.style("PK", fg="yellow"))
57 if model_field.unique:
58 marks.append(click.style("unique", fg="yellow"))
59 if model_field.null:
60 marks.append(click.style("null", dim=True))
61 click.echo(
62 f" {model_field.name:24s} {_pad(styled=click.style(type_name, fg='cyan'), visible_length=len(type_name))} {' '.join(marks)}".rstrip()
63 )
64
65 for relation in description.relations:
66 if relation.type == "ManyToManyField":
67 target = f"⇆ {relation.to}"
68 marks = [click.style(f"through {relation.through}", dim=True)]
69 else:
70 target = f"→ {relation.to}"
71 marks = [_style_on_delete(relation.on_delete)]
72 if relation.unique:
73 marks.append(click.style("1:1", fg="yellow"))
74 if relation.null:
75 marks.append(click.style("null", dim=True))
76 click.echo(
77 f" {relation.name:24s} {_pad(styled=click.style(target, fg='cyan'), visible_length=len(target))} {' '.join(marks)}"
78 )
79
80 for reverse in description.reverse:
81 source = f"{reverse.from_model}.{reverse.field}"
82 marks = []
83 if reverse.accessor:
84 marks.append(click.style(f".{reverse.accessor}", fg="cyan"))
85 if reverse.one_to_one:
86 marks.append(click.style("1:1", fg="yellow"))
87 if reverse.type == "ManyToManyField":
88 marks.append(click.style("many-to-many", dim=True))
89 marks.append(click.style(f"filter: {reverse.query_name}", dim=True))
90 click.echo(f" {click.style('←', dim=True)} {source:48s} {' '.join(marks)}")
91
92 for constraint in description.constraints:
93 if constraint.kind == "unique":
94 detail = _columns_detail(
95 columns=constraint.fields or constraint.expressions,
96 condition=constraint.condition,
97 )
98 else:
99 detail = constraint.check or ""
100 _echo_schema_line(kind=constraint.kind, name=constraint.name, detail=detail)
101
102 for index in description.indexes:
103 detail = _columns_detail(
104 columns=index.fields or index.expressions, condition=index.condition
105 )
106 _echo_schema_line(kind="index", name=index.name, detail=detail)
107
108
109def _columns_detail(*, columns: list[str], condition: str | None) -> str:
110 """`(a, b)`, plus ` WHERE ...` for a partial constraint or index.
111
112 Expressions arrive parenthesized the way their DDL writes them, so an
113 expression index reads `((LOWER("name")))`, as in `CREATE INDEX`."""
114 detail = f"({', '.join(columns)})"
115 if condition:
116 detail += f" WHERE {condition}"
117 return detail
118
119
120def _pad(*, styled: str, visible_length: int, width: int = 22) -> str:
121 """Pad after the styled text, so the ANSI codes don't count toward the width."""
122 return styled + " " * max(width - visible_length, 0)
123
124
125def _echo_schema_line(*, kind: str, name: str, detail: str) -> None:
126 click.echo(
127 f" {click.style(f'{kind:8s}', dim=True)} {name} {click.style(detail, dim=True)}"
128 )
129
130
131def _style_on_delete(on_delete: str | None) -> str:
132 if on_delete == "CASCADE":
133 return click.style(on_delete, fg="red")
134 if on_delete == "SET_NULL":
135 return click.style(on_delete, fg="yellow")
136 return click.style(on_delete or "", fg="blue")