Materialized Views in a Database

study

For example there is a dashboard for a fictitious logistics company, they have a multitude of workers, each frequently visiting the website.

page

But there is a problem. The summary page that is found on the / route needs to execute a heavy query on each visit, which slows everything down. Not to mention the lack of reusability when embedding the summary SQL code inside the app.

-- Employee Leaderboard
SELECT
  concat(e.firstname, ' ', e.lastname) AS name,
  sum(od.price * od.quantity) AS amount
FROM
  employee AS e
  INNER JOIN order AS o ON o.employeeid = e.id
  INNER JOIN orderdetail AS od ON od.orderid = o.id
GROUP BY
  e.id
ORDER BY
  amount DESC
LIMIT
  5;

-- Customer Leaderboard
SELECT
  c.company AS name,
  sum(od.price * od.quantity) AS amount
FROM
  customer AS c
  INNER JOIN order AS o ON o.customerid = c.id
  INNER JOIN orderdetail AS od ON od.orderid = o.id
GROUP BY
  c.id
ORDER BY
  amount DESC
LIMIT
  5;

-- Product Leaderboard
SELECT
  p.product AS name, sum(od.price * od.quantity) AS amount
FROM
  orderdetail AS od
  INNER JOIN order AS o ON o.id = od.orderid
  INNER JOIN product AS p ON p.id = od.productid
GROUP BY
  p.id
ORDER BY
  amount DESC
LIMIT
  5;

-- Recent Orders
SELECT
  o.id,
  concat(e.firstname, ' ', e.lastname) AS employee,
  c.company AS customer,
  o.date,
  sum(od.price * od.quantity) AS amount
FROM
  order AS o
  INNER JOIN orderdetail AS od ON od.orderid = o.id
  INNER JOIN employee AS e ON e.id = o.employeeid
  INNER JOIN customer AS c ON c.id = o.customerid
WHERE
  o.date IS NOT NULL
GROUP BY
  o.id, e.firstname, e.lastname, c.company
ORDER BY
  o.date DESC
LIMIT
  5;

-- Product Reorder list
SELECT
  product AS name, reorderthreshold, unitsinstock, unitsordered
FROM
  product
WHERE
  (unitsinstock + unitsordered) < reorderthreshold;

Let's move this query inside the database as a MATERIALIZED VIEW.

Refactor to PostgreSQL Materialized View

PostgreSQL natively supports MATERIALIZED VIEW, which will simplify things a lot. The queries stay basically the same, so it's a simple refactor.

One problem one might encounter is a need to manually refresh the views, which can be solved with TRIGGER.

-- Employee Leaderboard
CREATE MATERIALIZED VIEW mv_employeeleaderboard
AS
SELECT
  concat(e.firstname, ' ', e.lastname) AS name,
  sum(od.price * od.quantity) AS amount
FROM
  employee AS e
  INNER JOIN order AS o ON o.employeeid = e.id
  INNER JOIN orderdetail AS od ON o.id = od.orderid
  GROUP BY
    e.id
  ORDER BY
    amount DESC
  LIMIT 5;

-- Customer Leaderboard
CREATE MATERIALIZED VIEW mv_customerleaderboard
AS
SELECT
  c.company AS name,
  sum(od.price * od.quantity) AS amount
FROM
  customer AS c
  INNER JOIN order AS o ON o.customerid = c.id
  INNER JOIN orderdetail AS od ON o.id = od.orderid
  GROUP BY
    c.id
  ORDER BY
    amount DESC
  LIMIT 5;

-- Product Leaderboard
CREATE MATERIALIZED VIEW mv_productleaderboard
AS
SELECT
  p.product AS name,
  sum(od.price * od.quantity) AS amount
FROM
  orderdetail AS od
  INNER JOIN order AS o ON od.orderid = o.id
  INNER JOIN product AS p ON od.productid = p.id
  GROUP BY
    p.id
  ORDER BY
    amount DESC
  LIMIT 5;

-- Recent Orders
CREATE VIEW mv_recentorders
AS
SELECT
  o.id,
  concat(e.firstname, ' ', e.lastname) AS employee,
  c.company AS customer,
  o.date,
  sum(od.price * od.quantity) AS subtotal
FROM
  order AS o
  INNER JOIN orderdetail AS od ON od.orderid = o.id
  INNER JOIN employee AS e ON o.employeeid = e.id
  INNER JOIN customer AS c ON o.customerid = c.id
  WHERE
    o.date IS NOT NULL
  GROUP BY
    o.id,
    e.firstname,
    e.lastname,
    c.company
  ORDER BY
    o.date DESC
  LIMIT 5;

-- Product Reorder list (VIEW is used to keep it up-to-date)
CREATE VIEW v_reorderlist
SELECT
  product AS name, reorderthreshold, unitsinstock, unitsordered
FROM
  product
WHERE
  (unitsinstock + unitsordered) < reorderthreshold;

Now let's create some TRIGGERs and a FUNCTION that will refresh created views on order update or delete.

CREATE OR REPLACE FUNCTION refresh_dashboard()
  RETURNS TRIGGER
AS $$
BEGIN
  REFRESH MATERIALIZED VIEW mv_employeeleaderboard;
  REFRESH MATERIALIZED VIEW mv_customerleaderboard;
  REFRESH MATERIALIZED VIEW mv_productleaderboard;
  REFRESH MATERIALIZED VIEW mv_recentorders;
  RETURN new;
END;
$$
LANGUAGE 'plpgsql';

CREATE TRIGGER dashboard_refresh_for_order_update
AFTER UPDATE
  ON order FOR EACH ROW EXECUTE PROCEDURE refresh_dashboard();

CREATE TRIGGER dashboard_refresh_for_order_delete
AFTER DELETE
  ON order FOR EACH ROW EXECUTE PROCEDURE refresh_dashboard();

These statements can be entered directly via a terminal into a database, or even better inserted by a migration.

It is easy to revert introduced changes with these statements.

DROP MATERIALIZED VIEW mv_employeeleaderboard;
DROP MATERIALIZED VIEW mv_customerleaderboard;
DROP MATERIALIZED VIEW mv_productleaderboard;
DROP MATERIALIZED VIEW mv_recentorders;
DROP VIEW v_reorderlist;

DROP TRIGGER dashboard_refresh_for_order_update ON order;
DROP TRIGGER dashboard_refresh_for_order_delete ON order;

Refactor to MySQL Materialized View Hack

Sadly MySQL doesn't have MATERIALIZED VIEW, what we could do is create a specialized table only used for the dashboard and manually update it's data.

First step is to encapsulate each query inside of a dedicated VIEW.

-- Employee Leaderboard
CREATE OR REPLACE VIEW v_employeeleaderboard
AS (
  SELECT
    concat(e.firstname, ' ', e.lastname) AS name,
    sum(od.price * od.quantity) AS amount
  FROM
    employee AS e
  INNER JOIN order AS o ON o.employeeid = e.id
  INNER JOIN orderdetail AS od ON o.id = od.orderid
  GROUP BY
    e.id
  ORDER BY
    amount DESC
  LIMIT 5);

-- Customer Leaderboard
CREATE OR REPLACE VIEW v_customerleaderboard
AS (
  SELECT
    c.company AS name,
    SUM(od.price * od.quantity) AS amount
  FROM
    customer AS c
  INNER JOIN order AS o ON o.customerid = c.id
  INNER JOIN orderdetail AS od ON o.id = od.orderid
  GROUP BY
    c.id
  ORDER BY
    amount DESC
  LIMIT 5);

-- Product Leaderboard
CREATE OR REPLACE VIEW v_productleaderboard
AS (
  SELECT
    p.product AS name,
    sum(od.price * od.quantity) AS amount
  FROM
    orderdetail AS od
  INNER JOIN order AS o ON od.orderid = o.id
  INNER JOIN product AS p ON od.productid = p.id
  GROUP BY
    p.id
  ORDER BY
    amount DESC
  LIMIT 5);

-- Recent Orders
CREATE OR REPLACE VIEW v_recentorders
AS (
  SELECT
    o.id,
    concat(e.firstname, ' ', e.lastname) AS employee,
    c.company AS customer,
    o.date,
    sum(od.price * od.quantity) AS amount
  FROM
    order AS o
  INNER JOIN orderdetail AS od ON od.orderid = o.id
  INNER JOIN employee AS e ON o.employeeid = e.id
  INNER JOIN customer AS c ON o.customerid = c.id
  WHERE
    o.date IS NOT NULL
  GROUP BY
    o.id,
    e.firstname,
    e.lastname,
    c.company
  ORDER BY
    o.date DESC
  LIMIT 5);

-- Product Reorder list (VIEW is used to keep it up-to-date)
CREATE OR REPLACE VIEW v_reorderlist
SELECT
  product AS name, reorderthreshold, unitsinstock, unitsordered
FROM
  product
WHERE
  (unitsinstock + unitsordered) < reorderthreshold;

Next is to create a TABLE that will imitate MATERIALIZED VIEW.

CREATE TABLE mv_employeeleaderboard AS
SELECT * FROM v_employeeleaderboard;

CREATE TABLE mv_customerleaderboard AS
SELECT * FROM v_customerleaderboard;

CREATE TABLE mv_productleaderboard AS
SELECT * FROM v_productleaderboard;

CREATE TABLE mv_recentorders AS
SELECT * FROM v_recentorders;

CREATE TRIGGER dashboard_refresh_for_order_update
AFTER UPDATE
  ON order FOR EACH ROW
BEGIN
  DELETE FROM mv_employeeleaderboard;
  DELETE FROM mv_customerleaderboard;
  DELETE FROM mv_productleaderboard;
  DELETE FROM mv_recentorders;

  INSERT INTO mv_employeeleaderboard
  SELECT * FROM v_employeeleaderboard;

  INSERT INTO mv_customerleaderboard
  SELECT * FROM v_customerleaderboard;

  INSERT INTO mv_productleaderboard
  SELECT * FROM v_productleaderboard;

  INSERT INTO mv_recentorders
  SELECT * FROM v_recentorders;
END;

CREATE TRIGGER dashboard_refresh_for_delete
AFTER DELETE
  ON order FOR EACH ROW
BEGIN
  DELETE FROM mv_employeeleaderboard;
  DELETE FROM mv_customerleaderboard;
  DELETE FROM mv_productleaderboard;
  DELETE FROM mv_recentorders;

  INSERT INTO mv_employeeleaderboard
  SELECT * FROM v_employeeleaderboard;

  INSERT INTO mv_customerleaderboard
  SELECT * FROM v_customerleaderboard;

  INSERT INTO mv_productleaderboard
  SELECT * FROM v_productleaderboard;

  INSERT INTO mv_recentorders
  SELECT * FROM v_recentorders;
END;

And to revert the changes.

DROP VIEW v_employeeleaderboard;
DROP VIEW v_customerleaderboard;
DROP VIEW v_productleaderboard;
DROP VIEW v_recentorders;
DROP VIEW v_reorderlist;

DROP TABLE IF EXISTS mv_customerleaderboard;
DROP TABLE IF EXISTS mv_employeeleaderboard;
DROP TABLE IF EXISTS mv_productleaderboard;
DROP TABLE IF EXISTS mv_recentorders;

DROP TRIGGER IF EXISTS dashboard_refresh_for_order_update;
DROP TRIGGER IF EXISTS dashboard_refresh_for_order_delete;

Final

In the end this will significantly speedup the dashboard loading. In a future project I will create a logistics mock application that will feature this enhancement.

PS: To use the views in your application code:

SELECT * FROM mv_employeeleaderboard;
SELECT * FROM mv_customerleaderboard;
SELECT * FROM mv_productleaderboard;
SELECT * FROM mv_recentorders;
SELECT * FROM v_reorderlist;