Capstone — Migrating and Extending a MySQL Database in PostgreSQL

PostgreSQL

Chapter 12 · Capstone: Migrating and Extending a MySQL Database in PostgreSQL

Eleven chapters covered what's genuinely different about Postgres. This capstone combines three of them — JSONB, recursive CTEs, and PL/pgSQL — into one real, working schema, deliberately reusing ordinary relational design everywhere else, exactly the way postgres1-1 promised this course would work.

The Scenario

Start from a small, ordinary mysql2/mysql3-style e-commerce schema — customers, categories, products, orders, order items — and reimplement it in Postgres, reaching for this course's own distinguishing features only where they provide genuine value, echoing postgres1-3's own warning against reaching for richness that isn't actually earning its keep.

The Schema

CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL
);

CREATE TABLE categories (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  parent_id INT REFERENCES categories(id),
  tax_rate NUMERIC(4,3) NOT NULL DEFAULT 0.000
);

CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  category_id INT REFERENCES categories(id),
  price NUMERIC(10,2) NOT NULL,
  attributes JSONB
);
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  customer_id INT REFERENCES customers(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id SERIAL PRIMARY KEY,
  order_id INT REFERENCES orders(id),
  product_id INT REFERENCES products(id),
  quantity INT NOT NULL,
  unit_price NUMERIC(10,2) NOT NULL
);

customers, orders, and order_items are ordinary relational tables — nothing here needed anything Postgres-specific. categories and products are where the interesting work happens.

Recursive CTE — Category Breadcrumbs

categories self-references via parent_id, the same hierarchical pattern postgres1-5 introduced — but this time walking up toward the root, rather than down toward the leaves the way that chapter's own employee-hierarchy example did, building a display-ready breadcrumb path:

WITH RECURSIVE breadcrumb AS (
  SELECT id, name, parent_id, name::TEXT AS path
  FROM categories
  WHERE id = 15  -- e.g. "Laptops"

  UNION ALL

  SELECT c.id, c.name, c.parent_id, c.name || ' > ' || breadcrumb.path
  FROM categories c
  JOIN breadcrumb ON c.id = breadcrumb.parent_id
)
SELECT path FROM breadcrumb WHERE parent_id IS NULL;
-- 'Electronics > Computers > Laptops'

JSONB — Product Attributes

products.attributes reuses postgres1-4's own product catalog pattern directly — a shirt-style product might store {"size": "M", "color": "blue"}, a laptop-style product {"ram_gb": 32, "storage_gb": 1024}, all in the same column, all queryable through the GIN index already declared on it.

A Custom PL/pgSQL Function — Order Total With Tax

This function combines postgres1-8's own function-writing pattern with this chapter's own recursive category walk — for each order line, it walks up the category tree to find the nearest set tax rate, exactly the kind of composition this whole course has been building toward:

CREATE FUNCTION order_total_with_tax(order_id_param INT)
RETURNS NUMERIC AS $$
DECLARE
  subtotal NUMERIC := 0;
  tax_total NUMERIC := 0;
  item RECORD;
  cat_tax NUMERIC;
BEGIN
  FOR item IN
    SELECT oi.quantity, oi.unit_price, p.category_id
    FROM order_items oi
    JOIN products p ON p.id = oi.product_id
    WHERE oi.order_id = order_id_param
  LOOP
    subtotal := subtotal + (item.quantity * item.unit_price);

    WITH RECURSIVE cat_walk AS (
      SELECT id, parent_id, tax_rate FROM categories WHERE id = item.category_id
      UNION ALL
      SELECT c.id, c.parent_id, c.tax_rate
      FROM categories c
      JOIN cat_walk ON c.id = cat_walk.parent_id
    )
    SELECT tax_rate INTO cat_tax FROM cat_walk WHERE tax_rate > 0 LIMIT 1;

    tax_total := tax_total + (item.quantity * item.unit_price * COALESCE(cat_tax, 0));
  END LOOP;

  RETURN subtotal + tax_total;
END;
$$ LANGUAGE plpgsql;

SELECT order_total_with_tax(42);

Chapter Attribution

Capstone elementChapter
Ordinary relational schema (customers/orders/order_items)mysql2 / mysql3 (assumed baseline)
Self-referencing categories, recursive breadcrumb walkpostgres1-5
JSONB product attributes + GIN indexpostgres1-4, postgres1-7
Dollar-quoted function, FOR loop, RECORD variablepostgres1-8
Nested recursive CTE inside the functionpostgres1-5, postgres1-8 (composed)
Honest scope note
This capstone is a schema-redesign exercise, not a live migration walkthrough — real MySQL-to-Postgres data migration tooling (like pgloader) is deliberately out of scope. No replication/HA setup from postgres1-11 is applied here, and full-text search from postgres1-6 isn't exercised, since this particular schema's own natural fit was JSONB, recursion, and PL/pgSQL instead — not every chapter's feature needs to appear in every real schema, which is itself the point. This is also a deliberately lighter, single-engine echo of the site's own cp1 bucket-list interest in relational-to-document conversion — a real reimplementation exercise, not the harder cross-engine MySQL-to-MongoDB problem cp1 itself describes.
postgres1-1's own promise, kept
Three tables in this schema (customers, orders, order_items) needed nothing Postgres-specific at all — exactly as expected, since postgres1-1 opened this course by promising it wouldn't re-teach SQL fundamentals mysql2/mysql3 already cover. Postgres's own distinguishing features were reached for only where categories and products genuinely needed them — postgres1-3's own "richness is a capability, not an obligation" warning, now demonstrated at the whole-schema level.

Hands-On Exercises

Exercise 1

Explain why the breadcrumb recursive CTE in this chapter walks in the opposite direction from postgres1-5's own employee-hierarchy example, and explain what would need to change in the anchor/recursive terms to reverse the direction.

📄 View solution
Exercise 2

Explain how order_total_with_tax() composes postgres1-8's own function-writing material with postgres1-5's own recursive CTE material, rather than being purely one or the other.

📄 View solution
Exercise 3

Using this chapter's own tip-box, explain why customers/orders/order_items needed nothing Postgres-specific, and explain how this demonstrates postgres1-3's own "richness is a capability, not an obligation" warning at the whole-schema level.

📄 View solution

Chapter 12 Quick Reference — Course Complete

  • Ordinary relational design (customers/orders/order_items) reused unchanged, exactly per postgres1-1's own opening promise
  • Recursive CTE walks UP the category tree for breadcrumbs — the reverse direction of postgres1-5's own DOWN-walking employee example
  • JSONB + GIN reuses postgres1-4/postgres1-7's own product-attributes pattern directly
  • The PL/pgSQL function composes postgres1-8's own function-writing pattern WITH a nested recursive CTE — a genuine synthesis, not three separate demos
  • Honest scope note: no live migration tooling, no replication/HA, no full-text search — not every feature belongs in every schema
  • This closes the full 12-chapter PostgreSQL course