= {};
+ for (const fk of foreignKeys) {
+ const built = buildOrphanCount(tableName, fk, dialect);
+ if (!built) continue;
+ const { results } = await executeSql(
+ { connectionId, password: sessionPasswords[connectionId] || undefined },
+ [built.sql],
+ undefined,
+ undefined,
+ [built.params]
+ );
+ const first = results[0];
+ if (!first || !first.ok) {
+ setOrphanError(first && 'error' in first ? String(first.error) : 'Orphan check failed.');
+ return;
+ }
+ counts[fk.name || (fk.columns ?? []).join(',')] = Number(first.rows?.[0]?.[0] ?? 0);
+ }
+ setOrphanCounts(counts);
+ } catch (e) {
+ setOrphanError(e instanceof Error ? e.message : String(e));
+ } finally {
+ setCheckingOrphans(false);
+ }
+ };
useEffect(() => {
let cancelled = false;
@@ -96,7 +172,7 @@ export const PeekInsight: React.FC<{
{data && cards && (
<>
+
+
+ {foreignKeys.length > 0 && (
+
+ {foreignKeys.map((fk) => {
+ const key = fk.name || (fk.columns ?? []).join(',');
+ const count = orphanCounts?.[key];
+ const rows = data?.estimatedRows ?? null;
+ const matched =
+ count != null && rows != null && rows > 0
+ ? `${Math.round((1 - count / rows) * 10000) / 100}% matched`
+ : null;
+ return (
+
+
+ {(fk.columns ?? []).join(', ')} → {fkDrillTableName(fk)}
+
+ {count == null ? (
+ not checked
+ ) : (
+ <>
+ 0 ? 'text-rose-300' : 'text-emerald-300'
+ }`}
+ >
+ {count === 0 ? 'no orphans' : `${count.toLocaleString()} orphans`}
+
+ {matched && {matched}}
+ >
+ )}
+ {count != null && count > 0 && (
+
+ )}
+
+ );
+ })}
+
+
+ {/* Said plainly: everything above this line came from the
+ catalog and cost nothing; this one reads the table. */}
+ Scans the table — not a catalog read
+
+ {orphanError && (
+
+ {orphanError}
+
+ )}
+
+ )}
+
Estimated rows:{' '}
diff --git a/apps/web/src/frontend/shared/api/schemaApi.ts b/apps/web/src/frontend/shared/api/schemaApi.ts
index 96db6271..c38e26f5 100644
--- a/apps/web/src/frontend/shared/api/schemaApi.ts
+++ b/apps/web/src/frontend/shared/api/schemaApi.ts
@@ -405,6 +405,8 @@ export type TableInsightResponse = {
schema: string;
dialect?: string;
estimatedRows: number | null;
+ /** Table + indexes as the catalog reports it; null where the engine has no dependable figure. */
+ sizeBytes: number | null;
columns: Array<{ name: string; nDistinct: number | null; nullFrac: number | null }>;
mode: 'catalog' | 'unsupported';
support: { mode: string; query: boolean; hint: string };
diff --git a/apps/web/src/frontend/shared/lib/tablePreview.orphans.test.ts b/apps/web/src/frontend/shared/lib/tablePreview.orphans.test.ts
new file mode 100644
index 00000000..952702ad
--- /dev/null
+++ b/apps/web/src/frontend/shared/lib/tablePreview.orphans.test.ts
@@ -0,0 +1,104 @@
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Orphan detection: child rows whose foreign key points at nothing.
+ *
+ * The assertions worth having here are the ones that decide whether the answer
+ * is right rather than merely well-formed — NOT EXISTS over NOT IN, NULL keys
+ * excluded, and a self-referencing table kept apart from itself.
+ */
+import { describe, expect, it } from 'vitest';
+import { buildOrphanCount, buildOrphanPeek } from './tablePreview';
+import type { ForeignKeyInfo } from './types';
+
+const fk = (over: Partial = {}): ForeignKeyInfo => ({
+ name: 'fk_orders_customer',
+ columns: ['customer_id'],
+ referencedTable: 'customers',
+ referencedSchema: 'public',
+ referencedColumns: ['id'],
+ ...over,
+});
+
+describe('buildOrphanCount', () => {
+ it('uses NOT EXISTS, because NOT IN gets this wrong when a parent key is NULL', () => {
+ // A single NULL in the parent column makes `x NOT IN (SELECT …)` return no
+ // rows at all, reporting a table full of orphans as clean. The two are not
+ // interchangeable and only one of them answers the question.
+ const q = buildOrphanCount('public.orders', fk(), 'postgres')!;
+ expect(q.sql).toContain('NOT EXISTS');
+ expect(q.sql).not.toContain('NOT IN');
+ });
+
+ it('does not count a NULL foreign key as an orphan', () => {
+ // A NULL FK is an absent relationship, not a broken one. Counting it would
+ // call every optional reference an orphan.
+ const q = buildOrphanCount('public.orders', fk(), 'postgres')!;
+ expect(q.sql).toContain('IS NOT NULL');
+ });
+
+ it('keeps both sides apart when a table references itself', () => {
+ const q = buildOrphanCount(
+ 'public.employees',
+ fk({ referencedTable: 'employees', columns: ['manager_id'], referencedColumns: ['id'] }),
+ 'postgres'
+ )!;
+ expect(q.sql).toContain('fox_c');
+ expect(q.sql).toContain('fox_p');
+ });
+
+ it('pairs every column of a composite key, in order', () => {
+ const q = buildOrphanCount(
+ 'public.order_items',
+ fk({
+ columns: ['order_region', 'order_id'],
+ referencedTable: 'orders',
+ referencedColumns: ['region', 'id'],
+ }),
+ 'postgres'
+ )!;
+ expect(q.sql).toContain('"region" = fox_c."order_region"');
+ expect(q.sql).toContain('"id" = fox_c."order_id"');
+ });
+
+ it('quotes for the dialect it is asked for', () => {
+ expect(buildOrphanCount('app.orders', fk(), 'mysql')!.sql).toContain('`orders`');
+ expect(buildOrphanCount('public.orders', fk(), 'postgres')!.sql).toContain('"orders"');
+ });
+
+ it('resolves the parent through its own schema, not the child\'s', () => {
+ // A cross-schema FK that loses its schema resolves a bare name wherever the
+ // connection happens to point — a different table with the same name.
+ const q = buildOrphanCount(
+ 'sales.orders',
+ fk({ referencedSchema: 'reference', referencedTable: 'customers' }),
+ 'postgres'
+ )!;
+ expect(q.sql).toContain('"reference"."customers"');
+ });
+
+ it('offers nothing when the catalog gave no usable column pair', () => {
+ expect(buildOrphanCount('public.orders', fk({ columns: [] }), 'postgres')).toBeNull();
+ expect(
+ buildOrphanCount('public.orders', fk({ referencedColumns: ['a', 'b'] }), 'postgres')
+ ).toBeNull();
+ expect(buildOrphanCount('', fk(), 'postgres')).toBeNull();
+ });
+});
+
+describe('buildOrphanPeek', () => {
+ it('selects the offending rows rather than counting them', () => {
+ const q = buildOrphanPeek('public.orders', fk(), 'postgres')!;
+ expect(q.sql).toContain('fox_c.*');
+ expect(q.sql).not.toContain('COUNT(*)');
+ });
+
+ it('asks the same question as the count, so the two cannot disagree', () => {
+ const count = buildOrphanCount('public.orders', fk(), 'postgres')!;
+ const peek = buildOrphanPeek('public.orders', fk(), 'postgres')!;
+ const predicate = (s: string) => s.slice(s.indexOf(' WHERE '));
+ expect(predicate(peek.sql)).toBe(predicate(count.sql));
+ });
+});
diff --git a/apps/web/src/frontend/shared/lib/tablePreview.ts b/apps/web/src/frontend/shared/lib/tablePreview.ts
index 33d3439f..172d957f 100644
--- a/apps/web/src/frontend/shared/lib/tablePreview.ts
+++ b/apps/web/src/frontend/shared/lib/tablePreview.ts
@@ -105,6 +105,76 @@ export function buildRowLookup(
return { sql: text, params };
}
+/**
+ * Child rows whose foreign key points at a parent row that is not there.
+ *
+ * Deliberately not part of the table-insight probe. That probe is catalog-only
+ * — `pg_stats`, `TABLE_ROWS`, a page count — and answers in constant time
+ * whatever the table's size. Counting orphans is a scan of the child against
+ * the parent, so it is offered as something the reader asks for, once, rather
+ * than something a 2.4M-row table pays for because a tab was opened.
+ *
+ * `NOT EXISTS`, not `NOT IN`: a single NULL in the parent's key column makes
+ * `NOT IN` return no rows at all, which would report a table with orphans as
+ * clean. The two forms are not interchangeable and this is the direction that
+ * gets the answer right.
+ *
+ * Rows with a NULL foreign key are excluded. A NULL FK is an absent
+ * relationship, not a broken one — counting it would call every optional
+ * reference an orphan.
+ *
+ * Returns null when the catalog gave no usable column pair, so the caller can
+ * leave the affordance off rather than offer a check that cannot run.
+ */
+function orphanQuery(
+ select: string,
+ childTable: string,
+ fk: ForeignKeyInfo,
+ dialect: string
+): PreviewQuery | null {
+ const childCols = fk.columns ?? [];
+ const refCols = fk.referencedColumns ?? [];
+ if (childCols.length === 0 || childCols.length !== refCols.length) return null;
+
+ const childParts = tableNameParts(childTable);
+ const parentParts = tableNameParts(fkDrillTableName(fk));
+ if (childParts.length === 0 || parentParts.length === 0) return null;
+
+ // Aliases keep the two sides apart when a table references itself.
+ let query = sql`SELECT ${sql.raw(select)} FROM ${sql.id(...childParts)} AS fox_c WHERE `;
+ childCols.forEach((col, i) => {
+ const joiner = i === 0 ? sql`` : sql` AND `;
+ query = sql`${query}${joiner}fox_c.${sql.id(col)} IS NOT NULL`;
+ });
+ query = sql`${query} AND NOT EXISTS (SELECT 1 FROM ${sql.id(...parentParts)} AS fox_p WHERE `;
+ refCols.forEach((refCol, i) => {
+ const joiner = i === 0 ? sql`` : sql` AND `;
+ query = sql`${query}${joiner}fox_p.${sql.id(refCol)} = fox_c.${sql.id(childCols[i]!)}`;
+ });
+ query = sql`${query})`;
+
+ const { text, params } = renderSqlQuery(query, dialect);
+ return { sql: text, params };
+}
+
+/** How many child rows point at a parent row that does not exist. */
+export function buildOrphanCount(
+ childTable: string,
+ fk: ForeignKeyInfo,
+ dialect: string
+): PreviewQuery | null {
+ return orphanQuery('COUNT(*) AS fox_orphans', childTable, fk, dialect);
+}
+
+/** The orphan rows themselves — what "Peek orphans" opens. */
+export function buildOrphanPeek(
+ childTable: string,
+ fk: ForeignKeyInfo,
+ dialect: string
+): PreviewQuery | null {
+ return orphanQuery('fox_c.*', childTable, fk, dialect);
+}
+
export interface FkColumnLink {
/** Index into the result's `columns` that carries the child value. */
columnIndex: number;
diff --git a/packages/server/src/features/schema/table-insight.service.ts b/packages/server/src/features/schema/table-insight.service.ts
index b7150c55..45ca0c14 100644
--- a/packages/server/src/features/schema/table-insight.service.ts
+++ b/packages/server/src/features/schema/table-insight.service.ts
@@ -57,6 +57,7 @@ export async function probeTableInsight(opts: {
table: opts.table,
schema: opts.schema ?? '',
estimatedRows: norm.estimatedRows,
+ sizeBytes: norm.sizeBytes,
columns: norm.columns,
mode: 'catalog',
support,
diff --git a/packages/sql/src/modules/utilities/table-insight.test.ts b/packages/sql/src/modules/utilities/table-insight.test.ts
index b886bbca..9d76282a 100644
--- a/packages/sql/src/modules/utilities/table-insight.test.ts
+++ b/packages/sql/src/modules/utilities/table-insight.test.ts
@@ -7,7 +7,9 @@ import { describe, expect, it } from 'vitest';
import {
buildTableInsightQuery,
dialectSupportsTableInsight,
+ normalizeTableInsightRows,
tableInsightDialectIds,
+ type TableInsightQuery,
} from './table-insight';
describe('table insight probes', () => {
@@ -40,3 +42,63 @@ describe('table insight probes', () => {
if ('sql' in lite) expect(lite.sql.toLowerCase()).toContain('sqlite_stat1');
});
});
+
+describe('table size', () => {
+ it('asks Postgres for exact bytes, table plus indexes', () => {
+ const q = buildTableInsightQuery({ dialect: 'postgres', schema: 'public', table: 'orders' });
+ expect('error' in q).toBe(false);
+ expect((q as TableInsightQuery).sql).toContain('pg_total_relation_size(c.oid) AS size_bytes');
+ });
+
+ it('does not ask Redshift for a function it does not have', () => {
+ // The "redshift" service used by e2e is a real Postgres, so baking the
+ // Postgres expression into the shared factory would pass every test in this
+ // repo and fail on an actual warehouse. Size lives in SVV_TABLE_INFO there.
+ const q = buildTableInsightQuery({ dialect: 'redshift', schema: 'public', table: 'orders' });
+ expect((q as TableInsightQuery).sql).not.toContain('pg_total_relation_size');
+ expect((q as TableInsightQuery).sql).toContain('NULL AS size_bytes');
+ });
+
+ it('does not ask CockroachDB either — its version of the function is a stub', () => {
+ // Measured on v26.3.0: the call is accepted and returns NULL. Shipping it
+ // would spend a round trip to learn nothing, and the support hint would
+ // imply a size the engine never gives.
+ const q = buildTableInsightQuery({ dialect: 'cockroachdb', schema: 'public', table: 'orders' });
+ expect((q as TableInsightQuery).sql).toContain('NULL AS size_bytes');
+ });
+
+ it('keeps the Postgres expression for YugabyteDB', () => {
+ // Unverified against a live node — its container would not start here — so
+ // it stays on the Postgres path. If the function is absent or stubbed there
+ // too, the column comes back null and the card reads "Not reported", which
+ // is the same graceful answer as an opt-out.
+ const q = buildTableInsightQuery({ dialect: 'yugabytedb', schema: 'public', table: 'orders' });
+ expect((q as TableInsightQuery).sql).toContain('pg_total_relation_size');
+ });
+
+ it('reads size from the engine on the MySQL and SQL Server families', () => {
+ for (const dialect of ['mysql', 'mariadb', 'tidb']) {
+ const q = buildTableInsightQuery({ dialect, schema: 'app', table: 'orders' });
+ expect((q as TableInsightQuery).sql, dialect).toContain('DATA_LENGTH + t.INDEX_LENGTH');
+ }
+ for (const dialect of ['sqlserver', 'azuresql']) {
+ const q = buildTableInsightQuery({ dialect, schema: 'dbo', table: 'orders' });
+ expect((q as TableInsightQuery).sql, dialect).toContain('used_page_count');
+ }
+ });
+
+ it('normalizes the size column, and reports null when the engine sent none', () => {
+ expect(
+ normalizeTableInsightRows('postgres', [
+ { column_name: 'id', estimated_rows: 10, size_bytes: 8192 },
+ ]).sizeBytes
+ ).toBe(8192);
+ // A dialect that opted out sends NULL; that has to stay null rather than
+ // becoming 0, which would render as a real "0 B" table.
+ expect(
+ normalizeTableInsightRows('redshift', [
+ { column_name: 'id', estimated_rows: 10, size_bytes: null },
+ ]).sizeBytes
+ ).toBeNull();
+ });
+});
diff --git a/packages/sql/src/modules/utilities/table-insight.ts b/packages/sql/src/modules/utilities/table-insight.ts
index 9bbb4c7d..49865f63 100644
--- a/packages/sql/src/modules/utilities/table-insight.ts
+++ b/packages/sql/src/modules/utilities/table-insight.ts
@@ -79,11 +79,12 @@ export function parseSqliteStat1(stat: unknown): { estimatedRows: number | null;
export function normalizeTableInsightRows(
dialect: string,
raw: unknown
-): { estimatedRows: number | null; columns: TableInsightColumn[] } {
+): { estimatedRows: number | null; sizeBytes: number | null; columns: TableInsightColumn[] } {
const rows: Record[] = Array.isArray(raw) ? raw : [];
const d = dialect.toLowerCase();
const columns: TableInsightColumn[] = [];
let estimatedRows: number | null = null;
+ let sizeBytes: number | null = null;
const seen = new Set();
for (const row of rows) {
if (estimatedRows == null) {
@@ -93,6 +94,7 @@ export function normalizeTableInsightRows(
estimatedRows = num(row.estimated_rows ?? row.TABLE_ROWS ?? row.num_rows);
}
}
+ if (sizeBytes == null) sizeBytes = num(row.size_bytes ?? row.SIZE_BYTES);
const name = str(row.column_name ?? row.idx);
if (!name || seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
@@ -107,5 +109,5 @@ export function normalizeTableInsightRows(
});
}
}
- return { estimatedRows, columns };
+ return { estimatedRows, sizeBytes, columns };
}
diff --git a/packages/sql/src/modules/utilities/table-insight.types.ts b/packages/sql/src/modules/utilities/table-insight.types.ts
index a56aab17..1dacfe33 100644
--- a/packages/sql/src/modules/utilities/table-insight.types.ts
+++ b/packages/sql/src/modules/utilities/table-insight.types.ts
@@ -31,6 +31,16 @@ export interface TableInsightResult {
table: string;
schema: string;
estimatedRows: number | null;
+ /**
+ * Bytes the table occupies, table plus indexes, as the catalog reports it.
+ *
+ * Null where the engine has no dependable answer rather than a guessed one:
+ * Oracle and Db2 expose only a page count whose page size varies per
+ * tablespace, and SQLite/DuckDB carry no size in their stat tables at all. A
+ * plausible wrong number is worse here than a dash, because a size is the
+ * kind of figure people act on.
+ */
+ sizeBytes: number | null;
columns: TableInsightColumn[];
mode: TableInsightMode;
support: TableInsightSupport;
diff --git a/packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts b/packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts
index b3ecf768..9c8babe0 100644
--- a/packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts
+++ b/packages/sql/src/providers/cockroachDb/cockroachdb.table-insight.ts
@@ -2,5 +2,10 @@ import { makePostgresTableInsight } from '../postgres/postgres.table-insight.js'
export const cockroachDbTableInsight = makePostgresTableInsight(
'cockroachdb',
- 'CockroachDB: pg_stats / table statistics (estimated).'
+ 'CockroachDB: pg_stats / table statistics (estimated). Table size is not reported.',
+ // CockroachDB accepts pg_total_relation_size and always answers NULL — it is
+ // a compatibility stub, not an implementation. Measured on v26.3.0:
+ // SELECT pg_total_relation_size('demo_a.orders'::regclass); -> NULL
+ // Asking for it costs a call and returns nothing, so say so up front.
+ 'NULL'
);
diff --git a/packages/sql/src/providers/mysql/mysql.table-insight.ts b/packages/sql/src/providers/mysql/mysql.table-insight.ts
index 7525131e..1230dcb9 100644
--- a/packages/sql/src/providers/mysql/mysql.table-insight.ts
+++ b/packages/sql/src/providers/mysql/mysql.table-insight.ts
@@ -23,7 +23,11 @@ SELECT
s.COLUMN_NAME AS column_name,
s.CARDINALITY AS n_distinct,
NULL AS null_frac,
- t.TABLE_ROWS AS estimated_rows
+ t.TABLE_ROWS AS estimated_rows,
+ -- What the engine itself reports for the table: data plus indexes. InnoDB
+ -- rounds to extent boundaries, so this is the engine's own answer rather
+ -- than a byte-exact one, and it is the number every MySQL tool shows.
+ (t.DATA_LENGTH + t.INDEX_LENGTH) AS size_bytes
FROM information_schema.TABLES t
LEFT JOIN information_schema.STATISTICS s
ON s.TABLE_SCHEMA = t.TABLE_SCHEMA AND s.TABLE_NAME = t.TABLE_NAME
diff --git a/packages/sql/src/providers/postgres/postgres.table-insight.ts b/packages/sql/src/providers/postgres/postgres.table-insight.ts
index 3758c7ac..d4ba176a 100644
--- a/packages/sql/src/providers/postgres/postgres.table-insight.ts
+++ b/packages/sql/src/providers/postgres/postgres.table-insight.ts
@@ -18,12 +18,21 @@ const SUPPORT: TableInsightSupport = {
hint: 'PostgreSQL: pg_stats.n_distinct and pg_class.reltuples (ANALYZE).',
};
-const SQL = `
+/**
+ * `pg_total_relation_size` is table plus indexes plus TOAST, in bytes, and it
+ * is exact rather than estimated. Redshift does not have it — its stand-in in
+ * this repo is a real Postgres, so a test would happily pass while production
+ * failed — which is why the expression is a parameter and not baked in here.
+ */
+const SIZE_EXPR = 'pg_total_relation_size(c.oid)';
+
+const sqlFor = (sizeExpr: string) => `
SELECT
s.attname AS column_name,
s.n_distinct AS n_distinct,
s.null_frac AS null_frac,
- c.reltuples::bigint AS estimated_rows
+ c.reltuples::bigint AS estimated_rows,
+ ${sizeExpr} AS size_bytes
FROM pg_stats s
JOIN pg_namespace n ON n.nspname = s.schemaname
JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = s.tablename
@@ -32,14 +41,20 @@ WHERE s.schemaname = $1
ORDER BY s.attname
`.trim();
-export function makePostgresTableInsight(id: string, hint = SUPPORT.hint): TableInsightDialect {
+export function makePostgresTableInsight(
+ id: string,
+ hint = SUPPORT.hint,
+ /** Pass 'NULL' for an engine without pg_total_relation_size. */
+ sizeExpr: string = SIZE_EXPR
+): TableInsightDialect {
+ const sql = sqlFor(sizeExpr);
return {
id,
support: { ...SUPPORT, hint },
probe(target: TableInsightTarget): TableInsightQuery {
return {
mode: 'catalog',
- sql: SQL,
+ sql,
params: [target.schema || 'public', target.table],
};
},
diff --git a/packages/sql/src/providers/redshift/redshift.table-insight.ts b/packages/sql/src/providers/redshift/redshift.table-insight.ts
index 2859a425..af414c8f 100644
--- a/packages/sql/src/providers/redshift/redshift.table-insight.ts
+++ b/packages/sql/src/providers/redshift/redshift.table-insight.ts
@@ -2,5 +2,10 @@ import { makePostgresTableInsight } from '../postgres/postgres.table-insight.js'
export const redshiftTableInsight = makePostgresTableInsight(
'redshift',
- 'Redshift: pg_stats-style catalog estimates.'
+ 'Redshift: pg_stats-style catalog estimates. Table size is not reported.',
+ // Redshift has no pg_total_relation_size; size lives in SVV_TABLE_INFO,
+ // which is a different query and not a drop-in expression here. The local
+ // e2e "redshift" is a real Postgres, so baking the Postgres expression in
+ // would pass every test here and fail on the actual warehouse.
+ 'NULL'
);
diff --git a/packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts b/packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts
index 07bd8892..4cd9ce82 100644
--- a/packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts
+++ b/packages/sql/src/providers/sqlServer/sqlserver.table-insight.ts
@@ -23,7 +23,9 @@ SELECT
c.name AS column_name,
NULL AS n_distinct,
NULL AS null_frac,
- SUM(p.row_count) OVER () AS estimated_rows
+ SUM(p.row_count) OVER () AS estimated_rows,
+ -- SQL Server pages are always 8 KB, so page count converts to bytes exactly.
+ (SUM(p.used_page_count) OVER () * 8192) AS size_bytes
FROM sys.tables t
INNER JOIN sys.schemas s ON s.schema_id = t.schema_id
INNER JOIN sys.dm_db_partition_stats p