diff --git a/apps/web/src/frontend/app/store/useSqlEditorStore.ts b/apps/web/src/frontend/app/store/useSqlEditorStore.ts index e57d2671..001e69b5 100644 --- a/apps/web/src/frontend/app/store/useSqlEditorStore.ts +++ b/apps/web/src/frontend/app/store/useSqlEditorStore.ts @@ -27,6 +27,7 @@ import { beamAliasesForCount, MAX_SERVERS } from '@foxschema/shared'; import { buildSampleBookmarks } from '@/features/sql-editor/lib/sqlEditorSamples'; import { buildForeignKeyDrilldown, + buildOrphanPeek, buildRowLookup, buildTablePreview, composePeekSql, @@ -522,6 +523,17 @@ interface SqlEditorState { fk: ForeignKeyInfo, values: unknown[] ) => Promise; + /** + * Open the child rows whose foreign key points at a parent row that is gone. + * + * A scan, unlike the rest of Peek's catalog reads, so it is reached only by + * asking for it from the Insight tab. + */ + openDataPeekOrphans: ( + connectionId: string, + childTable: string, + fk: ForeignKeyInfo + ) => Promise; /** * Open one row of one table, given its key — the way out of a joined result. * @@ -1828,6 +1840,31 @@ export const useSqlEditorStore = create()( await get().runDataPeekEntry(entry.id); }, + openDataPeekOrphans: async (connectionId, childTable, fk) => { + const conn = useSyncStore.getState().connections.find((c) => c.id === connectionId); + if (!conn) return; + const built = buildOrphanPeek(childTable, fk, conn.dialect); + if (!built) return; + const composed = composePeekSql(built.sql, built.params, {}); + if ('error' in composed) return; + const entry: DataPeekEntry = { + id: `peek-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + title: `${childTable} · orphans via ${fk.name || (fk.columns ?? []).join(', ')}`, + tableName: childTable, + baseSql: built.sql, + baseParams: built.params, + whereClause: '', + orderByClause: '', + limit: DATA_PEEK_ROWS, + pageIndex: 0, + sql: composed.sql, + params: composed.params, + status: 'loading', + }; + set({ dataPeek: { connectionId, dialect: conn.dialect, entries: [entry] } }); + await get().runDataPeekEntry(entry.id); + }, + openDataPeekForRow: async (connectionId, tableName, keys) => { const conn = useSyncStore.getState().connections.find((c) => c.id === connectionId); if (!conn) return; diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx index d8a1d92d..1d75f21e 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.tsx @@ -22,6 +22,7 @@ import { } from 'lucide-react'; import { dialectSupportsDbAccess, + userManagementSupport, privilegesForPrincipal, type DbPrincipal, type DbPrivilege, @@ -35,6 +36,12 @@ import { useAuthStore } from '@/app/store/authStore'; import { EmptyState, Segmented, inputCls, labelCls } from './controls'; import { type DbAccessConfirmRequest } from './DbAccessPermissionSections'; import { PermissionInspector } from './PermissionInspector'; +import { SectionLabel } from '@/shared/components/surfaces'; +import { + ALTERATION_LABEL, + availableAlterations, + dropSafetyNotes, +} from '../lib/accountAlterations'; import type { AccessPrincipalDraft } from '../lib/access-draft'; type PermissionStage = 'account' | 'grants' | 'effective'; @@ -465,7 +472,12 @@ export const AccessPermissionPanel: React.FC<{

)} {selected && stage === 'account' && ( - + )} {selected && stage === 'grants' && ( void; -}> = ({ principal, onManageUsers }) => { +}> = ({ principal, privileges = [], dialect, onManageUsers }) => { + const support = dialect ? userManagementSupport(dialect) : null; + const alterations = support + ? availableAlterations(support, principal.kind === 'user' ? 'user' : 'role') + : []; + const dropNotes = dropSafetyNotes(principal, privileges); const login = principal.canLogin === true ? 'Can log in' @@ -588,6 +608,46 @@ const AccountStage: React.FC<{ {login} + {/* What this engine can change about this principal. Naming them here + means the reader learns what is possible without opening the form and + finding out by absence. */} +
+ Alteration + {alterations.length === 0 ? ( +

+ {support + ? 'This engine has no edit actions for this kind of account.' + : 'Choose a connection to see what this engine can change.'} +

+ ) : ( +
    + {alterations.map((a) => ( +
  • + {ALTERATION_LABEL[a]} +
  • + ))} +
+ )} +
+ + {dropNotes.length > 0 && ( +
+ Drop safety notes +
    + {dropNotes.map((n) => ( +
  • {n}
  • + ))} +
+
+ )} + {onManageUsers && ( + )} + + ); + })} +
+ + {/* 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