Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 62 additions & 2 deletions core/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 25 additions & 4 deletions core/engines/mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 --------------------------------------------------

Expand Down
16 changes: 16 additions & 0 deletions core/engines/mysql_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
"""
24 changes: 23 additions & 1 deletion core/engines/pg_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,4 +749,26 @@
) AS sml
ORDER BY wasted_bytes DESC
LIMIT {limit};
"""
"""

# 主キーの列を定義順で。ページ送りの並び順に使う ── 順序を指定しないと
# 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;
"""
44 changes: 38 additions & 6 deletions core/engines/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading