v0.166.0
  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")