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..1889ff8 100644 --- a/core/tests.py +++ b/core/tests.py @@ -628,6 +628,125 @@ 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): + """テーブルの中身のページ送り。 + + **並び順を決めずに 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): """**アプリが数えた数は区切る。利用者のデータは区切らない。** @@ -3450,7 +3569,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") @@ -3465,7 +3584,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") @@ -3645,6 +3764,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.locator(".subtab", has_text="Columns").click() # Add a column via the drawer. page.get_by_role("button", name="+ Add column").first.click() @@ -3696,6 +3818,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.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/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 199fc73..1996873 100644 Binary files a/locale/ja/LC_MESSAGES/django.mo and b/locale/ja/LC_MESSAGES/django.mo differ diff --git a/locale/ja/LC_MESSAGES/django.po b/locale/ja/LC_MESSAGES/django.po index f749b17..559593d 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:53+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: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:326 templates/partials/detail.html:359 +#: 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:322 -#: templates/partials/detail.html:435 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:332 -#: templates/partials/detail.html:435 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:336 +#: 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:332 -#: templates/partials/detail.html:403 +#: 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,110 +1123,165 @@ 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:445 +#: templates/partials/detail.html:140 templates/partials/detail.html:500 msgid "+ Create index" msgstr "+ インデックスを作成" -#: templates/partials/detail.html:140 templates/partials/detail.html:439 +#: 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:232 +#, fuzzy, python-format +#| msgid "rows %(n)s" +msgid "rows %(a)s–%(b)s" +msgstr "行 %(n)s" + +#: 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: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:241 +msgid "" +"A table with no primary key has no defined row order, so paging can repeat " +"or skip rows." +msgstr "" +"主キーの無いテーブルには決まった行の順序がないので、ページをめくると同じ行が" +"二度出たり、どこにも出ない行ができることがあります。" + +#: templates/partials/detail.html:242 +msgid "⚠ No primary key — row order is not guaranteed between pages" +msgstr "⚠ 主キーが無いため、ページ間で行の順序は保証されません" + +#: templates/partials/detail.html:249 +msgid "← Prev" +msgstr "← 前" + +#: templates/partials/detail.html:253 +#, python-format +msgid "page %(p)s of about %(t)s" +msgstr "%(p)s / 約 %(t)s ページ" + +#: templates/partials/detail.html:255 +#, python-format +msgid "page %(p)s" +msgstr "%(p)s ページ" + +#: templates/partials/detail.html:260 +msgid "Next →" +msgstr "次 →" + +#: 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:296 msgid "This table is empty." msgstr "このテーブルは空です。" -#: templates/partials/detail.html:252 +#: templates/partials/detail.html:307 msgid "Truncate table" msgstr "テーブルを truncate" -#: templates/partials/detail.html:253 +#: templates/partials/detail.html:308 msgid "Delete every row. A safety snapshot is taken first." msgstr "全行を削除します。先に安全スナップショットを取得します。" -#: templates/partials/detail.html:256 +#: templates/partials/detail.html:311 #, python-format msgid "" "Truncate "%(schema)s.%(table)s"? This permanently deletes every " @@ -1249,81 +1290,81 @@ msgstr "" ""%(schema)s.%(table)s" を truncate しますか?全行が完全に削除されま" "す。" -#: templates/partials/detail.html:256 templates/partials/detail.html:268 +#: 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:256 templates/partials/detail.html:268 +#: 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:256 templates/partials/detail.html:268 +#: 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:256 templates/partials/detail.html:268 +#: templates/partials/detail.html:311 templates/partials/detail.html:323 msgid "Referenced by:" msgstr "参照しているテーブル:" -#: templates/partials/detail.html:256 templates/partials/detail.html:268 +#: templates/partials/detail.html:311 templates/partials/detail.html:323 msgid "A DROP … CASCADE would also drop:" msgstr "DROP … CASCADE で一緒に消えるもの:" -#: templates/partials/detail.html:259 +#: templates/partials/detail.html:314 msgid "Truncate" msgstr "Truncate" -#: templates/partials/detail.html:264 templates/partials/detail.html:271 +#: templates/partials/detail.html:319 templates/partials/detail.html:326 msgid "Drop table" msgstr "テーブルを削除" -#: templates/partials/detail.html:265 +#: templates/partials/detail.html:320 msgid "Remove the table and all its data. A safety snapshot is taken first." msgstr "テーブルと全データを削除します。先に安全スナップショットを取得します。" -#: templates/partials/detail.html:268 +#: 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:282 +#: templates/partials/detail.html:337 msgid "Rename table" msgstr "テーブルをリネーム" -#: templates/partials/detail.html:282 +#: templates/partials/detail.html:337 msgid "Add column" msgstr "列を追加" -#: templates/partials/detail.html:283 +#: templates/partials/detail.html:338 msgid "Edit column" msgstr "列を編集" -#: templates/partials/detail.html:283 +#: templates/partials/detail.html:338 msgid "Create index" msgstr "インデックスを作成" -#: templates/partials/detail.html:284 templates/partials/detail.html:312 +#: templates/partials/detail.html:339 templates/partials/detail.html:367 msgid "Import CSV" msgstr "CSV インポート" -#: templates/partials/detail.html:295 templates/partials/objects.html:201 +#: templates/partials/detail.html:350 templates/partials/objects.html:201 msgid "New name" msgstr "新しい名前" -#: templates/partials/detail.html:297 templates/partials/detail.html:350 +#: templates/partials/detail.html:352 templates/partials/detail.html:405 #: templates/partials/objects.html:203 msgid "Rename" msgstr "リネーム" -#: templates/partials/detail.html:306 +#: templates/partials/detail.html:361 msgid "CSV file" msgstr "CSV ファイル" -#: templates/partials/detail.html:310 +#: 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 " @@ -1333,43 +1374,43 @@ msgstr "" "は1つのトランザクションで追加され、いずれかの行の型が合わない場合はインポート" "全体がロールバックされます。事前に安全のためのスナップショットを取得します。" -#: templates/partials/detail.html:323 +#: templates/partials/detail.html:378 msgid "column name" msgstr "列名" -#: templates/partials/detail.html:335 +#: templates/partials/detail.html:390 msgid "not null" msgstr "not null" -#: templates/partials/detail.html:347 +#: templates/partials/detail.html:402 msgid "Rename to" msgstr "リネーム先" -#: templates/partials/detail.html:362 +#: templates/partials/detail.html:417 msgid "(current)" msgstr "(現在)" -#: templates/partials/detail.html:365 +#: templates/partials/detail.html:420 msgid "Change" msgstr "変更" -#: templates/partials/detail.html:370 +#: templates/partials/detail.html:425 msgid "Nullability" msgstr "NULL許可" -#: templates/partials/detail.html:378 +#: templates/partials/detail.html:433 msgid "set NOT NULL" msgstr "NOT NULL を設定" -#: templates/partials/detail.html:378 +#: templates/partials/detail.html:433 msgid "drop NOT NULL" msgstr "NOT NULL を解除" -#: templates/partials/detail.html:389 +#: templates/partials/detail.html:444 msgid "Rehearse it" msgstr "リハーサル" -#: templates/partials/detail.html:394 +#: 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." @@ -1377,27 +1418,27 @@ msgstr "" "本物の文を実行して、ロールバックする。実行中は本物の ACCESS EXCLUSIVE ロック" "を取るので、読み取りはその後ろに並ぶ。短いが、本物。" -#: templates/partials/detail.html:403 +#: templates/partials/detail.html:458 msgid "(empty drops it)" msgstr "(空で解除)" -#: templates/partials/detail.html:406 templates/partials/settings_row.html:42 +#: templates/partials/detail.html:461 templates/partials/settings_row.html:42 msgid "Set" msgstr "設定" -#: templates/partials/detail.html:422 +#: templates/partials/detail.html:477 msgid "Click columns in the order you want them indexed." msgstr "インデックスしたい順に列をクリックしてください。" -#: templates/partials/detail.html:436 +#: templates/partials/detail.html:491 msgid "index name" msgstr "インデックス名" -#: templates/partials/detail.html:446 +#: 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 "接続できませんでした" @@ -1575,11 +1616,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 +2979,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" @@ -2969,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 8a075f9..1ba861b 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.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 1ebb7f7..1140034 100644 --- a/templates/partials/detail.html +++ b/templates/partials/detail.html @@ -3,15 +3,28 @@ 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 %} -
+{% comment %}既定タブは Data ── テーブル名を押した人が見たいのは中身で、列の構成は +自分で書いたので知っている。 + +ただし**同じテーブルの中で操作したときは、いま見ていたタブに留まる**。この panel は +htmx で丸ごと差し替わるので、素朴に書くと列を 1 つ直すたびに Data へ弾き飛ばされる。 +直前に見ていたタブをテーブル名つきで覚えておき、同じテーブルなら戻す(別のテーブルへ +移ったら Data から始める)。 + +⚠️ 注釈は**タグの外**に置くこと。属性の中に書いた `{#…#}` は Django に解釈されず、 +そのまま画面へ出る(2026-09-04 に 2 か所やらかした)。{% 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 %}
- + + @@ -214,13 +227,66 @@

{{ 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 %} + / {% 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 %} +
{% if preview_rows %} -
+ {% comment %}グリッドだけが縦にスクロールする。1000 行あるので、ページ全体が + 動くと列名もページ送りも画面の外へ流れてしまう。SQL ランナーと絞り込みの + 結果は既にこうなっていて、ここだけ揃っていなかった。{% endcomment %} +
- + {% for c in preview_columns %}{% endfor %} diff --git a/templates/workspace.html b/templates/workspace.html index f84a504..ce8a231 100644 --- a/templates/workspace.html +++ b/templates/workspace.html @@ -71,7 +71,15 @@ - + + {% comment %}バーの右側。いま何を見ているかを置く ── #detail は htmx で丸ごと + 入れ替わり、ブラウザの履歴にも残らないので、そうでないと現在地がタイトルにしか + 無い。テーブルを開いたときだけ出す(サイドバーの選択 sel を見ている。ここは + htmx の差し替え範囲の外なので、サーバ往復も OOB swap も要らない)。{% endcomment %} + + / + + {% endblock %}
{{ c }}