EthicsComputerCourses
Join training +91 90532 10052

Project: Shop Reporting Database

PostgreSQL · Lesson 32 of 32 Project 8 min read Updated

The shop's managers keep asking the developers for numbers: sales this month, best customers, which products are slow, revenue for any date range. Writing a new query every time is slow and leads to different answers for the same question. In this project you will build a small reporting layer inside the database, so every tool and person uses the same definitions.

You will create a reporting schema, a base view that joins the order data once, report views that use FILTER and window functions, a PL/pgSQL function for any date range, a materialized view for a heavier report, an index plan, and a read-only role for the reporting team. Every step runs in the browser; because each run starts from a fresh database, each example repeats the base view it needs.

The plan

NeedObjectLesson it uses
One trusted definition of "an order line with its details"View reporting.order_linesViews
Monthly revenue with status breakdown and running totalView reporting.monthly_salesJoins, Aggregates & FILTER, Window Functions
Customer value and tiersView reporting.customer_valueCASE, window ranking
Sales for any date rangeFunction reporting.sales_between()PL/pgSQL
Fast product dashboardMaterialized view reporting.product_performanceMaterialized views
Speed as data growsIndex planIndexes, EXPLAIN
Safe accessRole reporting_teamRoles

One business rule applies everywhere: cancelled orders do not count as sales. Putting that rule in one view, instead of in fifty queries, is the whole point of the project.

Step 1: the reporting schema and base view

A separate schema keeps reporting objects apart from the application's tables, and lets you grant access to all of them at once. The base view joins the four sales tables and calculates the line total, so no later query needs to repeat the join:

PostgreSQLTry it yourself
CREATE SCHEMA reporting;

CREATE VIEW reporting.order_lines AS
SELECT o.order_id, o.order_date, o.status,
       c.customer_id, c.first_name || ' ' || c.last_name AS customer, c.city, c.state,
       p.product_id, p.product_name, p.category,
       oi.quantity, oi.unit_price,
       oi.quantity * oi.unit_price AS line_total,
       o.status <> 'cancelled' AS counts_as_sale
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id;

SELECT count(*) AS lines,
       count(*) FILTER (WHERE counts_as_sale) AS sale_lines,
       sum(line_total) FILTER (WHERE counts_as_sale) AS revenue
FROM reporting.order_lines;
Output
Query OK
Query OK
lines | sale_lines | revenue
------+------------+----------
22    | 21         | 104474.00
(1 row)

The view exposes counts_as_sale as a boolean column, so the cancellation rule is defined once and every report filters on it. 21 of the 22 lines count as sales; the one cancelled line (the SSD in order 105) is excluded from revenue.

Step 2: monthly sales

Managers want one row per month with revenue, the number of orders in each state, and a running total for the year. FILTER gives the breakdown and a window function gives the running total:

PostgreSQLTry it yourself
CREATE SCHEMA reporting;
CREATE VIEW reporting.order_lines AS
SELECT o.order_id, o.order_date, o.status, c.customer_id,
       c.first_name || ' ' || c.last_name AS customer, c.city,
       p.product_id, p.product_name, p.category, oi.quantity,
       oi.quantity * oi.unit_price AS line_total,
       o.status <> 'cancelled' AS counts_as_sale
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id;

CREATE VIEW reporting.monthly_sales AS
SELECT date_trunc('month', order_date)::date AS month,
       count(DISTINCT order_id) FILTER (WHERE counts_as_sale) AS orders,
       count(DISTINCT order_id) FILTER (WHERE status = 'cancelled') AS cancelled,
       sum(line_total) FILTER (WHERE counts_as_sale) AS revenue,
       sum(sum(line_total) FILTER (WHERE counts_as_sale))
         OVER (ORDER BY date_trunc('month', order_date)::date) AS running_revenue
FROM reporting.order_lines
GROUP BY 1;

SELECT * FROM reporting.monthly_sales ORDER BY month;
Output
Query OK
Query OK
Query OK
month      | orders | cancelled | revenue  | running_revenue
-----------+--------+-----------+----------+----------------
2025-01-01 | 3      | 0         | 23544.00 | 23544.00
2025-02-01 | 2      | 1         | 12947.00 | 36491.00
2025-03-01 | 3      | 0         | 7697.00  | 44188.00
2025-04-01 | 3      | 0         | 43691.00 | 87879.00
2025-05-01 | 3      | 0         | 16595.00 | 104474.00
(5 rows)

The expression sum(sum(...)) OVER (...) looks strange at first: the inner sum is the monthly total from GROUP BY, and the outer sum ... OVER adds those monthly totals up in date order. Window functions run after grouping, which is why this works. April was the best month, thanks to Sneha's two monitors.

Step 3: customer value and tiers

The marketing team wants every customer with their spend, a tier and a rank. Customers who never ordered must still appear, so this view starts from customers and uses a LEFT JOIN:

PostgreSQLTry it yourself
CREATE SCHEMA reporting;
CREATE VIEW reporting.order_lines AS
SELECT o.order_id, o.order_date, o.status, o.customer_id,
       oi.quantity * oi.unit_price AS line_total,
       o.status <> 'cancelled' AS counts_as_sale
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id;

CREATE VIEW reporting.customer_value AS
SELECT c.customer_id,
       c.first_name || ' ' || c.last_name AS customer,
       c.city,
       count(DISTINCT ol.order_id) FILTER (WHERE ol.counts_as_sale) AS orders,
       coalesce(sum(ol.line_total) FILTER (WHERE ol.counts_as_sale), 0) AS spent,
       max(ol.order_date) AS last_order,
       CASE
         WHEN coalesce(sum(ol.line_total) FILTER (WHERE ol.counts_as_sale), 0) >= 20000 THEN 'Gold'
         WHEN coalesce(sum(ol.line_total) FILTER (WHERE ol.counts_as_sale), 0) >= 5000 THEN 'Silver'
         WHEN count(ol.order_id) > 0 THEN 'Bronze'
         ELSE 'Not yet ordered'
       END AS tier,
       dense_rank() OVER (ORDER BY coalesce(sum(ol.line_total) FILTER (WHERE ol.counts_as_sale), 0) DESC) AS spend_rank
FROM customers AS c
LEFT JOIN reporting.order_lines AS ol ON ol.customer_id = c.customer_id
GROUP BY c.customer_id;

SELECT spend_rank, customer, city, orders, spent, tier
FROM reporting.customer_value
ORDER BY spend_rank, customer
LIMIT 6;

SELECT tier, count(*) AS customers
FROM reporting.customer_value
GROUP BY tier
ORDER BY min(spend_rank);
Output
Query OK
Query OK
Query OK
spend_rank | customer     | city     | orders | spent    | tier
-----------+--------------+----------+--------+----------+-------
1          | Sneha Gupta  | Delhi    | 1      | 32797.00 | Gold
2          | Priya Verma  | Jaipur   | 2      | 16899.00 | Silver
3          | Aarav Sharma | Rohtak   | 3      | 14944.00 | Silver
4          | Rohan Mehta  | Mumbai   | 2      | 14397.00 | Silver
5          | Isha Kapoor  | Gurugram | 1      | 8695.00  | Silver
6          | Vikram Yadav | Rohtak   | 1      | 4298.00  | Bronze
(6 rows)

tier            | customers
----------------+----------
Gold            | 1
Silver          | 4
Bronze          | 6
Not yet ordered | 1
(4 rows)

This version of the base view keeps only the columns this step needs, which is fine for a self-contained example. In the real database there is just one order_lines view. Ananya appears as Bronze with zero spend: her only order was cancelled, so she has ordered but not bought anything, which is exactly the kind of case a marketing team wants to see.

Step 4: sales for any date range

Views cannot take parameters, but functions can. reporting.sales_between returns revenue per category for any period, and it is STABLE because it only reads data:

PostgreSQLTry it yourself
CREATE SCHEMA reporting;
CREATE VIEW reporting.order_lines AS
SELECT o.order_id, o.order_date, p.category,
       oi.quantity, oi.quantity * oi.unit_price AS line_total,
       o.status <> 'cancelled' AS counts_as_sale
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
JOIN products AS p ON p.product_id = oi.product_id;

CREATE FUNCTION reporting.sales_between(p_from date, p_to date)
RETURNS TABLE (category text, orders bigint, units bigint, revenue numeric, share_pct numeric)
LANGUAGE plpgsql
STABLE
AS $$
BEGIN
  IF p_to < p_from THEN
    RAISE EXCEPTION 'End date % is before start date %', p_to, p_from;
  END IF;

  RETURN QUERY
    SELECT ol.category::text,
           count(DISTINCT ol.order_id),
           sum(ol.quantity),
           sum(ol.line_total),
           round(100 * sum(ol.line_total) / sum(sum(ol.line_total)) OVER (), 1)
    FROM reporting.order_lines AS ol
    WHERE ol.counts_as_sale
      AND ol.order_date >= p_from AND ol.order_date < p_to + 1
    GROUP BY ol.category
    ORDER BY 4 DESC;
END;
$$;

SELECT * FROM reporting.sales_between('2025-01-01', '2025-03-31');
Output
Query OK
Query OK
Query OK
category    | orders | units | revenue  | share_pct
------------+--------+-------+----------+----------
Displays    | 1      | 1     | 15999.00 | 36.2
Accessories | 4      | 8     | 13892.00 | 31.4
Audio       | 2      | 2     | 11498.00 | 26.0
Books       | 2      | 5     | 2250.00  | 5.1
Storage     | 1      | 1     | 549.00   | 1.2
(5 rows)

The date filter >= p_from AND < p_to + 1 includes the whole last day and would still be correct if order_date later became a timestamptz. The function also refuses a reversed date range with a clear message, instead of quietly returning nothing.

Step 5: a materialized view for the product dashboard

The product dashboard is opened all day, and on a real shop with millions of order lines its query would be heavy. A materialized view stores the result, and a unique index allows a concurrent refresh, for example every 15 minutes:

PostgreSQLTry it yourself
CREATE SCHEMA reporting;

CREATE MATERIALIZED VIEW reporting.product_performance AS
SELECT p.product_id, p.product_name, p.category, p.stock,
       coalesce(sum(oi.quantity) FILTER (WHERE o.status <> 'cancelled'), 0) AS units_sold,
       coalesce(sum(oi.quantity * oi.unit_price) FILTER (WHERE o.status <> 'cancelled'), 0) AS revenue,
       CASE
         WHEN p.stock = 0 THEN 'Restock now'
         WHEN coalesce(sum(oi.quantity) FILTER (WHERE o.status <> 'cancelled'), 0) = 0 THEN 'Not selling'
         ELSE 'OK'
       END AS action
FROM products AS p
LEFT JOIN order_items AS oi ON oi.product_id = p.product_id
LEFT JOIN orders AS o ON o.order_id = oi.order_id
GROUP BY p.product_id;

CREATE UNIQUE INDEX product_performance_id_idx ON reporting.product_performance (product_id);

REFRESH MATERIALIZED VIEW CONCURRENTLY reporting.product_performance;

SELECT product_name, stock, units_sold, revenue, action
FROM reporting.product_performance
WHERE action <> 'OK' OR revenue > 15000
ORDER BY revenue DESC;
Output
Query OK
Query OK
Query OK
Query OK
product_name                | stock | units_sold | revenue  | action
----------------------------+-------+------------+----------+------------
27-inch Monitor             | 18    | 3          | 47997.00 | OK
Noise-Cancelling Headphones | 25    | 2          | 17998.00 | OK
HD Webcam                   | 0     | 1          | 2199.00  | Restock now
Graphics Tablet             | 12    | 0          | 0        | Not selling
(4 rows)

The dashboard now reads a small stored table. The action column turns numbers into decisions: the webcam needs restocking, the tablet is not selling, and the monitor earns the most. On a real server, schedule the refresh with cron or the pg_cron extension.

Step 6: the index plan

Views do not need indexes of their own, but the tables under them do. Look at what the reports filter, join and group on, and index exactly that:

Query patternIndex
Join order_items to ordersorder_items (order_id)
Join order_items to productsorder_items (product_id)
Join orders to customersorders (customer_id)
Date ranges in sales_between and monthly reportsorders (order_date)
Operations team: open orders by datePartial index on orders (order_date) for pending and shipped
PostgreSQLTry it yourself
CREATE INDEX order_items_order_idx ON order_items (order_id);
CREATE INDEX order_items_product_idx ON order_items (product_id);
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE INDEX orders_date_idx ON orders (order_date);
CREATE INDEX orders_open_date_idx ON orders (order_date) WHERE status IN ('pending', 'shipped');

SELECT tablename, indexname
FROM pg_indexes
WHERE schemaname = 'public' AND tablename IN ('orders', 'order_items')
ORDER BY tablename, indexname;
Output
Query OK
Query OK
Query OK
Query OK
Query OK
tablename   | indexname
------------+------------------------
order_items | order_items_order_idx
order_items | order_items_pkey
order_items | order_items_product_idx
orders      | orders_customer_idx
orders      | orders_date_idx
orders      | orders_open_date_idx
orders      | orders_pkey
(7 rows)

The primary keys already have indexes, so they are listed too. With only 15 orders, EXPLAIN will still show sequential scans, and that is correct: reading one page is cheaper than using an index. The plan pays off as the tables grow; re-check it with EXPLAIN (ANALYZE, BUFFERS) on realistic data, as you learned in EXPLAIN & EXPLAIN ANALYZE, and drop any index that pg_stat_user_indexes shows is never used.

Step 7: a read-only role for the reporting team

Finally, give the analysts access to the reporting layer only. They can read the views and call the function, but they cannot see or change the raw tables directly. On your own server (roles do not run in the browser):

PostgreSQL
CREATE ROLE reporting_team NOLOGIN;
GRANT USAGE ON SCHEMA reporting TO reporting_team;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO reporting_team;
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA reporting TO reporting_team;
ALTER DEFAULT PRIVILEGES IN SCHEMA reporting GRANT SELECT ON TABLES TO reporting_team;

CREATE ROLE farhan LOGIN PASSWORD 'a-long-random-password' IN ROLE reporting_team;

This works because a view reads its tables with the privileges of the view's owner, so the analysts read the data through the reporting views without any grant on the base tables. Functions are different: by default they run with the privileges of the caller, which is fine here because sales_between only reads the reporting.order_lines view. ALL TABLES in the grant includes views and materialized views.

What you built

  • A reporting schema that keeps reports separate and easy to secure.
  • One base view that defines "an order line" and the cancellation rule once.
  • Report views using FILTER, CASE, window functions and LEFT JOIN for customers without orders.
  • A parameterised PL/pgSQL function with input validation.
  • A materialized view with a unique index and concurrent refresh.
  • An index plan based on the joins and filters the reports really use, and a least-privilege role.

Key points

  • Put shared business rules, such as "cancelled orders are not sales", in one view and build everything on it.
  • Use views for live reports, functions for parameterised reports and materialized views for heavy dashboards.
  • FILTER, window functions and LEFT JOIN produce complete, readable reports.
  • Index the columns your reports join and filter on, and verify with EXPLAIN ANALYZE on realistic data.
  • Give report users access to a reporting schema, not to the raw tables.

Exercise

Extend the project with a view reporting.city_sales that shows, for each state, the number of customers, the number of customers who have bought something, and the revenue (excluding cancelled orders), with states that have no sales showing 0 revenue. Sort by revenue, highest first.

Show solution
PostgreSQL
CREATE SCHEMA reporting;
CREATE VIEW reporting.order_lines AS
SELECT o.order_id, o.customer_id, oi.quantity * oi.unit_price AS line_total,
       o.status <> 'cancelled' AS counts_as_sale
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id;

CREATE VIEW reporting.city_sales AS
SELECT c.state,
       count(DISTINCT c.customer_id) AS customers,
       count(DISTINCT c.customer_id) FILTER (WHERE ol.counts_as_sale) AS buyers,
       coalesce(sum(ol.line_total) FILTER (WHERE ol.counts_as_sale), 0) AS revenue
FROM customers AS c
LEFT JOIN reporting.order_lines AS ol ON ol.customer_id = c.customer_id
GROUP BY c.state;

SELECT * FROM reporting.city_sales ORDER BY revenue DESC, state;

Delhi comes first because of Sneha's large order, and Haryana has three customers who all bought something. Tamil Nadu shows one customer, no buyers and 0 revenue (Ananya's only order was cancelled), and Maharashtra shows two customers but only one buyer, because Divya has never ordered.

Quick Quiz

1. Why define counts_as_sale in the base view instead of in each report?

2. Which object should handle "revenue between any two dates"?

3. What does REFRESH MATERIALIZED VIEW CONCURRENTLY need?

4. Why do the analysts not need SELECT on the base tables?

Learn PostgreSQL with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call