Coverage for python/felis/browser/columns.py: 96%
68 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-31 01:35 -0700
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-31 01:35 -0700
1"""The columns table of a table's page: the fields shown and the tags after
2a column's name.
3"""
5# This file is part of felis.
6#
7# Developed for the LSST Data Management System.
8# This product includes software developed by the LSST Project
9# (https://www.lsst.org).
10# See the COPYRIGHT file at the top-level directory of this distribution
11# for details of code ownership.
12#
13# This program is free software: you can redistribute it and/or modify
14# it under the terms of the GNU General Public License as published by
15# the Free Software Foundation, either version 3 of the License, or
16# (at your option) any later version.
17#
18# This program is distributed in the hope that it will be useful,
19# but WITHOUT ANY WARRANTY; without even the implied warranty of
20# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21# GNU General Public License for more details.
22#
23# You should have received a copy of the GNU General Public License
24# along with this program. If not, see <https://www.gnu.org/licenses/>.
26from __future__ import annotations
28from typing import NamedTuple
30from felis.datamodel import Column, Schema, Table
32from .schemas import _foreign_keys, _primary_key_columns, _resolve_column
34_COLUMN_FIELDS = (
35 "name",
36 "datatype",
37 "description",
38 "unit",
39 "nullable",
40 "ivoa:ucd",
41 "tap:column_index",
42 "tap:principal",
43 "tap:std",
44 "votable:arraysize",
45 "votable:datatype",
46 "votable:xtype",
47 "votable:utype",
48 "value",
49 "length",
50 "precision",
51 "autoincrement",
52)
53"""Column fields shown in the columns table, in order, by their names in the
54schema YAML, except ``unit``: a column's ``ivoa:unit`` or ``fits:tunit``,
55which are never both set.
56"""
59_REQUIRED_COLUMN_FIELDS = frozenset({"name", "datatype", "description", "unit", "nullable"})
60"""Column fields always shown in the columns table, even if empty."""
63_COLUMN_ATTRIBUTES = {field.alias or name: name for name, field in Column.model_fields.items()}
64"""Column model attribute names, keyed by schema YAML field name."""
67def _format_field_value(value: object) -> str:
68 """Format a field value for display: "" for `None`, "true"/"false", lists
69 comma-separated.
70 """
71 if value is None:
72 return ""
73 if isinstance(value, bool):
74 return "true" if value else "false"
75 if isinstance(value, list): 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true
76 return ", ".join(str(item) for item in value)
77 return str(value)
80def _column_value(column: Column, field: str) -> object:
81 """Return a column's value for a YAML field name, or for "unit" its
82 ``ivoa:unit`` or ``fits:tunit``.
83 """
84 if field == "unit":
85 return column.ivoa_unit or column.fits_tunit
86 return getattr(column, _COLUMN_ATTRIBUTES[field])
89def _field_default(field: str) -> object:
90 """Return a field's default: `None` for most, 0 for ``tap:principal`` and
91 ``tap:std``.
92 """
93 if field == "unit": 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 return None
95 return Column.model_fields[_COLUMN_ATTRIBUTES[field]].get_default(call_default_factory=True)
98def _column_field(column: Column, field: str) -> str:
99 return _format_field_value(_column_value(column, field))
102def _column_sets_field(column: Column, field: str) -> bool:
103 """Return whether a column sets a field to a non-empty value other than its
104 default.
105 """
106 value = _column_value(column, field)
107 return _format_field_value(value) != "" and value != _field_default(field)
110def _table_column_fields(table: Table, show_empty: bool) -> list[str]:
111 """Return the fields to show in a table's columns table, in
112 `_COLUMN_FIELDS` order.
114 Unless ``show_empty`` is true, a field is left out if no column of the
115 table sets it (see `_column_sets_field`), so the table isn't filled with
116 empty columns; the fields in `_REQUIRED_COLUMN_FIELDS` are always shown so
117 every table starts the same.
118 """
119 return [
120 field
121 for field in _COLUMN_FIELDS
122 if show_empty
123 or field in _REQUIRED_COLUMN_FIELDS
124 or any(_column_sets_field(column, field) for column in table.columns)
125 ]
128class ColumnTag(NamedTuple):
129 """A tag shown after a column's name in the columns table."""
131 kind: str
132 """Kind of tag, used in its CSS class: ``primary-key``, ``foreign-key``
133 or ``index``.
134 """
136 label: str
137 """Text of the tag."""
139 title: str
140 """Hover text explaining the tag."""
143_TAG_KINDS = ("primary-key", "foreign-key", "index")
144"""Kinds of column tag, in the order they are shown."""
147def _column_tag(kind: str, details: list[str]) -> ColumnTag:
148 if kind == "primary-key":
149 return ColumnTag(kind, "primary key", "Part of the table's primary key")
150 if kind == "foreign-key":
151 return ColumnTag(kind, "foreign key", "References " + ", ".join(details))
152 prefix = "In indexes " if len(details) > 1 else "In index "
153 return ColumnTag(kind, "indexed", prefix + ", ".join(details))
156def _column_tags(schema: Schema, table: Table) -> dict[str, list[ColumnTag]]:
157 """Return the tags of a table's columns, by column name, in `_TAG_KINDS`
158 order.
159 """
160 # Each column tagged, with the kind of tag and what it is tagged by.
161 found: list[tuple[Column, str, str]] = []
162 for column in _primary_key_columns(schema, table):
163 found.append((column, "primary-key", ""))
164 for constraint in _foreign_keys(table):
165 for ref, referenced_ref in zip(constraint.columns, constraint.referenced_columns, strict=True):
166 referenced = _resolve_column(schema, referenced_ref)
167 target = f"{schema.get_table_by_column(referenced).name}.{referenced.name}"
168 found.append((_resolve_column(schema, ref), "foreign-key", target))
169 for index in table.indexes:
170 for ref in index.columns or []:
171 found.append((_resolve_column(schema, ref), "index", index.name))
173 # Gather what each column is tagged by, by kind.
174 details: dict[str, dict[str, list[str]]] = {}
175 for column, kind, detail in found:
176 details.setdefault(column.name, {}).setdefault(kind, []).append(detail)
178 tags: dict[str, list[ColumnTag]] = {}
179 for name, details_by_kind in details.items():
180 tags[name] = [
181 _column_tag(kind, details_by_kind[kind]) for kind in _TAG_KINDS if kind in details_by_kind
182 ]
183 return tags