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 %}
-
+
+ {% 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 %}
+ {% trans "← Prev" %}
+ {% 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 %}
+ {% trans "Next →" %}
+ {% endif %}
+
+
+