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
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,31 @@ Use this in staging/production databases or CI pipelines to guarantee every new

### As an analysis tool - audit existing tables

Use `check()` to inspect any table's current layout and see where padding is wasted:
Use `padding_wasted()` to quickly check how many bytes a table wastes per row:

```sql
SELECT column_tetris.padding_wasted('orders');
-- Returns: 7 (bytes of avoidable padding per row)
```

Pass `'total'` to see the total waste across all rows in the table. Wrap with `pg_size_pretty()` for human-readable output:

```sql
SELECT pg_size_pretty(column_tetris.padding_wasted('orders', 'total'));
-- Returns: '458 MB'
```
Comment on lines +141 to +153

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README describes padding_wasted() as "bytes a table wastes per row" and shows an example return value, but the implementation is based on the extension’s fixed-width alignment heuristic (current vs suggested order). Consider clarifying that it reports avoidable fixed-width alignment padding (not total row overhead), and note that 'total' will compute row count and may be slow on large tables.

Copilot uses AI. Check for mistakes.

Find all tables with padding waste:

```sql
SELECT schemaname, tablename,
column_tetris.padding_wasted(schemaname || '.' || tablename) AS bytes_per_row
FROM pg_tables
WHERE schemaname = 'public'
AND column_tetris.padding_wasted(schemaname || '.' || tablename) > 0;
```

Use `check()` for a detailed column-by-column layout report:

```sql
SELECT * FROM column_tetris.check('orders');
Expand Down
115 changes: 115 additions & 0 deletions pg_column_tetris--0.1.0.sql
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,121 @@ LANGUAGE sql VOLATILE AS $$
SELECT * FROM column_tetris.compute_layout(relation_name::regclass::oid);
$$;

-- ---------------------------------------------------------------------------
-- padding_wasted(text) — Returns bytes of avoidable padding per row
-- ---------------------------------------------------------------------------

CREATE FUNCTION column_tetris.padding_wasted(relation_name text, report_mode text DEFAULT 'row')
RETURNS bigint
Comment on lines +264 to +269

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a new public function to the extension but the extension version remains 0.1.0 (and there are no upgrade scripts). If users already have 0.1.0 installed, they won't get this function via ALTER EXTENSION ... UPDATE. Consider bumping the extension version and adding an upgrade script that creates padding_wasted.

Copilot uses AI. Check for mistakes.
LANGUAGE plpgsql AS $$
DECLARE
v_rel_oid oid;
v_current_waste int := 0;
v_optimal_waste int := 0;
v_offset int;
v_align int;
v_padding int;
v_num_cols int;
v_any_nullable bool;
v_row_count bigint;
col record;
BEGIN
IF report_mode NOT IN ('row', 'total') THEN
RAISE EXCEPTION 'invalid report_mode: %. Must be row or total', report_mode;
END IF;

v_rel_oid := relation_name::regclass::oid;

SELECT count(*)::int, bool_or(NOT a.attnotnull)
INTO v_num_cols, v_any_nullable
FROM pg_attribute a
WHERE a.attrelid = v_rel_oid
AND a.attnum > 0
AND NOT a.attisdropped;

Comment on lines +268 to +295

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function queries pg_attribute/pg_type without fixing search_path. Other catalog-reading functions in this extension set search_path to pg_catalog, pg_temp; doing the same here would prevent user objects shadowing catalog names and keep behavior consistent.

Copilot uses AI. Check for mistakes.
IF v_num_cols = 0 THEN
RETURN 0;
END IF;

-- Current layout waste
v_offset := 23;
IF v_any_nullable THEN
v_offset := v_offset + ((v_num_cols + 7) / 8);
END IF;
v_offset := ((v_offset + 7) / 8) * 8;

FOR col IN
SELECT t.typalign, t.typlen
FROM pg_attribute a
JOIN pg_type t ON t.oid = a.atttypid
WHERE a.attrelid = v_rel_oid
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum
LOOP
IF col.typlen = -1 THEN
v_align := 4;
v_offset := ((v_offset + v_align - 1) / v_align) * v_align + 4;
ELSE
v_align := CASE col.typalign
WHEN 'd' THEN 8 WHEN 'i' THEN 4
WHEN 's' THEN 2 WHEN 'c' THEN 1 ELSE 4
END;
v_padding := ((v_offset + v_align - 1) / v_align) * v_align - v_offset;
v_current_waste := v_current_waste + v_padding;
v_offset := ((v_offset + v_align - 1) / v_align) * v_align + col.typlen;
END IF;
END LOOP;

-- Optimal layout waste
v_offset := 23;
IF v_any_nullable THEN
v_offset := v_offset + ((v_num_cols + 7) / 8);
END IF;
v_offset := ((v_offset + 7) / 8) * 8;

FOR col IN
SELECT t.typalign, t.typlen
FROM pg_attribute a
JOIN pg_type t ON t.oid = a.atttypid
WHERE a.attrelid = v_rel_oid
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY
CASE
WHEN t.typlen = -1 THEN 5
WHEN t.typalign = 'd' THEN 1
WHEN t.typalign = 'i' THEN 2
WHEN t.typalign = 's' THEN 3
WHEN t.typalign = 'c' THEN 4
ELSE 5
END,
CASE WHEN a.attnotnull THEN 0 ELSE 1 END,
a.attnum
LOOP
IF col.typlen = -1 THEN
v_align := 4;
v_offset := ((v_offset + v_align - 1) / v_align) * v_align + 4;
ELSE
v_align := CASE col.typalign
WHEN 'd' THEN 8 WHEN 'i' THEN 4
WHEN 's' THEN 2 WHEN 'c' THEN 1 ELSE 4
END;
v_padding := ((v_offset + v_align - 1) / v_align) * v_align - v_offset;
v_optimal_waste := v_optimal_waste + v_padding;
v_offset := ((v_offset + v_align - 1) / v_align) * v_align + col.typlen;
END IF;
END LOOP;

IF report_mode = 'total' THEN

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

report_mode = 'total' computes row count via count(*), which will force a full scan (or at least a potentially expensive count) on large tables. Consider either (a) documenting that total may be slow, or (b) adding an estimated mode based on pg_class.reltuples/pg_stat_all_tables.n_live_tup to avoid heavy scans when users just want an order-of-magnitude estimate.

Suggested change
IF report_mode = 'total' THEN
IF report_mode = 'estimated' THEN
SELECT GREATEST(COALESCE(c.reltuples, 0), 0)::bigint
INTO v_row_count
FROM pg_class c
WHERE c.oid = v_rel_oid;
RETURN (v_current_waste - v_optimal_waste)::bigint * COALESCE(v_row_count, 0);
ELSIF report_mode = 'total' THEN
-- Exact row count; may be expensive on large tables.

Copilot uses AI. Check for mistakes.
EXECUTE format('SELECT count(*) FROM %s', v_rel_oid::regclass) INTO v_row_count;
RETURN (v_current_waste - v_optimal_waste)::bigint * v_row_count;
END IF;

RETURN (v_current_waste - v_optimal_waste)::bigint;
Comment on lines +372 to +375

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

padding_wasted can return a negative number when v_current_waste <= v_optimal_waste (the same situation validate() treats as non-suboptimal). Since this function is documented as returning wasted/avoidable bytes, consider clamping the result to 0 when current is not worse than optimal (and do the same for total mode).

Suggested change
RETURN (v_current_waste - v_optimal_waste)::bigint * v_row_count;
END IF;
RETURN (v_current_waste - v_optimal_waste)::bigint;
RETURN GREATEST((v_current_waste - v_optimal_waste)::bigint, 0::bigint) * v_row_count;
END IF;
RETURN GREATEST((v_current_waste - v_optimal_waste)::bigint, 0::bigint);

Copilot uses AI. Check for mistakes.
END;
$$;

-- ---------------------------------------------------------------------------
-- validate(oid) — Raises exception if layout is suboptimal
-- ---------------------------------------------------------------------------
Expand Down
106 changes: 106 additions & 0 deletions test/sql/06_padding_wasted.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
-- Test: padding_wasted() function
-- Expected: correct per-row and total waste calculations

-- Setup: need warn mode so test tables can be created with suboptimal order
SELECT column_tetris.set_mode('warn');

-- Test 1: Suboptimal table should have padding waste > 0
CREATE TABLE test_pw_suboptimal (
flag boolean,
big_id bigint
);

DO $$
DECLARE
v_waste int;
BEGIN
SELECT column_tetris.padding_wasted('test_pw_suboptimal') INTO v_waste;
IF v_waste <= 0 THEN
RAISE EXCEPTION 'TEST FAILED: padding_wasted should be > 0 for suboptimal table, got %', v_waste;
END IF;
RAISE NOTICE 'TEST PASSED: suboptimal table has % bytes waste per row', v_waste;
END;
$$;

-- Test 2: Optimal table should have 0 waste
CREATE TABLE test_pw_optimal (
big_id bigint,
count integer,
flag boolean
);

DO $$
DECLARE
v_waste int;
BEGIN
SELECT column_tetris.padding_wasted('test_pw_optimal') INTO v_waste;
IF v_waste <> 0 THEN
Comment on lines +14 to +37

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test stores padding_wasted() (which returns bigint) into int variables (v_waste). Using bigint here would match the function signature and avoid truncation if a future test uses larger totals.

Copilot uses AI. Check for mistakes.
RAISE EXCEPTION 'TEST FAILED: padding_wasted should be 0 for optimal table, got %', v_waste;
END IF;
RAISE NOTICE 'TEST PASSED: optimal table has 0 waste';
END;
$$;

-- Test 3: Non-optimal order but zero actual waste (int4, int4, float8, text)
CREATE TABLE test_pw_zero_waste (
a integer,
b integer,
c float8,
d text
);

DO $$
DECLARE
v_waste int;
BEGIN
SELECT column_tetris.padding_wasted('test_pw_zero_waste') INTO v_waste;
IF v_waste <> 0 THEN
RAISE EXCEPTION 'TEST FAILED: int4+int4+float8+text should have 0 waste, got %', v_waste;
Comment on lines +53 to +58

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as earlier blocks: padding_wasted() returns bigint, but this block declares v_waste int. Consider using bigint consistently in the tests to avoid truncation in future cases.

Copilot uses AI. Check for mistakes.
END IF;
RAISE NOTICE 'TEST PASSED: zero waste despite non-optimal order';
END;
$$;

-- Test 4: Total mode = per_row * row_count
INSERT INTO test_pw_suboptimal (flag, big_id)
SELECT true, generate_series(1, 100);

DO $$
DECLARE
v_per_row bigint;
v_total bigint;
BEGIN
SELECT column_tetris.padding_wasted('test_pw_suboptimal') INTO v_per_row;
SELECT column_tetris.padding_wasted('test_pw_suboptimal', 'total') INTO v_total;
IF v_total <> v_per_row * 100 THEN
RAISE EXCEPTION 'TEST FAILED: total should be % * 100 = %, got %', v_per_row, v_per_row * 100, v_total;
END IF;
RAISE NOTICE 'TEST PASSED: total mode = % (% per row * 100 rows)', v_total, v_per_row;
END;
$$;

-- Test 5: Invalid mode raises exception
DO $$
DECLARE
v_caught bool := false;
BEGIN
BEGIN
PERFORM column_tetris.padding_wasted('test_pw_suboptimal', 'bad_mode');
EXCEPTION WHEN raise_exception THEN
v_caught := true;
IF SQLERRM NOT LIKE '%invalid report_mode%' THEN
RAISE EXCEPTION 'TEST FAILED: unexpected error message: %', SQLERRM;
END IF;
RAISE NOTICE 'TEST PASSED: invalid mode rejected';
END;
IF NOT v_caught THEN
RAISE EXCEPTION 'TEST FAILED: invalid mode should raise exception';
END IF;
END;
$$;

-- Cleanup
DROP TABLE IF EXISTS test_pw_suboptimal;
DROP TABLE IF EXISTS test_pw_optimal;
DROP TABLE IF EXISTS test_pw_zero_waste;
DO $$ BEGIN RAISE NOTICE 'All 06_padding_wasted tests passed'; END; $$;
Loading