diff --git a/README.md b/README.md index 985a8f1..8ce604e 100644 --- a/README.md +++ b/README.md @@ -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' +``` + +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'); diff --git a/pg_column_tetris--0.1.0.sql b/pg_column_tetris--0.1.0.sql index 8be50ac..e5c9031 100644 --- a/pg_column_tetris--0.1.0.sql +++ b/pg_column_tetris--0.1.0.sql @@ -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 +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; + + 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 + 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; +END; +$$; + -- --------------------------------------------------------------------------- -- validate(oid) — Raises exception if layout is suboptimal -- --------------------------------------------------------------------------- diff --git a/test/sql/06_padding_wasted.sql b/test/sql/06_padding_wasted.sql new file mode 100644 index 0000000..9152d7e --- /dev/null +++ b/test/sql/06_padding_wasted.sql @@ -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 + 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; + 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; $$;