From 7c2379d55bd25e213983079e30ce1ef761c38d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Fri, 4 Sep 2026 22:47:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E3=83=86=E3=83=BC=E3=83=96=E3=83=AB?= =?UTF-8?q?=E5=90=8D=E3=82=92=E6=8A=BC=E3=81=97=E3=81=9F=E3=82=89=E4=B8=AD?= =?UTF-8?q?=E8=BA=AB=E3=82=92=E5=87=BA=E3=81=99=EF=BC=881000=20=E8=A1=8C?= =?UTF-8?q?=E3=81=9A=E3=81=A4=E3=81=AE=E3=83=9A=E3=83=BC=E3=82=B8=E9=80=81?= =?UTF-8?q?=E3=82=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 押すと列の一覧が出ていた。マイグレーションを書いたのは自分なので、列の構成は 知っている。**押した動機は中身を見ることだった。** - 既定タブを Data にした。列と索引は 1 クリック先 - **タブが操作をまたいで保たれる。** panel は htmx で丸ごと差し替わるので、 列を 1 つ直すたびに先頭のタブへ弾き飛ばされていた。同じテーブルの中では いま見ていたタブに留まり、別のテーブルへ移れば Data から始める - 50 行で打ち切って 51 行目を見る手段が無かったのを、**1000 行ずつのページ送り**に した(100 / 500 / 1000 / 5000 から選べる) **並び順は主キー。** 順序を指定しない LIMIT/OFFSET は速いが、PostgreSQL は行の 順序を約束しないので、めくると同じ行が二度出たり、どこにも出ない行ができる ── ページャの体裁で嘘をつくことになる。実測(Airlines・236 万行): 主キー順は先頭 7 ms・最深部 878 ms、順序なしは 50 ms だが正しくない。主キーが無いテーブルでは 並べようがないので、そう画面に書く。 **総数は統計の推定で、推定だと書く。** count(*) は開くたびの全走査になる。 ただし「次のページがあるか」は推定ではなく**返ってきた行数**で決める ── 統計が古いと「次へ」が空振りし、それは総数が少しずれているより体験として悪い。 画面から来た page / rows は allow-list に通してから使う。壊れた値は黙って既定へ 落とす(ここでエラーを出しても、利用者にできることが無い)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkRWmyQf9Rt3sYjjkPSq4Y --- CHANGELOG.md | 27 ++++ core/engines/base.py | 64 ++++++++- core/engines/mysql.py | 29 +++- core/engines/mysql_sql.py | 16 +++ core/engines/pg_sql.py | 24 +++- core/engines/postgres.py | 44 +++++- core/tests.py | 97 +++++++++++++ core/views/tables.py | 39 ++++- locale/ja/LC_MESSAGES/django.mo | Bin 67318 -> 67999 bytes locale/ja/LC_MESSAGES/django.po | 244 ++++++++++++++++++++------------ planner_lab/tests.py | 3 + templates/partials/detail.html | 61 +++++++- 12 files changed, 536 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5346e6..b38a4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,33 @@ Versioning convention for this project: ## [Unreleased] +### Changed + +- **Clicking a table now shows its rows, not its column list.** You wrote the + migration; you know what the columns are. What you clicked the table to see + is what is in it. Columns and Indexes are one click away, and the tab you are + on now survives an edit — changing a column used to throw you back to the + first tab every time the panel re-rendered. + +- **The row grid is paged: 1,000 rows at a time, and you can change that** + (100 / 500 / 1,000 / 5,000). It used to show the first 50 rows and stop, with + no way to see row 51. + + **Pages are ordered by the primary key.** `LIMIT`/`OFFSET` without an + `ORDER BY` is faster, but PostgreSQL does not promise an order, so paging + through would show some rows twice and never show others — a pager that + lies. Measured on the bundled Airlines demo (2,360,335 rows): the first page + takes 7 ms ordered, the last 878 ms; unordered it is 50 ms and wrong. On a + table with no primary key there is nothing to order by, and the pager says + so rather than pretending. + + **The total is the statistics estimate, labelled as one** — `count(*)` would + scan the whole table every time you opened it. Whether a *next* page exists + is decided by how many rows actually came back, not by the estimate: stale + statistics make a "next" that lands on nothing, which is worse than a total + that is slightly off. + + ### Fixed - **The table list showed 0 rows for tables that were not empty.** The bundled diff --git a/core/engines/base.py b/core/engines/base.py index 347c4c6..f6c942c 100644 --- a/core/engines/base.py +++ b/core/engines/base.py @@ -122,8 +122,63 @@ def has_gin(self) -> bool: @dataclass class Preview: + """テーブルの中身、1 ページぶん。 + + 行数は数えない ── `count(*)` は 236 万行のテーブルで毎回全走査になる。 + プランナ統計の推定を使い、**推定であることを画面でも言う** + (TRUNCATE / DROP の確認と同じ規則)。 + """ + columns: list[str] rows: list[tuple] + # 何行目から返したか(0 始まり)と、1 ページの大きさ。 + offset: int = 0 + page_size: int = 1000 + # テーブル全体の推定行数。統計がまだ無ければ None =「不明」。 + estimated_rows: int | None = None + # 並び順に使った列。**空なら順序は保証されない** ── 主キーが無いテーブルでは + # ページをめくると行が重複したり飛んだりしうるので、そう言う。 + ordered_by: list[str] = field(default_factory=list) + + @property + def page(self) -> int: + """1 始まりのページ番号。""" + return self.offset // self.page_size + 1 + + @property + def first_row(self) -> int: + """このページの先頭が、テーブル全体の何行目か(1 始まり)。""" + return self.offset + 1 if self.rows else 0 + + @property + def last_row(self) -> int: + """このページの末尾が、テーブル全体の何行目か。""" + return self.offset + len(self.rows) + + @property + def stable(self) -> bool: + """ページ送りが安定しているか(=並び順が決まっているか)。""" + return bool(self.ordered_by) + + @property + def has_previous(self) -> bool: + return self.offset > 0 + + @property + def has_next(self) -> bool: + """次のページがあるか。 + + 推定に頼らず、**返ってきた行数で判断する** ── 統計は古いことがあり、 + 「次へ」が空振りするのは、行数が少し違うより体験として悪い。 + 取れた行がページ一杯なら、次があると見なす。 + """ + return len(self.rows) == self.page_size + + @property + def estimated_pages(self) -> int | None: + if self.estimated_rows is None: + return None + return max(1, -(-self.estimated_rows // self.page_size)) @dataclass @@ -1034,8 +1089,13 @@ def jsonb_shape(self, schema: str, table: str, column: str, *, empty; the caller checks supports() first.""" raise NotImplementedError - def preview_rows(self, schema: str, table: str, limit: int = 50) -> Preview: - """First rows of a table. The Web equivalent of `SELECT * ... LIMIT n`.""" + def preview_rows(self, schema: str, table: str, limit: int = 1000, + offset: int = 0) -> Preview: + """テーブルの中身を 1 ページぶん。`SELECT * … ORDER BY 主キー LIMIT n OFFSET m`。 + + 並び順を決めないとページ送りで行が重複・欠落するので、主キーがあれば必ず + それで並べる。行数は数えず、統計の推定を返す。詳細は PostgresEngine 側に。 + """ raise NotImplementedError def run_query(self, sql: str, *, max_rows: int = 1000, diff --git a/core/engines/mysql.py b/core/engines/mysql.py index a9e1190..c12eb96 100644 --- a/core/engines/mysql.py +++ b/core/engines/mysql.py @@ -68,6 +68,8 @@ LIST_INDEXES_SQL, LIST_ROLES_SQL, LIST_TABLES_SQL, + PRIMARY_KEY_COLUMNS_SQL, + ESTIMATED_ROWS_SQL, TABLE_COMMENT_SQL, TABLE_SIZES_SQL, UNUSED_INDEXES_SQL, @@ -365,14 +367,33 @@ def table_comment(self, schema: str, table: str) -> str | None: # MySQL returns '' (not NULL) for no comment; normalise. return (row[0] or None) if row else None - def preview_rows(self, schema: str, table: str, limit: int = 50) -> Preview: - query = f"SELECT * FROM {_ident(schema)}.{_ident(table)} LIMIT %s" # nosec B608 — identifiers go through _ident (backtick-quoted); limit is bound as %s + def preview_rows(self, schema: str, table: str, limit: int = 1000, + offset: int = 0) -> Preview: + """PostgreSQL 側と同じ規則 ── 主キー順、行数は推定。理由はそちらに。""" + order_cols = self._primary_key_columns(schema, table) + order = "" + if order_cols: + order = " ORDER BY " + ", ".join(_ident(c) for c in order_cols) + query = (f"SELECT * FROM {_ident(schema)}.{_ident(table)}" # nosec B608 — identifiers go through _ident (backtick-quoted); limit/offset are bound as %s + f"{order} LIMIT %s OFFSET %s") with self._connect() as conn: with conn.cursor() as cur: - cur.execute(query, (limit,)) + cur.execute(query, (limit, offset)) columns = [d[0] for d in cur.description] rows = cur.fetchall() - return Preview(columns=columns, rows=list(rows)) + cur.execute(ESTIMATED_ROWS_SQL, (schema, table)) + row = cur.fetchone() + estimated = None if not row or row[0] is None else int(row[0]) + return Preview(columns=columns, rows=list(rows), offset=offset, + page_size=limit, estimated_rows=estimated, + ordered_by=order_cols) + + def _primary_key_columns(self, schema: str, table: str) -> list[str]: + """主キーの列を、定義順で。無ければ空。""" + with self._connect() as conn: + with conn.cursor() as cur: + cur.execute(PRIMARY_KEY_COLUMNS_SQL, (schema, table)) + return [r[0] for r in cur.fetchall()] # --- ad-hoc queries -------------------------------------------------- diff --git a/core/engines/mysql_sql.py b/core/engines/mysql_sql.py index 479e1b7..a4b98f4 100644 --- a/core/engines/mysql_sql.py +++ b/core/engines/mysql_sql.py @@ -197,3 +197,19 @@ WHERE object_schema = %s ORDER BY object_name, index_name; """ + + +# 主キーの列を定義順で。ページ送りの並び順に使う。 +PRIMARY_KEY_COLUMNS_SQL = """ +SELECT COLUMN_NAME +FROM information_schema.KEY_COLUMN_USAGE +WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND CONSTRAINT_NAME = 'PRIMARY' +ORDER BY ORDINAL_POSITION; +""" + +# 1 テーブルの推定行数。InnoDB では統計からの推定で、無ければ NULL。 +ESTIMATED_ROWS_SQL = """ +SELECT TABLE_ROWS +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s; +""" diff --git a/core/engines/pg_sql.py b/core/engines/pg_sql.py index 2ec88de..1a854fa 100644 --- a/core/engines/pg_sql.py +++ b/core/engines/pg_sql.py @@ -749,4 +749,26 @@ ) AS sml ORDER BY wasted_bytes DESC LIMIT {limit}; -""" \ No newline at end of file +""" + +# 主キーの列を定義順で。ページ送りの並び順に使う ── 順序を指定しないと +# PostgreSQL は行の順序を約束せず、ページをめくると重複や欠落が起きる。 +PRIMARY_KEY_COLUMNS_SQL = """ +SELECT a.attname +FROM pg_catalog.pg_index i +JOIN pg_catalog.pg_class c ON c.oid = i.indrelid +JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid + AND a.attnum = ANY(i.indkey) +WHERE n.nspname = %s AND c.relname = %s AND i.indisprimary +ORDER BY array_position(i.indkey, a.attnum); +""" + +# 1 テーブルの推定行数。count(*) は全走査になるので使わない。 +# -1 は「まだ ANALYZE していない」で、0 行とは違うので NULL にして返す。 +ESTIMATED_ROWS_SQL = """ +SELECT CASE WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples::bigint END +FROM pg_catalog.pg_class c +JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = %s AND c.relname = %s; +""" diff --git a/core/engines/postgres.py b/core/engines/postgres.py index a5ce9dc..fac284e 100644 --- a/core/engines/postgres.py +++ b/core/engines/postgres.py @@ -77,6 +77,8 @@ LIST_ROLES_SQL, LIST_SCHEMAS_SQL, LIST_TABLES_SQL, + PRIMARY_KEY_COLUMNS_SQL, + ESTIMATED_ROWS_SQL, NOT_VALID_FK_SQL, REPLICATION_STATUS_SQL, SETTINGS_SELECT, @@ -459,18 +461,48 @@ def jsonb_shape(self, schema: str, table: str, column: str, *, return JsonbShape(column=column, sampled=len(docs), root_types=root_types, keys=keys, max_depth=max_depth, gin_indexes=gin) - def preview_rows(self, schema: str, table: str, limit: int = 50) -> Preview: + def preview_rows(self, schema: str, table: str, limit: int = 1000, + offset: int = 0) -> Preview: + """テーブルの中身を 1 ページぶん。**主キー順**で返す。 + + 並び順を指定しない `LIMIT/OFFSET` は速いが、PostgreSQL は行の順序を + 約束しないので、**ページをめくると同じ行が二度出たり、どこにも出ない行が + できる**。ページャの体裁で嘘をつくことになるので、主キーがあれば必ずそれで + 並べる。実測(Airlines・236 万行): 主キー順の OFFSET は先頭 7 ms、 + 最深部でも 878 ms。順序なしなら 50 ms だが、そちらは正しくない。 + + 主キーが無いテーブルでは並べようがないので、`ordered_by` を空で返す + ── 画面はそれを見て「順序は保証されない」と断る。 + + 行数は数えない。`count(*)` は毎回の全走査になるので、プランナ統計の推定を + 使い、推定だと言う(TRUNCATE / DROP の確認と同じ規則)。 + """ # Identifiers come from the schema, but compose them safely anyway so a # table named `users; DROP …` can never break out of the query. - query = sql.SQL("SELECT * FROM {}.{} LIMIT %s").format( - sql.Identifier(schema), sql.Identifier(table) - ) + order_cols = self._primary_key_columns(schema, table) + query = sql.SQL("SELECT * FROM {}.{}").format( + sql.Identifier(schema), sql.Identifier(table)) + if order_cols: + query = query + sql.SQL(" ORDER BY ") + sql.SQL(", ").join( + sql.Identifier(c) for c in order_cols) + query = query + sql.SQL(" LIMIT %s OFFSET %s") with self._connect() as conn: with conn.cursor() as cur: - cur.execute(query, (limit,)) + cur.execute(query, (limit, offset)) columns = [d.name for d in cur.description] rows = cur.fetchall() - return Preview(columns=columns, rows=rows) + cur.execute(ESTIMATED_ROWS_SQL, (schema, table)) + row = cur.fetchone() + estimated = row[0] if row else None + return Preview(columns=columns, rows=rows, offset=offset, page_size=limit, + estimated_rows=estimated, ordered_by=order_cols) + + def _primary_key_columns(self, schema: str, table: str) -> list[str]: + """主キーの列を、定義順で。無ければ空。""" + with self._connect() as conn: + with conn.cursor() as cur: + cur.execute(PRIMARY_KEY_COLUMNS_SQL, (schema, table)) + return [r[0] for r in cur.fetchall()] def run_query(self, sql_text: str, *, max_rows: int = 1000, timeout_ms: int = 15000, read_only: bool = True, diff --git a/core/tests.py b/core/tests.py index 6c3be02..ff4ab0f 100644 --- a/core/tests.py +++ b/core/tests.py @@ -628,6 +628,97 @@ def test_never_analyzed_becomes_none_not_minus_one(self): self.assertIn("NULL", LIST_TABLES_SQL) +class PreviewPagingTests(SimpleTestCase): + """テーブルの中身のページ送り。 + + **並び順を決めずに OFFSET を使わない。** PostgreSQL は行の順序を約束しないので、 + ページをめくると同じ行が二度出たり、どこにも出ない行ができる ── ページャの + 体裁で嘘をつくことになる。実測(Airlines・236 万行): 主キー順の OFFSET は + 先頭 7 ms、最深部 878 ms。順序なしなら 50 ms だが、そちらは正しくない。 + """ + + @staticmethod + def _preview(**kw): + from core.engines.base import Preview + + kw.setdefault("columns", ["a"]) + kw.setdefault("rows", [(1,)] * kw.get("page_size", 1000)) + return Preview(**kw) + + def test_page_number_is_one_based(self): + self.assertEqual(self._preview(offset=0, page_size=1000).page, 1) + self.assertEqual(self._preview(offset=1000, page_size=1000).page, 2) + self.assertEqual(self._preview(offset=2359000, page_size=1000).page, 2360) + + def test_row_range_counts_from_one(self): + p = self._preview(offset=1000, page_size=1000) + self.assertEqual((p.first_row, p.last_row), (1001, 2000)) + + def test_empty_page_has_no_range(self): + # 0–0 行目、とは出さない。 + p = self._preview(offset=0, page_size=1000, rows=[]) + self.assertEqual((p.first_row, p.last_row), (0, 0)) + + def test_next_page_is_decided_by_rows_returned_not_the_estimate(self): + # 統計は古いことがある。「次へ」が空振りするのは、行数が少し違うより悪い。 + full = self._preview(page_size=1000, estimated_rows=5) + self.assertTrue(full.has_next) + partial = self._preview(page_size=1000, rows=[(1,)] * 3, + estimated_rows=1_000_000) + self.assertFalse(partial.has_next) + + def test_unknown_total_means_no_page_count(self): + # 統計が無ければページ数も出さない。0 ページとは言わない。 + self.assertIsNone(self._preview(estimated_rows=None).estimated_pages) + self.assertEqual(self._preview(estimated_rows=2360335, page_size=1000) + .estimated_pages, 2361) + + def test_no_primary_key_means_paging_is_not_stable(self): + self.assertFalse(self._preview().stable) + self.assertTrue(self._preview(ordered_by=["id"]).stable) + + def test_preview_sql_orders_by_the_primary_key(self): + # 順序なしの LIMIT/OFFSET に戻すと、ページ送りが静かに壊れる。 + import inspect + + from core.engines.postgres import PostgresEngine + + src = inspect.getsource(PostgresEngine.preview_rows) + self.assertIn("ORDER BY", src) + self.assertIn("_primary_key_columns", src) + + +class PagingParameterTests(SimpleTestCase): + """`?page=` と `?rows=` の受け取り。**画面から来た数を LIMIT に直接渡さない。**""" + + @staticmethod + def _paging(**params): + from django.test import RequestFactory + + from core.views.tables import _paging + + return _paging(RequestFactory().get("/", params)) + + def test_defaults(self): + from core.views.tables import DEFAULT_PAGE_SIZE + + self.assertEqual(self._paging(), (DEFAULT_PAGE_SIZE, 0)) + + def test_offset_follows_the_page(self): + self.assertEqual(self._paging(page="3", rows="100"), (100, 200)) + + def test_junk_falls_back_quietly(self): + # ここでエラーを出しても、利用者にできることが無い。 + from core.views.tables import DEFAULT_PAGE_SIZE + + for params in [{"page": "abc"}, {"page": "-5"}, {"rows": "99999"}, + {"rows": "0"}, {"rows": "; DROP TABLE x"}]: + with self.subTest(params=params): + size, offset = self._paging(**params) + self.assertIn(size, (DEFAULT_PAGE_SIZE, 100, 500, 1000, 5000)) + self.assertGreaterEqual(offset, 0) + + class ThousandSeparatorTests(SimpleTestCase): """**アプリが数えた数は区切る。利用者のデータは区切らない。** @@ -3645,6 +3736,9 @@ def test_add_rename_drop_column(self): page.goto(f"{self.live_server_url}/c/{self.conn.pk}/") page.locator(f'button[hx-get$="table={self.TBL}"]').click() page.locator("#detail h2").wait_for() + # 既定タブは Data(テーブル名を押した人が見たいのは中身)。 + # 列を触るテストは、まず Columns へ移る。 + page.get_by_role("button", name="Columns", exact=True).click() # Add a column via the drawer. page.get_by_role("button", name="+ Add column").first.click() @@ -3696,6 +3790,9 @@ def test_change_type_then_set_not_null(self): page.goto(f"{self.live_server_url}/c/{self.conn.pk}/") page.locator(f'button[hx-get$="table={self.TBL}"]').click() page.locator("#detail h2").wait_for() + # 既定タブは Data(テーブル名を押した人が見たいのは中身)。 + # 列を触るテストは、まず Columns へ移る。 + page.get_by_role("button", name="Columns", exact=True).click() # Open the edit drawer and change text → integer. self._amount_row().get_by_role("button", name="edit").click() diff --git a/core/views/tables.py b/core/views/tables.py index 71fde10..a976e04 100644 --- a/core/views/tables.py +++ b/core/views/tables.py @@ -11,12 +11,40 @@ from .connection import overview +# 1 ページに出す行数。既定は 1000 ── テーブル名を押した人が見たいのは中身で、 +# 50 行では「入っているものを見た」ことにならない。選べる値は固定の allow-list に +# する(画面から来た数をそのまま LIMIT に渡さないため)。 +PAGE_SIZES = (100, 500, 1000, 5000) +DEFAULT_PAGE_SIZE = 1000 + + +def _paging(request): + """`?page=` と `?rows=` を、範囲の決まった (page_size, offset) に直す。 + + 壊れた値・負の値・一覧に無い大きさは、黙って既定へ落とす ── ここで + エラーを出しても、利用者にできることが無い。 + """ + try: + size = int(request.GET.get("rows", DEFAULT_PAGE_SIZE)) + except (TypeError, ValueError): + size = DEFAULT_PAGE_SIZE + if size not in PAGE_SIZES: + size = DEFAULT_PAGE_SIZE + try: + page = max(1, int(request.GET.get("page", 1))) + except (TypeError, ValueError): + page = 1 + return size, (page - 1) * size + + def table_detail(request, pk): - """Columns + a row preview for one table (htmx partial into the main pane).""" + """Columns + a page of rows for one table (htmx partial into the main pane).""" connection = get_object_or_404(Connection, pk=pk) schema = request.GET.get("schema", "") table = request.GET.get("table", "") - return _render_detail(request, connection, schema, table) + size, offset = _paging(request) + return _render_detail(request, connection, schema, table, + page_size=size, offset=offset) def jsonb_shape(request, pk): @@ -63,7 +91,8 @@ def _write_previews(engine, schema, table): return out -def _render_detail(request, connection, schema, table, error=None, notice=None): +def _render_detail(request, connection, schema, table, error=None, notice=None, + page_size=DEFAULT_PAGE_SIZE, offset=0): """Render the table-detail panel: columns, indexes and a row preview. A connection-level failure falls back to the error partial; a per-action @@ -73,7 +102,7 @@ def _render_detail(request, connection, schema, table, error=None, notice=None): engine = get_engine(connection) columns = engine.list_columns(schema, table) indexes = engine.list_indexes(schema, table) - preview = engine.preview_rows(schema, table) + preview = engine.preview_rows(schema, table, limit=page_size, offset=offset) comment = engine.table_comment(schema, table) except EngineError as exc: return render(request, "partials/error.html", {"message": str(exc)}) @@ -104,6 +133,8 @@ def _render_detail(request, connection, schema, table, error=None, notice=None): "column_types": COLUMN_TYPES, "preview_columns": preview.columns, "preview_rows": rows, + "preview": preview, + "page_sizes": PAGE_SIZES, # Display-only: prefilled into the SQL editor as a starting point, # never executed server-side. The run path (run_query) is read-only # enforced by the DB. nosec B608 — not a query we build and execute. diff --git a/locale/ja/LC_MESSAGES/django.mo b/locale/ja/LC_MESSAGES/django.mo index 199fc73123b81c6ccecb37bc2b34234c988280ed..7f40620a0697fa33950f0b22fdffb5204998678f 100644 GIT binary patch delta 13363 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 PX7f1Vv4h`Wd~^IC^4tjP diff --git a/locale/ja/LC_MESSAGES/django.po b/locale/ja/LC_MESSAGES/django.po index f749b17..194b7c6 100644 --- a/locale/ja/LC_MESSAGES/django.po +++ b/locale/ja/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-04 13:25+0000\n" +"POT-Creation-Date: 2026-09-04 13:40+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -22,30 +22,30 @@ msgstr "" msgid "Unsupported database kind: %(kind)s" msgstr "未対応のデータベース種別: %(kind)s" -#: core/engines/mysql.py:439 core/engines/mysql.py:846 -#: core/engines/mysql.py:852 core/engines/postgres.py:434 -#: core/engines/postgres.py:535 core/engines/postgres.py:1205 +#: core/engines/mysql.py:460 core/engines/mysql.py:867 +#: core/engines/mysql.py:873 core/engines/postgres.py:436 +#: core/engines/postgres.py:567 core/engines/postgres.py:1237 #: planner_lab/rehearsal.py:86 #, python-format msgid "No such column: %(name)s" msgstr "存在しない列: %(name)s" -#: core/engines/mysql.py:441 core/engines/postgres.py:537 +#: core/engines/mysql.py:462 core/engines/postgres.py:569 #, python-format msgid "Unknown filter operator: %(op)s" msgstr "不明なフィルタ演算子: %(op)s" -#: core/engines/mysql.py:486 core/engines/mysql.py:491 -#: core/engines/postgres.py:587 +#: core/engines/mysql.py:507 core/engines/mysql.py:512 +#: core/engines/postgres.py:619 msgid "The CSV file is empty." msgstr "CSV ファイルが空です。" -#: core/engines/mysql.py:496 core/engines/postgres.py:594 +#: core/engines/mysql.py:517 core/engines/postgres.py:626 #, python-format msgid "CSV header has columns not in %(table)s: %(cols)s" msgstr "%(table)s に無い列が CSV ヘッダにあります: %(cols)s" -#: core/engines/mysql.py:655 +#: core/engines/mysql.py:676 msgid "" "Can't check for lock waits: the MySQL performance_schema is disabled on this " "server, so blocking can't be detected. Enable it (performance_schema=ON) to " @@ -55,7 +55,7 @@ msgstr "" "効なため、ブロッキングを検出できません。このパネルを使うには有効化してくださ" "い (performance_schema=ON)。" -#: core/engines/mysql.py:665 +#: core/engines/mysql.py:686 msgid "" "Can't check for lock waits: this needs MySQL 8.0+ " "(performance_schema.data_lock_waits is unavailable here)." @@ -63,7 +63,7 @@ msgstr "" "ロック待ちを確認できません: これには MySQL 8.0 以降が必要です " "(performance_schema.data_lock_waits がここでは利用できません)。" -#: core/engines/mysql.py:700 +#: core/engines/mysql.py:721 msgid "" "MySQL can't create a database from a template (no TEMPLATE/CREATE DATABASE … " "LIKE). Create it empty, then load a dump." @@ -71,7 +71,7 @@ msgstr "" "MySQL はテンプレートからデータベースを作成できません (TEMPLATE/CREATE " "DATABASE … LIKE は非対応)。空で作成してからダンプを読み込んでください。" -#: core/engines/mysql.py:713 +#: core/engines/mysql.py:734 msgid "" "Can't drop the database this connection is using — connect to another " "database first." @@ -79,7 +79,7 @@ msgstr "" "この接続が使用中のデータベースは削除できません — 先に別のデータベースに接続し" "てください。" -#: core/engines/mysql.py:719 +#: core/engines/mysql.py:740 msgid "" "MySQL doesn't support renaming a database. Create the new one and move the " "tables (RENAME TABLE), or dump and reload." @@ -87,44 +87,44 @@ msgstr "" "MySQL はデータベースのリネームに対応していません。新しいデータベースを作成し" "てテーブルを移動する (RENAME TABLE) か、ダンプして再読み込みしてください。" -#: core/engines/mysql.py:734 core/engines/postgres.py:174 +#: core/engines/mysql.py:755 core/engines/postgres.py:176 #, python-format msgid "Unsupported index method: %(method)s" msgstr "未対応のインデックス方式: %(method)s" -#: core/engines/mysql.py:736 core/engines/postgres.py:176 -#: core/views/tables.py:192 +#: core/engines/mysql.py:757 core/engines/postgres.py:178 +#: core/views/tables.py:223 msgid "Select at least one column to index." msgstr "インデックス対象の列を1つ以上選んでください。" -#: core/engines/mysql.py:743 core/engines/postgres.py:1103 +#: core/engines/mysql.py:764 core/engines/postgres.py:1135 #: planner_lab/whatif.py:115 #, python-format msgid "No such column(s): %(cols)s" msgstr "存在しない列: %(cols)s" -#: core/engines/mysql.py:756 +#: core/engines/mysql.py:777 msgid "Dropping an index needs the table it's on." msgstr "インデックスの削除には、対象のテーブルが必要です。" -#: core/engines/mysql.py:779 core/engines/mysql.py:808 +#: core/engines/mysql.py:800 core/engines/mysql.py:829 #, python-format msgid "Unsupported column type: %(t)s. Pick one of the listed types." msgstr "未対応の列型です: %(t)s。一覧の型から選んでください。" -#: core/engines/mysql.py:894 +#: core/engines/mysql.py:915 msgid "Orphan-row checks are a PostgreSQL feature here." msgstr "孤児行チェックはここでは PostgreSQL の機能です。" -#: core/engines/mysql.py:952 +#: core/engines/mysql.py:973 msgid "mysqldump timed out." msgstr "mysqldump がタイムアウトしました。" -#: core/engines/mysql.py:1002 core/engines/postgres.py:1322 +#: core/engines/mysql.py:1023 core/engines/postgres.py:1354 msgid "restore timed out." msgstr "リストアがタイムアウトしました。" -#: core/engines/mysql.py:1091 +#: core/engines/mysql.py:1112 msgid "" "MySQL has no replication slots — it streams the binary log directly, so " "there's nothing to create." @@ -132,18 +132,18 @@ msgstr "" "MySQL にレプリケーションスロットはありません — バイナリログを直接ストリーミン" "グするため、作成するものはありません。" -#: core/engines/mysql.py:1095 +#: core/engines/mysql.py:1116 msgid "MySQL has no replication slots — nothing to drop." msgstr "" "MySQL にレプリケーションスロットはありません — 削除するものはありません。" -#: core/engines/mysql.py:1185 core/engines/mysql.py:1190 -#: core/engines/mysql.py:1200 core/engines/postgres.py:1720 +#: core/engines/mysql.py:1206 core/engines/mysql.py:1211 +#: core/engines/mysql.py:1221 core/engines/postgres.py:1752 #, python-format msgid "Unknown setting: %(name)s" msgstr "不明な設定: %(name)s" -#: core/engines/mysql.py:1392 +#: core/engines/mysql.py:1413 #, python-format msgid "" "Gave up after waiting %(secs)ss for a lock on this object — another " @@ -154,25 +154,25 @@ msgstr "" "クションが保持しています。変更は行われていません。保持者は「ロック」パネルで" "確認し、解放されてから再実行してください。" -#: core/engines/postgres.py:437 +#: core/engines/postgres.py:439 #, python-format msgid "%(name)s is not a JSON column." msgstr "%(name)s は JSON 列ではありません。" -#: core/engines/postgres.py:1229 +#: core/engines/postgres.py:1261 #, python-format msgid "Unknown dump format: %(fmt)s" msgstr "不明なダンプ形式: %(fmt)s" -#: core/engines/postgres.py:1247 +#: core/engines/postgres.py:1279 msgid "pg_dump timed out." msgstr "pg_dump がタイムアウトしました。" -#: core/engines/postgres.py:1442 +#: core/engines/postgres.py:1474 msgid "That table no longer exists." msgstr "そのテーブルはもうありません。" -#: core/engines/postgres.py:1488 +#: core/engines/postgres.py:1520 msgid "" "This index no longer looks like a composite UNIQUE with a nullable column — " "it may have changed since the panel loaded." @@ -181,11 +181,11 @@ msgstr "" "てから作り替えられた可能性があります。この参照はもう単一の親に解決できません " "— パネルを開いてから変わった可能性があります。" -#: core/engines/postgres.py:1597 +#: core/engines/postgres.py:1629 msgid "No relationship to check was given." msgstr "チェックする関係が指定されていません。" -#: core/engines/postgres.py:1610 +#: core/engines/postgres.py:1642 msgid "" "This reference no longer resolves to a single parent — it may have changed " "since the panel loaded." @@ -193,7 +193,7 @@ msgstr "" "この参照はもう単一の親に解決できません — パネルを開いてから変わった可能性があ" "ります。" -#: core/engines/postgres.py:1805 +#: core/engines/postgres.py:1837 #, python-format msgid "" "Gave up after waiting %(timeout)s for a lock on this object — another " @@ -204,7 +204,7 @@ msgstr "" "クションが保持しています。変更は行われていません。保持者は「ロック」パネルで" "確認し、解放されてから再実行してください。" -#: core/engines/postgres.py:1814 +#: core/engines/postgres.py:1846 #, python-format msgid "" "Gave up after waiting %(timeout)s to measure table sizes — another " @@ -316,24 +316,24 @@ msgstr "エクスポートする結果がありません — 先にクエリを msgid "No table specified." msgstr "テーブルが指定されていません。" -#: core/views/tables.py:152 +#: core/views/tables.py:183 msgid "Choose a CSV file to import." msgstr "インポートする CSV ファイルを選んでください。" -#: core/views/tables.py:160 +#: core/views/tables.py:191 #, python-format msgid "Imported %(n)s row(s) from %(name)s." msgstr "%(name)s から %(n)s 行をインポートしました。" -#: core/views/tables.py:226 +#: core/views/tables.py:257 msgid "Enter a new table name." msgstr "新しいテーブル名を入力してください。" -#: core/views/tables.py:277 +#: core/views/tables.py:308 msgid "Enter a column name." msgstr "列名を入力してください。" -#: core/views/tables.py:297 +#: core/views/tables.py:328 msgid "Enter a new column name." msgstr "新しい列名を入力してください。" @@ -420,7 +420,7 @@ msgid "— click columns in order" msgstr "— 列を順にクリック" #: planner_lab/templates/partials/index_lab.html:64 -#: templates/partials/detail.html:151 templates/partials/detail.html:444 +#: templates/partials/detail.html:151 templates/partials/detail.html:490 msgid "unique" msgstr "ユニーク" @@ -570,14 +570,14 @@ msgid "Defaults point at the bundled sample DB." msgstr "初期値は同梱のサンプルDBを指しています。" #: templates/index.html:16 templates/partials/detail.html:70 -#: templates/partials/detail.html:326 templates/partials/detail.html:359 +#: templates/partials/detail.html:372 templates/partials/detail.html:405 #: templates/partials/replication.html:172 msgid "Type" msgstr "種別" #: templates/index.html:17 templates/partials/detail.html:69 -#: templates/partials/detail.html:138 templates/partials/detail.html:322 -#: templates/partials/detail.html:435 templates/partials/extensions.html:28 +#: templates/partials/detail.html:138 templates/partials/detail.html:368 +#: templates/partials/detail.html:481 templates/partials/extensions.html:28 #: templates/partials/extensions.html:64 templates/partials/objects.html:34 #: templates/partials/objects.html:95 templates/partials/objects.html:184 #: templates/partials/objects.html:252 templates/partials/objects.html:263 @@ -586,8 +586,8 @@ msgstr "種別" msgid "Name" msgstr "名前" -#: templates/index.html:17 templates/partials/detail.html:332 -#: templates/partials/detail.html:435 templates/partials/objects.html:188 +#: templates/index.html:17 templates/partials/detail.html:378 +#: templates/partials/detail.html:481 templates/partials/objects.html:188 #: templates/partials/objects.html:283 templates/partials/objects.html:310 msgid "(optional)" msgstr "(任意)" @@ -1094,7 +1094,7 @@ msgstr "データ" msgid "Danger" msgstr "危険" -#: templates/partials/detail.html:63 templates/partials/detail.html:336 +#: templates/partials/detail.html:63 templates/partials/detail.html:382 msgid "+ Add column" msgstr "+ 列を追加" @@ -1102,8 +1102,8 @@ msgstr "+ 列を追加" msgid "Nullable" msgstr "NULL 許容" -#: templates/partials/detail.html:72 templates/partials/detail.html:332 -#: templates/partials/detail.html:403 +#: templates/partials/detail.html:72 templates/partials/detail.html:378 +#: templates/partials/detail.html:449 msgid "Default" msgstr "デフォルト" @@ -1155,11 +1155,11 @@ msgstr "本番作成の前にインデックスを試して計測します" msgid "Try an index" msgstr "インデックスを試す" -#: templates/partials/detail.html:131 templates/partials/detail.html:445 +#: templates/partials/detail.html:131 templates/partials/detail.html:491 msgid "+ Create index" msgstr "+ インデックスを作成" -#: templates/partials/detail.html:140 templates/partials/detail.html:439 +#: templates/partials/detail.html:140 templates/partials/detail.html:485 msgid "Method" msgstr "方式" @@ -1228,19 +1228,74 @@ msgstr "フィルタを適用" msgid "All conditions are ANDed · read-only" msgstr "全条件は AND 結合・読み取り専用" -#: templates/partials/detail.html:241 +#: templates/partials/detail.html:223 +#, fuzzy, python-format +#| msgid "rows %(n)s" +msgid "rows %(a)s–%(b)s" +msgstr "行 %(n)s" + +#: templates/partials/detail.html:225 +#, fuzzy, python-format +#| msgid "Rows: about %(n)s (estimated from statistics, last analyzed %(d)s)" +msgid "of about %(n)s (estimated from statistics)" +msgstr "行数: 約 %(n)s(統計からの推定・最終 ANALYZE %(d)s)" + +#: templates/partials/detail.html:227 +#, fuzzy +#| msgid "Rows: unknown (this table has never been analyzed)" +msgid "of an unknown total — this table has never been analyzed" +msgstr "行数: 不明(このテーブルはまだ ANALYZE されていません)" + +#: templates/partials/detail.html:232 +msgid "" +"A table with no primary key has no defined row order, so paging can repeat " +"or skip rows." +msgstr "" +"主キーの無いテーブルには決まった行の順序がないので、ページをめくると同じ行が" +"二度出たり、どこにも出ない行ができることがあります。" + +#: templates/partials/detail.html:233 +msgid "⚠ No primary key — row order is not guaranteed between pages" +msgstr "⚠ 主キーが無いため、ページ間で行の順序は保証されません" + +#: templates/partials/detail.html:240 +msgid "← Prev" +msgstr "← 前" + +#: templates/partials/detail.html:244 +#, python-format +msgid "page %(p)s of about %(t)s" +msgstr "%(p)s / 約 %(t)s ページ" + +#: templates/partials/detail.html:246 +#, python-format +msgid "page %(p)s" +msgstr "%(p)s ページ" + +#: templates/partials/detail.html:251 +msgid "Next →" +msgstr "次 →" + +#: templates/partials/detail.html:257 +#, fuzzy, python-format +#| msgid "%(n)s row" +#| msgid_plural "%(n)s rows" +msgid "%(n)s rows" +msgstr "%(n)s 行" + +#: templates/partials/detail.html:287 msgid "This table is empty." msgstr "このテーブルは空です。" -#: templates/partials/detail.html:252 +#: templates/partials/detail.html:298 msgid "Truncate table" msgstr "テーブルを truncate" -#: templates/partials/detail.html:253 +#: templates/partials/detail.html:299 msgid "Delete every row. A safety snapshot is taken first." msgstr "全行を削除します。先に安全スナップショットを取得します。" -#: templates/partials/detail.html:256 +#: templates/partials/detail.html:302 #, python-format msgid "" "Truncate "%(schema)s.%(table)s"? This permanently deletes every " @@ -1249,81 +1304,81 @@ msgstr "" ""%(schema)s.%(table)s" を truncate しますか?全行が完全に削除されま" "す。" -#: templates/partials/detail.html:256 templates/partials/detail.html:268 +#: templates/partials/detail.html:302 templates/partials/detail.html:314 #: templates/partials/objects.html:67 templates/partials/objects.html:110 #: templates/partials/objects.html:154 msgid "This runs:" msgstr "実行される SQL:" -#: templates/partials/detail.html:256 templates/partials/detail.html:268 +#: templates/partials/detail.html:302 templates/partials/detail.html:314 msgid "Rows: unknown (this table has never been analyzed)" msgstr "行数: 不明(このテーブルはまだ ANALYZE されていません)" -#: templates/partials/detail.html:256 templates/partials/detail.html:268 +#: templates/partials/detail.html:302 templates/partials/detail.html:314 #, python-format msgid "Rows: about %(n)s (estimated from statistics, last analyzed %(d)s)" msgstr "行数: 約 %(n)s(統計からの推定・最終 ANALYZE %(d)s)" -#: templates/partials/detail.html:256 templates/partials/detail.html:268 +#: templates/partials/detail.html:302 templates/partials/detail.html:314 msgid "Referenced by:" msgstr "参照しているテーブル:" -#: templates/partials/detail.html:256 templates/partials/detail.html:268 +#: templates/partials/detail.html:302 templates/partials/detail.html:314 msgid "A DROP … CASCADE would also drop:" msgstr "DROP … CASCADE で一緒に消えるもの:" -#: templates/partials/detail.html:259 +#: templates/partials/detail.html:305 msgid "Truncate" msgstr "Truncate" -#: templates/partials/detail.html:264 templates/partials/detail.html:271 +#: templates/partials/detail.html:310 templates/partials/detail.html:317 msgid "Drop table" msgstr "テーブルを削除" -#: templates/partials/detail.html:265 +#: templates/partials/detail.html:311 msgid "Remove the table and all its data. A safety snapshot is taken first." msgstr "テーブルと全データを削除します。先に安全スナップショットを取得します。" -#: templates/partials/detail.html:268 +#: templates/partials/detail.html:314 #, python-format msgid "Drop table "%(schema)s.%(table)s"? This cannot be undone." msgstr "" "テーブル "%(schema)s.%(table)s" を削除しますか?元に戻せません。" -#: templates/partials/detail.html:282 +#: templates/partials/detail.html:328 msgid "Rename table" msgstr "テーブルをリネーム" -#: templates/partials/detail.html:282 +#: templates/partials/detail.html:328 msgid "Add column" msgstr "列を追加" -#: templates/partials/detail.html:283 +#: templates/partials/detail.html:329 msgid "Edit column" msgstr "列を編集" -#: templates/partials/detail.html:283 +#: templates/partials/detail.html:329 msgid "Create index" msgstr "インデックスを作成" -#: templates/partials/detail.html:284 templates/partials/detail.html:312 +#: templates/partials/detail.html:330 templates/partials/detail.html:358 msgid "Import CSV" msgstr "CSV インポート" -#: templates/partials/detail.html:295 templates/partials/objects.html:201 +#: templates/partials/detail.html:341 templates/partials/objects.html:201 msgid "New name" msgstr "新しい名前" -#: templates/partials/detail.html:297 templates/partials/detail.html:350 +#: templates/partials/detail.html:343 templates/partials/detail.html:396 #: templates/partials/objects.html:203 msgid "Rename" msgstr "リネーム" -#: templates/partials/detail.html:306 +#: templates/partials/detail.html:352 msgid "CSV file" msgstr "CSV ファイル" -#: templates/partials/detail.html:310 +#: templates/partials/detail.html:356 msgid "" "The first row must be a header; columns are matched to the table by name. " "Rows are appended in one transaction — if any row's types don't fit, the " @@ -1333,43 +1388,43 @@ msgstr "" "は1つのトランザクションで追加され、いずれかの行の型が合わない場合はインポート" "全体がロールバックされます。事前に安全のためのスナップショットを取得します。" -#: templates/partials/detail.html:323 +#: templates/partials/detail.html:369 msgid "column name" msgstr "列名" -#: templates/partials/detail.html:335 +#: templates/partials/detail.html:381 msgid "not null" msgstr "not null" -#: templates/partials/detail.html:347 +#: templates/partials/detail.html:393 msgid "Rename to" msgstr "リネーム先" -#: templates/partials/detail.html:362 +#: templates/partials/detail.html:408 msgid "(current)" msgstr "(現在)" -#: templates/partials/detail.html:365 +#: templates/partials/detail.html:411 msgid "Change" msgstr "変更" -#: templates/partials/detail.html:370 +#: templates/partials/detail.html:416 msgid "Nullability" msgstr "NULL許可" -#: templates/partials/detail.html:378 +#: templates/partials/detail.html:424 msgid "set NOT NULL" msgstr "NOT NULL を設定" -#: templates/partials/detail.html:378 +#: templates/partials/detail.html:424 msgid "drop NOT NULL" msgstr "NOT NULL を解除" -#: templates/partials/detail.html:389 +#: templates/partials/detail.html:435 msgid "Rehearse it" msgstr "リハーサル" -#: templates/partials/detail.html:394 +#: templates/partials/detail.html:440 msgid "" "Runs the real statement and rolls it back. It takes a real ACCESS EXCLUSIVE " "lock while it runs, so readers queue behind it — briefly, but for real." @@ -1377,23 +1432,23 @@ msgstr "" "本物の文を実行して、ロールバックする。実行中は本物の ACCESS EXCLUSIVE ロック" "を取るので、読み取りはその後ろに並ぶ。短いが、本物。" -#: templates/partials/detail.html:403 +#: templates/partials/detail.html:449 msgid "(empty drops it)" msgstr "(空で解除)" -#: templates/partials/detail.html:406 templates/partials/settings_row.html:42 +#: templates/partials/detail.html:452 templates/partials/settings_row.html:42 msgid "Set" msgstr "設定" -#: templates/partials/detail.html:422 +#: templates/partials/detail.html:468 msgid "Click columns in the order you want them indexed." msgstr "インデックスしたい順に列をクリックしてください。" -#: templates/partials/detail.html:436 +#: templates/partials/detail.html:482 msgid "index name" msgstr "インデックス名" -#: templates/partials/detail.html:446 +#: templates/partials/detail.html:492 msgid "built CONCURRENTLY" msgstr "CONCURRENTLY で作成" @@ -1575,11 +1630,11 @@ msgid "" "DISTINCT closes it; those indexes are not listed. Counting reads the " "table, read-only." msgstr "" -"NULL は別の NULL と等しくないので、キーに NULL を含む行は索引から見ると全部" -"別物になります。制約は効いていて、NULL 許容の列の形に穴が開いているだけです。" +"NULL は別の NULL と等しくないので、キーに NULL を含む行は索引から見ると全部別" +"物になります。制約は効いていて、NULL 許容の列の形に穴が開いているだけです。" "PostgreSQL 15 の NULLS NOT DISTINCT で塞げます" -"(そう作られた索引はここに出ません)。数えるときはテーブルを読みますが、" -"読み取りのみです。" +"(そう作られた索引はここに出ません)。数えるときはテーブルを読みますが、読み" +"取りのみです。" #: templates/partials/health.html:143 msgid "No composite UNIQUE has a nullable column — no hole to fit through. 🎉" @@ -2938,7 +2993,8 @@ msgstr "テーブルを絞り込み…" #: templates/partials/table_list.html:17 msgid "Statistics have never been collected for this table — run ANALYZE" -msgstr "このテーブルの統計がまだ集められていません(ANALYZE を実行してください)" +msgstr "" +"このテーブルの統計がまだ集められていません(ANALYZE を実行してください)" #: templates/partials/table_list.html:17 msgid "unknown" diff --git a/planner_lab/tests.py b/planner_lab/tests.py index 8a075f9..75e8259 100644 --- a/planner_lab/tests.py +++ b/planner_lab/tests.py @@ -256,6 +256,9 @@ def test_rehearse_reports_a_verdict_and_changes_nothing(self): page.goto(f"{self.live_server_url}/c/{self.conn.pk}/") page.locator(f'button[hx-get$="table={self.TBL}"]').click() page.locator("#detail h2").wait_for() + # 既定タブは Data(テーブル名を押した人が見たいのは中身)。 + # 列を触るテストは、まず Columns へ移る。 + page.get_by_role("button", name="Columns", exact=True).click() page.locator("#detail tbody tr", has_text="x").get_by_role( "button", name="edit").click() diff --git a/templates/partials/detail.html b/templates/partials/detail.html index 1ebb7f7..7ca9573 100644 --- a/templates/partials/detail.html +++ b/templates/partials/detail.html @@ -3,7 +3,20 @@ or, for one-click destructive ops, behind an hx-confirm. `drawer` holds the open form's kind; `ctx` carries the row the drawer is acting on. Submits target #detail, so the whole panel (drawer included) re-renders and resets.{% endcomment %} -
+

{{ schema }}.{{ table }}

@@ -214,6 +227,52 @@

{{ schema }}.{{ table }}

+ {% if preview %} + +
+ + {% blocktrans with a=preview.first_row|intcomma b=preview.last_row|intcomma %}rows {{ a }}–{{ b }}{% endblocktrans %} + {% if preview.estimated_rows is not None %} + / {% blocktrans with n=preview.estimated_rows|intcomma %}of about {{ n }} (estimated from statistics){% endblocktrans %} + {% else %} + / {% trans "of an unknown total — this table has never been analyzed" %} + {% endif %} + + + {% if not preview.stable %} + + {% trans "⚠ No primary key — row order is not guaranteed between pages" %} + + {% endif %} + + + {% if preview.has_previous %} + + {% endif %} + + {% if preview.estimated_pages %} + {% blocktrans with p=preview.page|intcomma t=preview.estimated_pages|intcomma %}page {{ p }} of about {{ t }}{% endblocktrans %} + {% else %} + {% blocktrans with p=preview.page|intcomma %}page {{ p }}{% endblocktrans %} + {% endif %} + + {% if preview.has_next %} + + {% endif %} + + +
+ {% endif %} +
From e12e510d099487c0c2c98325ca9baf398834e061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Fri, 4 Sep 2026 22:57:35 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=BF=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E3=81=92=E3=82=8B=E3=80=82=E3=83=8A=E3=83=93=E3=81=AE?= =?UTF-8?q?=E7=A9=BA=E3=81=8D=E3=81=AB=E7=8F=BE=E5=9C=A8=E5=9C=B0=E3=82=92?= =?UTF-8?q?=E5=87=BA=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **縦に 2 行詰めた。** - 見出しの下の「4 列 · 最大 1000 行をプレビュー」を消した。行数はページャが 「rows 1–1,000 of about 1,894,295」と正確に言うようになったので重複していて、 **同じことを 2 か所で言うと、いつか片方が古くなる** - 件数はタブのラベルへ移した(Columns 4 / Indexes 3)。切り替えなくても分かるので、 要約行そのものが要らなくなる - 間隔を space-y-5 → 3 **ナビの右側が空いていた。** ここに、いま開いているテーブル名を出す。#detail は htmx で丸ごと入れ替わり、ブラウザの履歴にも残らないので、そうしないと現在地が タイトルにしか無い。サイドバーの選択(Alpine の sel)を見ているだけなので、 サーバ往復も OOB swap も要らない。 **「— ホバーでセクションメニュー」は外した。** UI が自分の使い方を説明していた。 E2E のタブ選択は完全一致をやめて .subtab のクラスで取る(ラベルに件数が 入ったため)。315 件通過、未訳 0。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkRWmyQf9Rt3sYjjkPSq4Y --- core/tests.py | 8 +- locale/ja/LC_MESSAGES/django.mo | Bin 67999 -> 67656 bytes locale/ja/LC_MESSAGES/django.po | 232 +++++++++++++++----------------- planner_lab/tests.py | 2 +- templates/partials/detail.html | 12 +- templates/workspace.html | 11 +- 6 files changed, 126 insertions(+), 139 deletions(-) diff --git a/core/tests.py b/core/tests.py index ff4ab0f..e5c05c8 100644 --- a/core/tests.py +++ b/core/tests.py @@ -3541,7 +3541,7 @@ def test_create_then_drop_index(self): page.locator("#detail h2").wait_for() # Indexes subtab → open the Create index drawer. - page.get_by_role("button", name="Indexes", exact=True).click() + page.locator(".subtab", has_text="Indexes").click() page.get_by_role("button", name="+ Create index").first.click() create = page.locator('form[hx-post*="indexes/create"]') create.wait_for(state="visible") @@ -3556,7 +3556,7 @@ def test_create_then_drop_index(self): # The swap resets the panel to Columns; reopen Indexes to see the row, # with its columns in the chosen order. - page.get_by_role("button", name="Indexes", exact=True).click() + page.locator(".subtab", has_text="Indexes").click() row = page.locator("#detail tr", has_text=self.IDX) expect(row).to_contain_text("total, customer_id") @@ -3738,7 +3738,7 @@ def test_add_rename_drop_column(self): page.locator("#detail h2").wait_for() # 既定タブは Data(テーブル名を押した人が見たいのは中身)。 # 列を触るテストは、まず Columns へ移る。 - page.get_by_role("button", name="Columns", exact=True).click() + page.locator(".subtab", has_text="Columns").click() # Add a column via the drawer. page.get_by_role("button", name="+ Add column").first.click() @@ -3792,7 +3792,7 @@ def test_change_type_then_set_not_null(self): page.locator("#detail h2").wait_for() # 既定タブは Data(テーブル名を押した人が見たいのは中身)。 # 列を触るテストは、まず Columns へ移る。 - page.get_by_role("button", name="Columns", exact=True).click() + page.locator(".subtab", has_text="Columns").click() # Open the edit drawer and change text → integer. self._amount_row().get_by_role("button", name="edit").click() diff --git a/locale/ja/LC_MESSAGES/django.mo b/locale/ja/LC_MESSAGES/django.mo index 7f40620a0697fa33950f0b22fdffb5204998678f..19968733e967e7624cb921d902fada3ecedafbd1 100644 GIT binary patch delta 12758 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\n" "Language-Team: LANGUAGE \n" @@ -420,7 +420,7 @@ msgid "— click columns in order" msgstr "— 列を順にクリック" #: planner_lab/templates/partials/index_lab.html:64 -#: templates/partials/detail.html:151 templates/partials/detail.html:490 +#: templates/partials/detail.html:160 templates/partials/detail.html:499 msgid "unique" msgstr "ユニーク" @@ -569,15 +569,15 @@ msgstr "新しい接続" msgid "Defaults point at the bundled sample DB." msgstr "初期値は同梱のサンプルDBを指しています。" -#: templates/index.html:16 templates/partials/detail.html:70 -#: templates/partials/detail.html:372 templates/partials/detail.html:405 +#: templates/index.html:16 templates/partials/detail.html:79 +#: templates/partials/detail.html:381 templates/partials/detail.html:414 #: templates/partials/replication.html:172 msgid "Type" msgstr "種別" -#: templates/index.html:17 templates/partials/detail.html:69 -#: templates/partials/detail.html:138 templates/partials/detail.html:368 -#: templates/partials/detail.html:481 templates/partials/extensions.html:28 +#: templates/index.html:17 templates/partials/detail.html:78 +#: templates/partials/detail.html:147 templates/partials/detail.html:377 +#: templates/partials/detail.html:490 templates/partials/extensions.html:28 #: templates/partials/extensions.html:64 templates/partials/objects.html:34 #: templates/partials/objects.html:95 templates/partials/objects.html:184 #: templates/partials/objects.html:252 templates/partials/objects.html:263 @@ -586,8 +586,8 @@ msgstr "種別" msgid "Name" msgstr "名前" -#: templates/index.html:17 templates/partials/detail.html:378 -#: templates/partials/detail.html:481 templates/partials/objects.html:188 +#: templates/index.html:17 templates/partials/detail.html:387 +#: templates/partials/detail.html:490 templates/partials/objects.html:188 #: templates/partials/objects.html:283 templates/partials/objects.html:310 msgid "(optional)" msgstr "(任意)" @@ -889,7 +889,7 @@ msgstr "対象" msgid "Source DB" msgstr "元DB" -#: templates/partials/backups.html:17 templates/partials/detail.html:141 +#: templates/partials/backups.html:17 templates/partials/detail.html:150 #: templates/partials/health.html:69 templates/partials/health.html:169 #: templates/partials/health.html:270 templates/partials/health.html:423 #: templates/partials/objects.html:37 @@ -1018,8 +1018,8 @@ msgstr "子(参照する側)" msgid "Parent (referenced)" msgstr "親(参照される側)" -#: templates/partials/dependencies.html:88 templates/partials/detail.html:53 -#: templates/partials/detail.html:139 templates/partials/health.html:226 +#: templates/partials/dependencies.html:88 templates/partials/detail.html:62 +#: templates/partials/detail.html:148 templates/partials/health.html:226 #: templates/partials/health.html:316 msgid "Columns" msgstr "列" @@ -1028,107 +1028,93 @@ msgstr "列" msgid "Constraint" msgstr "制約" -#: templates/partials/detail.html:12 -#, python-format -msgid "%(nc)s column" -msgid_plural "%(nc)s columns" -msgstr[0] "%(nc)s 列" -msgstr[1] "%(nc)s 列" - -#: templates/partials/detail.html:13 -#, python-format -msgid "previewing up to %(nr)s row" -msgid_plural "previewing up to %(nr)s rows" -msgstr[0] "最大 %(nr)s 行をプレビュー" -msgstr[1] "最大 %(nr)s 行をプレビュー" - -#: templates/partials/detail.html:20 +#: templates/partials/detail.html:29 msgid "Open in SQL" msgstr "SQL で開く" -#: templates/partials/detail.html:24 +#: templates/partials/detail.html:33 msgid "backup" msgstr "バックアップ" -#: templates/partials/detail.html:26 +#: templates/partials/detail.html:35 msgid "pg_dump this table as plain SQL" msgstr "このテーブルを平文SQLで pg_dump" -#: templates/partials/detail.html:29 +#: templates/partials/detail.html:38 msgid "pg_dump this table in custom format" msgstr "このテーブルをカスタム形式で pg_dump" -#: templates/partials/detail.html:37 +#: templates/partials/detail.html:46 msgid "export" msgstr "エクスポート" -#: templates/partials/detail.html:39 +#: templates/partials/detail.html:48 msgid "Download every row as CSV" msgstr "全行を CSV でダウンロード" -#: templates/partials/detail.html:41 +#: templates/partials/detail.html:50 msgid "Download every row as JSON" msgstr "全行を JSON でダウンロード" -#: templates/partials/detail.html:44 +#: templates/partials/detail.html:53 msgid "Append rows from a CSV file" msgstr "CSV ファイルから行を追加" -#: templates/partials/detail.html:44 +#: templates/partials/detail.html:53 msgid "import" msgstr "インポート" -#: templates/partials/detail.html:45 templates/partials/objects.html:65 +#: templates/partials/detail.html:54 templates/partials/objects.html:65 msgid "rename" msgstr "リネーム" -#: templates/partials/detail.html:54 templates/partials/health.html:25 +#: templates/partials/detail.html:63 templates/partials/health.html:25 msgid "Indexes" msgstr "インデックス" -#: templates/partials/detail.html:55 +#: templates/partials/detail.html:64 msgid "Data" msgstr "データ" -#: templates/partials/detail.html:57 +#: templates/partials/detail.html:66 msgid "Danger" msgstr "危険" -#: templates/partials/detail.html:63 templates/partials/detail.html:382 +#: templates/partials/detail.html:72 templates/partials/detail.html:391 msgid "+ Add column" msgstr "+ 列を追加" -#: templates/partials/detail.html:71 templates/partials/health.html:114 +#: templates/partials/detail.html:80 templates/partials/health.html:114 msgid "Nullable" msgstr "NULL 許容" -#: templates/partials/detail.html:72 templates/partials/detail.html:378 -#: templates/partials/detail.html:449 +#: templates/partials/detail.html:81 templates/partials/detail.html:387 +#: templates/partials/detail.html:458 msgid "Default" msgstr "デフォルト" -#: templates/partials/detail.html:84 +#: templates/partials/detail.html:93 msgid "yes" msgstr "はい" -#: templates/partials/detail.html:84 +#: templates/partials/detail.html:93 msgid "no" msgstr "いいえ" -#: templates/partials/detail.html:87 +#: templates/partials/detail.html:96 msgid "generated" msgstr "生成列" -#: templates/partials/detail.html:99 +#: templates/partials/detail.html:108 msgid "shape" msgstr "構造" -#: templates/partials/detail.html:102 templates/partials/objects.html:108 +#: templates/partials/detail.html:111 templates/partials/objects.html:108 #: templates/partials/objects.html:152 msgid "edit" msgstr "編集" -#: templates/partials/detail.html:104 +#: templates/partials/detail.html:113 #, python-format msgid "" "Drop column "%(col)s" from %(schema)s.%(table)s? This permanently " @@ -1137,116 +1123,116 @@ msgstr "" "%(schema)s.%(table)s から列 "%(col)s" を削除しますか?データは完全" "に削除されます。" -#: templates/partials/detail.html:108 templates/partials/detail.html:166 +#: templates/partials/detail.html:117 templates/partials/detail.html:175 #: templates/partials/objects.html:69 templates/partials/objects.html:112 #: templates/partials/objects.html:156 templates/partials/replication.html:198 msgid "drop" msgstr "削除" -#: templates/partials/detail.html:118 +#: templates/partials/detail.html:127 msgid "Sampling…" msgstr "サンプリング中…" -#: templates/partials/detail.html:128 +#: templates/partials/detail.html:137 msgid "Try an index and measure it before creating it for real" msgstr "本番作成の前にインデックスを試して計測します" -#: templates/partials/detail.html:129 +#: templates/partials/detail.html:138 msgid "Try an index" msgstr "インデックスを試す" -#: templates/partials/detail.html:131 templates/partials/detail.html:491 +#: templates/partials/detail.html:140 templates/partials/detail.html:500 msgid "+ Create index" msgstr "+ インデックスを作成" -#: templates/partials/detail.html:140 templates/partials/detail.html:485 +#: templates/partials/detail.html:149 templates/partials/detail.html:494 msgid "Method" msgstr "方式" -#: templates/partials/detail.html:150 templates/partials/replication.html:26 +#: templates/partials/detail.html:159 templates/partials/replication.html:26 msgid "primary" msgstr "主キー" -#: templates/partials/detail.html:152 templates/partials/health.html:187 +#: templates/partials/detail.html:161 templates/partials/health.html:187 msgid "A concurrent build failed — this index is unusable; drop it." msgstr "並行ビルドに失敗 — このインデックスは使用不可です。削除してください。" -#: templates/partials/detail.html:152 +#: templates/partials/detail.html:161 msgid "invalid" msgstr "無効" -#: templates/partials/detail.html:159 +#: templates/partials/detail.html:168 msgid "pk" msgstr "pk" -#: templates/partials/detail.html:162 +#: templates/partials/detail.html:171 #, python-format msgid "Drop index "%(name)s"?" msgstr "インデックス "%(name)s" を削除しますか?" -#: templates/partials/detail.html:175 +#: templates/partials/detail.html:184 msgid "No indexes on this table yet." msgstr "このテーブルにはまだインデックスがありません。" -#: templates/partials/detail.html:190 +#: templates/partials/detail.html:199 msgid "column…" msgstr "列…" -#: templates/partials/detail.html:200 +#: templates/partials/detail.html:209 msgid "contains" msgstr "を含む" -#: templates/partials/detail.html:201 +#: templates/partials/detail.html:210 msgid "starts with" msgstr "で始まる" -#: templates/partials/detail.html:202 +#: templates/partials/detail.html:211 msgid "is null" msgstr "が NULL" -#: templates/partials/detail.html:203 +#: templates/partials/detail.html:212 msgid "is not null" msgstr "が NULL でない" -#: templates/partials/detail.html:206 +#: templates/partials/detail.html:215 msgid "value" msgstr "値" -#: templates/partials/detail.html:208 +#: templates/partials/detail.html:217 msgid "Remove condition" msgstr "条件を削除" -#: templates/partials/detail.html:212 +#: templates/partials/detail.html:221 msgid "+ Condition" msgstr "+ 条件" -#: templates/partials/detail.html:213 +#: templates/partials/detail.html:222 msgid "Apply filters" msgstr "フィルタを適用" -#: templates/partials/detail.html:214 +#: templates/partials/detail.html:223 msgid "All conditions are ANDed · read-only" msgstr "全条件は AND 結合・読み取り専用" -#: templates/partials/detail.html:223 +#: templates/partials/detail.html:232 #, fuzzy, python-format #| msgid "rows %(n)s" msgid "rows %(a)s–%(b)s" msgstr "行 %(n)s" -#: templates/partials/detail.html:225 +#: templates/partials/detail.html:234 #, fuzzy, python-format #| msgid "Rows: about %(n)s (estimated from statistics, last analyzed %(d)s)" msgid "of about %(n)s (estimated from statistics)" msgstr "行数: 約 %(n)s(統計からの推定・最終 ANALYZE %(d)s)" -#: templates/partials/detail.html:227 +#: templates/partials/detail.html:236 #, fuzzy #| msgid "Rows: unknown (this table has never been analyzed)" msgid "of an unknown total — this table has never been analyzed" msgstr "行数: 不明(このテーブルはまだ ANALYZE されていません)" -#: templates/partials/detail.html:232 +#: templates/partials/detail.html:241 msgid "" "A table with no primary key has no defined row order, so paging can repeat " "or skip rows." @@ -1254,48 +1240,48 @@ msgstr "" "主キーの無いテーブルには決まった行の順序がないので、ページをめくると同じ行が" "二度出たり、どこにも出ない行ができることがあります。" -#: templates/partials/detail.html:233 +#: templates/partials/detail.html:242 msgid "⚠ No primary key — row order is not guaranteed between pages" msgstr "⚠ 主キーが無いため、ページ間で行の順序は保証されません" -#: templates/partials/detail.html:240 +#: templates/partials/detail.html:249 msgid "← Prev" msgstr "← 前" -#: templates/partials/detail.html:244 +#: templates/partials/detail.html:253 #, python-format msgid "page %(p)s of about %(t)s" msgstr "%(p)s / 約 %(t)s ページ" -#: templates/partials/detail.html:246 +#: templates/partials/detail.html:255 #, python-format msgid "page %(p)s" msgstr "%(p)s ページ" -#: templates/partials/detail.html:251 +#: templates/partials/detail.html:260 msgid "Next →" msgstr "次 →" -#: templates/partials/detail.html:257 +#: templates/partials/detail.html:266 #, fuzzy, python-format #| msgid "%(n)s row" #| msgid_plural "%(n)s rows" msgid "%(n)s rows" msgstr "%(n)s 行" -#: templates/partials/detail.html:287 +#: templates/partials/detail.html:296 msgid "This table is empty." msgstr "このテーブルは空です。" -#: templates/partials/detail.html:298 +#: templates/partials/detail.html:307 msgid "Truncate table" msgstr "テーブルを truncate" -#: templates/partials/detail.html:299 +#: templates/partials/detail.html:308 msgid "Delete every row. A safety snapshot is taken first." msgstr "全行を削除します。先に安全スナップショットを取得します。" -#: templates/partials/detail.html:302 +#: templates/partials/detail.html:311 #, python-format msgid "" "Truncate "%(schema)s.%(table)s"? This permanently deletes every " @@ -1304,81 +1290,81 @@ msgstr "" ""%(schema)s.%(table)s" を truncate しますか?全行が完全に削除されま" "す。" -#: templates/partials/detail.html:302 templates/partials/detail.html:314 +#: templates/partials/detail.html:311 templates/partials/detail.html:323 #: templates/partials/objects.html:67 templates/partials/objects.html:110 #: templates/partials/objects.html:154 msgid "This runs:" msgstr "実行される SQL:" -#: templates/partials/detail.html:302 templates/partials/detail.html:314 +#: templates/partials/detail.html:311 templates/partials/detail.html:323 msgid "Rows: unknown (this table has never been analyzed)" msgstr "行数: 不明(このテーブルはまだ ANALYZE されていません)" -#: templates/partials/detail.html:302 templates/partials/detail.html:314 +#: templates/partials/detail.html:311 templates/partials/detail.html:323 #, python-format msgid "Rows: about %(n)s (estimated from statistics, last analyzed %(d)s)" msgstr "行数: 約 %(n)s(統計からの推定・最終 ANALYZE %(d)s)" -#: templates/partials/detail.html:302 templates/partials/detail.html:314 +#: templates/partials/detail.html:311 templates/partials/detail.html:323 msgid "Referenced by:" msgstr "参照しているテーブル:" -#: templates/partials/detail.html:302 templates/partials/detail.html:314 +#: templates/partials/detail.html:311 templates/partials/detail.html:323 msgid "A DROP … CASCADE would also drop:" msgstr "DROP … CASCADE で一緒に消えるもの:" -#: templates/partials/detail.html:305 +#: templates/partials/detail.html:314 msgid "Truncate" msgstr "Truncate" -#: templates/partials/detail.html:310 templates/partials/detail.html:317 +#: templates/partials/detail.html:319 templates/partials/detail.html:326 msgid "Drop table" msgstr "テーブルを削除" -#: templates/partials/detail.html:311 +#: templates/partials/detail.html:320 msgid "Remove the table and all its data. A safety snapshot is taken first." msgstr "テーブルと全データを削除します。先に安全スナップショットを取得します。" -#: templates/partials/detail.html:314 +#: templates/partials/detail.html:323 #, python-format msgid "Drop table "%(schema)s.%(table)s"? This cannot be undone." msgstr "" "テーブル "%(schema)s.%(table)s" を削除しますか?元に戻せません。" -#: templates/partials/detail.html:328 +#: templates/partials/detail.html:337 msgid "Rename table" msgstr "テーブルをリネーム" -#: templates/partials/detail.html:328 +#: templates/partials/detail.html:337 msgid "Add column" msgstr "列を追加" -#: templates/partials/detail.html:329 +#: templates/partials/detail.html:338 msgid "Edit column" msgstr "列を編集" -#: templates/partials/detail.html:329 +#: templates/partials/detail.html:338 msgid "Create index" msgstr "インデックスを作成" -#: templates/partials/detail.html:330 templates/partials/detail.html:358 +#: templates/partials/detail.html:339 templates/partials/detail.html:367 msgid "Import CSV" msgstr "CSV インポート" -#: templates/partials/detail.html:341 templates/partials/objects.html:201 +#: templates/partials/detail.html:350 templates/partials/objects.html:201 msgid "New name" msgstr "新しい名前" -#: templates/partials/detail.html:343 templates/partials/detail.html:396 +#: templates/partials/detail.html:352 templates/partials/detail.html:405 #: templates/partials/objects.html:203 msgid "Rename" msgstr "リネーム" -#: templates/partials/detail.html:352 +#: templates/partials/detail.html:361 msgid "CSV file" msgstr "CSV ファイル" -#: templates/partials/detail.html:356 +#: templates/partials/detail.html:365 msgid "" "The first row must be a header; columns are matched to the table by name. " "Rows are appended in one transaction — if any row's types don't fit, the " @@ -1388,43 +1374,43 @@ msgstr "" "は1つのトランザクションで追加され、いずれかの行の型が合わない場合はインポート" "全体がロールバックされます。事前に安全のためのスナップショットを取得します。" -#: templates/partials/detail.html:369 +#: templates/partials/detail.html:378 msgid "column name" msgstr "列名" -#: templates/partials/detail.html:381 +#: templates/partials/detail.html:390 msgid "not null" msgstr "not null" -#: templates/partials/detail.html:393 +#: templates/partials/detail.html:402 msgid "Rename to" msgstr "リネーム先" -#: templates/partials/detail.html:408 +#: templates/partials/detail.html:417 msgid "(current)" msgstr "(現在)" -#: templates/partials/detail.html:411 +#: templates/partials/detail.html:420 msgid "Change" msgstr "変更" -#: templates/partials/detail.html:416 +#: templates/partials/detail.html:425 msgid "Nullability" msgstr "NULL許可" -#: templates/partials/detail.html:424 +#: templates/partials/detail.html:433 msgid "set NOT NULL" msgstr "NOT NULL を設定" -#: templates/partials/detail.html:424 +#: templates/partials/detail.html:433 msgid "drop NOT NULL" msgstr "NOT NULL を解除" -#: templates/partials/detail.html:435 +#: templates/partials/detail.html:444 msgid "Rehearse it" msgstr "リハーサル" -#: templates/partials/detail.html:440 +#: templates/partials/detail.html:449 msgid "" "Runs the real statement and rolls it back. It takes a real ACCESS EXCLUSIVE " "lock while it runs, so readers queue behind it — briefly, but for real." @@ -1432,27 +1418,27 @@ msgstr "" "本物の文を実行して、ロールバックする。実行中は本物の ACCESS EXCLUSIVE ロック" "を取るので、読み取りはその後ろに並ぶ。短いが、本物。" -#: templates/partials/detail.html:449 +#: templates/partials/detail.html:458 msgid "(empty drops it)" msgstr "(空で解除)" -#: templates/partials/detail.html:452 templates/partials/settings_row.html:42 +#: templates/partials/detail.html:461 templates/partials/settings_row.html:42 msgid "Set" msgstr "設定" -#: templates/partials/detail.html:468 +#: templates/partials/detail.html:477 msgid "Click columns in the order you want them indexed." msgstr "インデックスしたい順に列をクリックしてください。" -#: templates/partials/detail.html:482 +#: templates/partials/detail.html:491 msgid "index name" msgstr "インデックス名" -#: templates/partials/detail.html:492 +#: templates/partials/detail.html:501 msgid "built CONCURRENTLY" msgstr "CONCURRENTLY で作成" -#: templates/partials/error.html:2 templates/workspace.html:106 +#: templates/partials/error.html:2 templates/workspace.html:115 msgid "Could not connect" msgstr "接続できませんでした" @@ -3025,14 +3011,10 @@ msgstr "" msgid "overview" msgstr "概要" -#: templates/workspace.html:75 -msgid "— hover for the section menu" -msgstr "— ホバーでセクションメニュー" - -#: templates/workspace.html:100 +#: templates/workspace.html:109 msgid "+ New connection" msgstr "+ 新しい接続" -#: templates/workspace.html:108 +#: templates/workspace.html:117 msgid "back to connections" msgstr "接続一覧に戻る" diff --git a/planner_lab/tests.py b/planner_lab/tests.py index 75e8259..1ba861b 100644 --- a/planner_lab/tests.py +++ b/planner_lab/tests.py @@ -258,7 +258,7 @@ def test_rehearse_reports_a_verdict_and_changes_nothing(self): page.locator("#detail h2").wait_for() # 既定タブは Data(テーブル名を押した人が見たいのは中身)。 # 列を触るテストは、まず Columns へ移る。 - page.get_by_role("button", name="Columns", exact=True).click() + page.locator(".subtab", has_text="Columns").click() page.locator("#detail tbody tr", has_text="x").get_by_role( "button", name="edit").click() diff --git a/templates/partials/detail.html b/templates/partials/detail.html index 7ca9573..de20b02 100644 --- a/templates/partials/detail.html +++ b/templates/partials/detail.html @@ -3,7 +3,7 @@ or, for one-click destructive ops, behind an hx-confirm. `drawer` holds the open form's kind; `ctx` carries the row the drawer is acting on. Submits target #detail, so the whole panel (drawer included) re-renders and resets.{% endcomment %} -

{{ schema }}.{{ table }}

-

- {% blocktrans count nc=columns|length %}{{ nc }} column{% plural %}{{ nc }} columns{% endblocktrans %} · - {% blocktrans count nr=preview_rows|length %}previewing up to {{ nr }} row{% plural %}previewing up to {{ nr }} rows{% endblocktrans %} -

- {% if table_comment %}

{{ table_comment }}

{% endif %} + {% if table_comment %}

{{ table_comment }}

{% endif %}
- + + diff --git a/templates/workspace.html b/templates/workspace.html index f84a504..a4f4efe 100644 --- a/templates/workspace.html +++ b/templates/workspace.html @@ -71,7 +71,16 @@
- + + {# バーの右側。**いま何を見ているか**を置く ── #detail は htmx で丸ごと + 入れ替わり、ブラウザの履歴にも残らないので、そうでないと現在地が + タイトルにしか無い。テーブルを開いたときだけ出す(サイドバーの選択 + `sel` を見ている。ここは htmx の差し替え範囲の外なので、サーバ往復も + OOB swap も要らない)。 #} + + / + + {% endblock %} From 2247ffb7eab2c131ad1e93f0a1be26a6eddd5b05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Fri, 4 Sep 2026 23:03:26 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E6=B3=A8=E9=87=88=E3=81=8C=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E3=81=AB=E5=87=BA=E3=81=A6=E3=81=84=E3=81=9F=E3=81=AE?= =?UTF-8?q?=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `
` と書いた。Django は**タグの属性の中の `{#…#}` を注釈として解釈しない**ので、説明文がそのまま画面に出ていた。 ヘッダの真ん中に、私が書いた実装メモが表示されていた。2 か所やった。 `{% comment %}` に直し、置く場所もタグの外にした。HTML コメントでも画面には 出ないが、ソースには残るので使わない(他のテンプレートと揃える)。 目で見張るものではないので、テンプレートを走査して**タグの中に `{#` があれば 落ちる**テストを置いた。直す前の状態で detail.html:6 を名指しで捕まえることを 確認済み。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkRWmyQf9Rt3sYjjkPSq4Y --- core/tests.py | 28 ++++++++++++++++++++++++++++ templates/partials/detail.html | 16 ++++++++++------ templates/workspace.html | 9 ++++----- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/core/tests.py b/core/tests.py index e5c05c8..1889ff8 100644 --- a/core/tests.py +++ b/core/tests.py @@ -628,6 +628,34 @@ def test_never_analyzed_becomes_none_not_minus_one(self): self.assertIn("NULL", LIST_TABLES_SQL) +class TemplateCommentPlacementTests(SimpleTestCase): + """注釈がタグの属性の中に無いこと。 + + 2026-09-04 の実害: `
` と書いたら、Django は + 属性の中の `{#…#}` を注釈として解釈せず、**そのまま画面に出た**。2 か所やった。 + ヘッダの真ん中に説明文が表示されていた。 + + タグの外に置くか、`{% comment %}` を使えば起きない。目で見張るものではないので + 機械に見せる。 + """ + + def test_no_comment_inside_a_tag(self): + import pathlib + import re + + root = pathlib.Path(__file__).resolve().parent.parent / "templates" + offenders = [] + for path in sorted(root.rglob("*.html")): + text = path.read_text(encoding="utf-8") + # `<` から `>` までの間に `{#` があれば、それは属性の中。 + for m in re.finditer(r"<[a-zA-Z][^>]*?\{#", text, re.S): + line = text[: m.start()].count("\n") + 1 + offenders.append(f"{path.name}:{line}") + self.assertEqual( + offenders, [], + "タグの中に {# #} がある(画面にそのまま出る): " + ", ".join(offenders)) + + class PreviewPagingTests(SimpleTestCase): """テーブルの中身のページ送り。 diff --git a/templates/partials/detail.html b/templates/partials/detail.html index de20b02..84e0298 100644 --- a/templates/partials/detail.html +++ b/templates/partials/detail.html @@ -3,13 +3,17 @@ or, for one-click destructive ops, behind an hx-confirm. `drawer` holds the open form's kind; `ctx` carries the row the drawer is acting on. Submits target #detail, so the whole panel (drawer included) re-renders and resets.{% endcomment %} -
/ From 8ce5ca65450a7053fd21d2aae718b614c5054bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Fri, 4 Sep 2026 23:06:25 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=BF=E3=81=AE?= =?UTF-8?q?=E3=82=B0=E3=83=AA=E3=83=83=E3=83=89=E3=81=A0=E3=81=91=E3=81=8C?= =?UTF-8?q?=E3=82=B9=E3=82=AF=E3=83=AD=E3=83=BC=E3=83=AB=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1000 行あるので、ページ全体が動くと列名もページ送りも画面の外へ流れていた。 - グリッドを overflow-auto max-h-[60vh] にし、列名を sticky に。 SQL ランナーと絞り込みの結果は既にそうなっていて、**ここだけ揃っていなかった** - ページャも上に貼り付ける。1000 行を送った先で「いま何ページ目か」を 見に戻るのに、上まで戻らなくていい Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XkRWmyQf9Rt3sYjjkPSq4Y --- templates/partials/detail.html | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/templates/partials/detail.html b/templates/partials/detail.html index 84e0298..1140034 100644 --- a/templates/partials/detail.html +++ b/templates/partials/detail.html @@ -230,7 +230,11 @@

{{ schema }}.{{ table }}

{% if preview %} -
+ {% comment %}ページャはグリッドの上に貼り付ける。グリッドだけがスクロールする + ので流れはしないが、1000 行を送った先で「いま何ページ目か」を見に戻るのに + 上まで戻る必要はない。{% endcomment %} +
{% blocktrans with a=preview.first_row|intcomma b=preview.last_row|intcomma %}rows {{ a }}–{{ b }}{% endblocktrans %} {% if preview.estimated_rows is not None %} @@ -277,9 +281,12 @@

{{ schema }}.{{ table }}

{% if preview_rows %} -
+ {% comment %}グリッドだけが縦にスクロールする。1000 行あるので、ページ全体が + 動くと列名もページ送りも画面の外へ流れてしまう。SQL ランナーと絞り込みの + 結果は既にこうなっていて、ここだけ揃っていなかった。{% endcomment %} +
- + {% for c in preview_columns %}{% endfor %}
{{ c }}