Modeling data in tables
Good tables make every later feature easier. Bad tables turn every report into an argument about which number is right. This first lesson of SQL & Data Modeling shows you how to split real grocery data into tables that keep each fact in one place, and how to write them in SQL and in Prisma. You need no database experience to start.
By the end you can
- Turn a messy sales sheet into clean tables that point to each other with keys
- Pick the right column type for money, time, quantities and ids
- Model one-to-many and many-to-many links, including a junction table
- Write the DDL and the matching Prisma schema, and run the first migration
- Decide when to break the rules on purpose: copied totals, soft delete, enums
The everyday picture
Think of a shopkeeper's ledger book on the counter (in South Asia it is called a khata). On every line he writes the date, the item, the amount, and the supplier's full name, phone number and address. After a year he has 3,000 lines. Then the supplier moves to a new warehouse. Now he must find and fix every line with the old address. He misses some. Next month his son reads the book and finds two different addresses for the same supplier. Which one is true?
A careful shopkeeper does it differently. He keeps one page at the back for suppliers. Each supplier gets a number and one line with the name, phone and address. On the sales pages he only writes "Supplier 14". When the address changes, he fixes one line, and every sale page is still correct.
That number is a key, the back page is a table, and "write each fact in one place, refer to it by number" is most of what data modeling means. The rest of this lesson is the detail.
Tables, rows and the right types
A table holds one kind of thing: stores, products, sales. Each row is one of those things, and each column is one fact about it. Every column has a type, and Postgres checks every value against it. A wrong value is rejected at the door, before it can spoil a report. So choosing the type is your first design decision, and the cheapest one to get right.
Two types cause most of the trouble in shop and payment systems: money and time.
Money goes in integer cents, never in a float. A floating-point column cannot hold 0.1 exactly, so small errors appear and add up over thousands of receipts. Whole numbers have no such problem:
SELECT 0.1::float8 + 0.2::float8 AS as_float,
0.1::numeric + 0.2::numeric AS as_numeric,
10 + 20 AS as_cents; as_float | as_numeric | as_cents
---------------------+------------+----------
0.30000000000000004 | 0.3 | 30Mangrove Market stores every amount as a whole number of cents: a bag of rice at $6.50 is 650 in a column named unit_price_cents. Integers are exact and fast, and they arrive in JavaScript as plain numbers that add up correctly. You turn cents into dollars only when you show the number. A Postgres integer holds up to about 21 million dollars in cents, plenty for one price or one receipt, and sum() over an integer column returns a bigint, so a total over millions of receipts does not overflow.
The other exact choice is numeric(12,2): up to 12 digits, 2 after the decimal point. It is a fine type, and many teams use it. The catch is in your app: drivers hand numeric values to JavaScript as strings or as a decimal object (Prisma uses Prisma.Decimal), so every calculation needs a decimal library. Pick one convention for the whole company and keep it. This course uses cents.
Moments go in timestamptz. A timestamptz value is one exact moment. Postgres stores it in UTC and shows it in your session's time zone. A sale at 21:30 in a store at UTC+6 is 15:30 UTC, and both displays mean the same moment. The older type, timestamp without time zone, stores only the clock reading. If one server writes local time and another writes UTC, the database cannot tell them apart, and your "sales by hour" chart shifts by six hours for half the stores. The examples in this course use the zone name 'Asia/Dhaka' (UTC+6) in code; put your own there, such as 'Africa/Lagos' or 'Asia/Jakarta'.
For the business day of a sale, add a separate date column. A receipt at 00:15 after a late festival night may belong to the previous trading day, and that is a business rule, not a clock fact.
| Fact | Use | Avoid | Why |
|---|---|---|---|
| Price, total, discount | integer cents (or numeric(12,2)) | real, double precision, text | Exact maths; text cannot be summed or checked |
| When something happened | timestamptz | timestamp | Without a zone, local and UTC times get mixed up |
| Business day | date | A string like '06/10/26' | Dates sort and compare correctly; strings do not |
| Quantity | integer | numeric for whole units | Smaller and faster; use numeric(10,3) for loose kg |
| Id | bigint | integer | integer stops at about 2.1 billion |
| Names, codes | text | Very short varchar(n) | Same speed in Postgres; add a CHECK if length matters |
| Yes / no | boolean | 'Y' / 'N' text | Only true, false or NULL can get in |
Quick check
A can of beans costs $1.25. What does Mangrove store in unit_price_cents?
Cents are a whole number, so $1.25 is 125, exact and easy to add up. A double cannot store most decimal fractions exactly, and text cannot be summed or checked by the database.
Primary keys: the number that never changes
A primary key is the row's identity. Other tables will store it, so it must be unique, never empty, and it should never change.
It is tempting to use a business code as the key, such as the store code 'S027' or the barcode. These are natural keys, and they change more often than people expect. Regions get re-coded. A supplier reuses a barcode for a new pack size. If 'S027' is stored in ten million sale rows, renaming it means rewriting all of them. The safe pattern is a surrogate key: a meaningless id as the primary key, plus the business code in its own column with a UNIQUE constraint. The code can then change in one row.
For the id itself you have two families:
bigintidentity. Postgres hands out 1, 2, 3 and so on. It is small (8 bytes), easy to read on a support call, and new rows always go to the end of the index. The downsides: only the database can create one, so an offline handheld scanner cannot, and the numbers are easy to guess in a URL.- UUID. 16 bytes, and any device can create one without asking the server, which is what offline-first apps need (Offline-first apps and data sync). Hard to guess. A random UUID (version 4,
gen_random_uuid()in Postgres) has a cost, shown below. A time-ordered UUID (version 7, or the similar ULID format) avoids most of that cost. Postgres 16 has no built-in v7 function (Postgres 18 addsuuidv7()), so on 16 you create v7 ids in the app; recent Prisma versions support@default(uuid(7)).
Quick check
Head office plans to re-code every store by region next year. What should the primary key of site be?
A surrogate id never changes, so re-coding touches one column in one row per store. Using the code as the key means rewriting every sale that points at it. Names and cities change too, and dropping the human code makes support calls and reports much harder.
Relationships: how tables point at each other
A foreign key is a column that holds another table's id. sale.site_id holds the id of the store where the sale happened. Postgres checks that the store exists, so a sale can never point at a site that is not there.
In this course, two table names come from the retail world, so here they are once. A site is any place that holds stock: a store, a partner shop or a distribution centre (DC). An article is one product, one SKU, such as "Rice 5kg". There are three shapes of link:
- One-to-many (1:N). One store has many sales; each sale belongs to one store. The "many" side holds the foreign key:
sale.site_id. This is the most common shape by far. - Many-to-many (N:M). An area manager covers many stores, and a store can have several managers. Neither table can hold a single foreign key, so you add a third table, a junction table,
staff_site(staff_id, site_id), with one row per pair. Thestocktable is also a junction (site × article) that carries its own fact, the quantity. - One-to-one (1:1). Rare. Use it to split off optional or rarely read data, such as a store's lease details. The second table's primary key is also its foreign key, so there can be at most one row per store.
stock is a junction table with one row per site and article.A foreign key also decides what happens when someone deletes the parent row. The default refuses the delete while child rows exist, which is right for stores and products: you must never lose sales history because someone removed a store. ON DELETE CASCADE deletes the children too. Use it only where the children have no meaning alone, such as the lines of a receipt.
Quick check
One store has many sales. Where does the foreign key go?
The "many" side holds the key: each sale stores the one store it belongs to. A sale_id on site could hold only one sale per store. A junction table is for many-to-many, and a sale never belongs to two stores.
Normalization: every fact in one place
Normalization is the ledger lesson written as rules. Textbooks list many "normal forms". In daily work you need the first three, and each one fixes a kind of mess you have seen in spreadsheets.
- First normal form (1NF): one value per cell. A column like "items: Rice x2, Oil x1" holds a list. You cannot sum it, index it or check it. Split it into one row per item.
- Second normal form (2NF): each column depends on the whole key. A receipt line is identified by (receipt, line number). The store name depends only on the receipt, not on the line, so it belongs on the receipt, not repeated on every line.
- Third normal form (3NF): no column depends on another non-key column. The store address depends on the store, not on the sale. It belongs in the store table.
In one sentence: each fact lives in exactly one row of one table, and everything else refers to it by id.
One copy is not a mistake. sale_line.unit_price_cents looks like a duplicate of article.unit_price_cents, but it records a different fact: the price at the moment of sale. Tomorrow's price change must not rewrite yesterday's receipts. When a value describes a moment in history, store it with the history.
Think first: sale.total_cents can be computed from its lines. Why store it at all?
Because it is read far more often than it is written. Dashboards sum totals over millions of receipts, and the POS prints the total on the receipt. Computing it from the lines every time means a join and a sum per receipt. The copy is safe here because a receipt is written once, in one transaction with its lines, and never edited afterwards. If receipts could be edited, every edit would have to update the total in the same transaction. That is denormalization on purpose: a copy with a clear reason and a clear way to stay correct.
Quick check
A partner_order row has a column products = 'rice:2, oil:1'. Which rule does it break first?
The cell holds a list, which is the 1NF problem. You cannot sum, index or check what is inside it. The fix is an order_line table with one row per product. 2NF and 3NF are about columns that sit in the wrong table, which comes after you have one value per cell.
Rules you write once: constraints, audit columns, soft delete, enums
Constraints are the last line of defence. Your NestJS DTO checks that a quantity is positive, but only for requests that pass through that endpoint. A data-fix script, a second service, or someone in psql at midnight skips it. A constraint in the database applies to every writer, forever. Use NOT NULL unless empty truly makes sense, UNIQUE on every business code, CHECK for simple rules like qty >= 0, and foreign keys for every link.
Audit columns answer "when did this change?" during an incident. Give each table created_at and updated_at. Prisma's @updatedAt is filled in by Prisma Client, so an UPDATE from psql or another service leaves it unchanged. A small trigger (step 4 below) covers every writer.
Soft delete means marking a row as gone (is_active = false or a deleted_at time) instead of removing it. It keeps history and makes undo easy. It also has costs: every query must remember to filter, and a UNIQUE code now clashes with its own deleted copy. A partial unique index fixes that (Indexes from the inside). A good default: deactivate master rows that history points to, such as a closed store or a discontinued product, and really delete rows that nothing references.
Enums or lookup tables. A Postgres enum is a fixed list inside the schema. Adding a value is one quick ALTER TYPE … ADD VALUE, but you cannot remove one, and every change needs a migration. A lookup table, such as category, is ordinary rows: the business can add one from an admin screen, and it can carry extra columns like a label in a second language, a sort order or an active flag. Use an enum when your code branches on the value: a mobile-wallet payment (M-Pesa, UPI, bKash and similar) runs a different flow from cash. Use a lookup table for lists that people manage.
Quick check
The buying team adds new product categories most months and wants to do it from an admin screen. Enum or lookup table?
A list that people change belongs in rows. Every enum change is a schema migration and a deploy, and values cannot be removed. Free text brings back the "one thing typed three ways" problem.
Build it
We will build the five core tables by hand in SQL, watch the constraints work, and then build the same tables with Prisma. No Docker yet? The Practice section below runs real PostgreSQL in your browser, so you can try the constraint rules there first.
-
Start a practice Postgres 16
Docker is the quickest way. The database runs on your machine only.
Terminaldocker run --name pg16 -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:16 docker exec -it pg16 createdb -U postgres shop docker exec -it pg16 psql -U postgres -d shop # opens a SQL prompt -
Create the master tables
Enums for values the code branches on, a lookup table for categories, surrogate ids, business codes as
UNIQUEcolumns, and money in cents.SQLCREATE TYPE site_format AS ENUM ('STORE', 'PARTNER', 'DC'); CREATE TYPE pay_method AS ENUM ('CASH', 'CARD', 'WALLET'); CREATE TABLE site ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, site_code text NOT NULL UNIQUE, -- the code people say: 'S027' name text NOT NULL, format site_format NOT NULL, city text NOT NULL, is_active boolean NOT NULL DEFAULT true, -- closed stores keep history created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE category ( -- a lookup table id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE ); CREATE TABLE article ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, sku text NOT NULL UNIQUE, -- barcode on the pack name text NOT NULL, category_id bigint NOT NULL REFERENCES category (id), unit_price_cents integer NOT NULL CHECK (unit_price_cents >= 0), -- 650 = $6.50 is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -
Create stock, sale and sale_line
Composite primary keys for the junction and line tables, a
UNIQUErule that blocks duplicate receipt uploads, and indexes on the foreign key columns.SQLCREATE TABLE stock ( site_id bigint NOT NULL REFERENCES site (id), article_id bigint NOT NULL REFERENCES article (id), qty integer NOT NULL DEFAULT 0 CHECK (qty >= 0), updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (site_id, article_id) -- one row per site per article ); CREATE INDEX stock_article_id_idx ON stock (article_id); CREATE TABLE sale ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, site_id bigint NOT NULL REFERENCES site (id), local_txn_id text NOT NULL, -- receipt number from the POS sale_date date NOT NULL, -- business day in the store's zone sold_at timestamptz NOT NULL DEFAULT now(), total_cents integer NOT NULL CHECK (total_cents >= 0), pay_method pay_method NOT NULL, UNIQUE (site_id, local_txn_id) -- a retried upload cannot insert twice ); CREATE INDEX sale_site_id_sale_date_idx ON sale (site_id, sale_date); CREATE TABLE sale_line ( sale_id bigint NOT NULL REFERENCES sale (id) ON DELETE CASCADE, line_no smallint NOT NULL, article_id bigint NOT NULL REFERENCES article (id), qty integer NOT NULL CHECK (qty > 0), unit_price_cents integer NOT NULL, -- price at the moment of sale PRIMARY KEY (sale_id, line_no) ); CREATE INDEX sale_line_article_id_idx ON sale_line (article_id); -
Keep updated_at honest with a trigger
This runs inside the database, so it works for Prisma, psql and any other writer. Repeat the
CREATE TRIGGERfor each table.SQLCREATE FUNCTION set_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at := now(); RETURN NEW; END $$; CREATE TRIGGER site_set_updated_at BEFORE UPDATE ON site FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -
Try to break the rules
Insert one store, one category and one article, then try three bad writes. Each one is stopped with a clear message that names the constraint.
SQLINSERT INTO site (site_code, name, format, city) VALUES ('S001', 'Riverside', 'STORE', 'Bayside'); INSERT INTO category (name) VALUES ('Grocery'); INSERT INTO article (sku, name, category_id, unit_price_cents) VALUES ('8901234500012', 'Rice 5kg', 1, 650); INSERT INTO stock (site_id, article_id, qty) VALUES (1, 1, -1); -- negative stock INSERT INTO stock (site_id, article_id, qty) VALUES (999, 1, 5); -- store does not exist INSERT INTO site (site_code, name, format, city) VALUES ('S001', 'Copy', 'STORE', 'Bayside');OutputERROR: new row for relation "stock" violates check constraint "stock_qty_check" ERROR: insert or update on table "stock" violates foreign key constraint "stock_site_id_fkey" DETAIL: Key (site_id)=(999) is not present in table "site". ERROR: duplicate key value violates unique constraint "site_site_code_key" DETAIL: Key (site_code)=(S001) already exists. -
Write the same tables in Prisma
In a fresh NestJS project, run
npm i -D prisma,npm i @prisma/clientandnpx prisma init. PointDATABASE_URLin.envat a new empty database, for exampleshop_prisma. Models are PascalCase with camelCase fields;@mapand@@mapkeep the real names snake_case.Prismadatasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } enum SiteFormat { STORE PARTNER DC @@map("site_format") } model Site { id BigInt @id @default(autoincrement()) siteCode String @unique @map("site_code") name String format SiteFormat city String isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) stock Stock[] sales Sale[] @@map("site") } model Category { id BigInt @id @default(autoincrement()) name String @unique articles Article[] @@map("category") }Prismamodel Article { id BigInt @id @default(autoincrement()) sku String @unique name String categoryId BigInt @map("category_id") unitPriceCents Int @map("unit_price_cents") // 650 means $6.50 isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) category Category @relation(fields: [categoryId], references: [id]) stock Stock[] saleLines SaleLine[] @@map("article") } model Stock { siteId BigInt @map("site_id") articleId BigInt @map("article_id") qty Int @default(0) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) site Site @relation(fields: [siteId], references: [id]) article Article @relation(fields: [articleId], references: [id]) @@id([siteId, articleId]) // composite primary key @@index([articleId]) // foreign keys need their own index @@map("stock") }Prismaenum PayMethod { CASH CARD WALLET @@map("pay_method") } model Sale { id BigInt @id @default(autoincrement()) siteId BigInt @map("site_id") localTxnId String @map("local_txn_id") saleDate DateTime @map("sale_date") @db.Date soldAt DateTime @default(now()) @map("sold_at") @db.Timestamptz(6) totalCents Int @map("total_cents") payMethod PayMethod @map("pay_method") site Site @relation(fields: [siteId], references: [id]) lines SaleLine[] @@unique([siteId, localTxnId]) @@index([siteId, saleDate]) @@map("sale") } model SaleLine { saleId BigInt @map("sale_id") lineNo Int @map("line_no") @db.SmallInt articleId BigInt @map("article_id") qty Int unitPriceCents Int @map("unit_price_cents") sale Sale @relation(fields: [saleId], references: [id], onDelete: Cascade) article Article @relation(fields: [articleId], references: [id]) @@id([saleId, lineNo]) @@index([articleId]) @@map("sale_line") } -
Create the migration, add what Prisma cannot say, and apply it
A Prisma schema cannot express
CHECKconstraints. Create the migration file without applying it, add the checks at the end, then apply. Prisma writesBIGSERIALforautoincrement(), an older form of identity that behaves the same for you.Terminalnpx prisma migrate dev --create-only --name init # writes prisma/migrations/<timestamp>_init/migration.sql # edit that file: append the CHECK constraints below npx prisma migrate dev # applies it and regenerates the clientSQL-- appended to the end of migration.sql ALTER TABLE "article" ADD CONSTRAINT article_unit_price_cents_check CHECK (unit_price_cents >= 0); ALTER TABLE "stock" ADD CONSTRAINT stock_qty_check CHECK (qty >= 0); ALTER TABLE "sale" ADD CONSTRAINT sale_total_cents_check CHECK (total_cents >= 0); ALTER TABLE "sale_line" ADD CONSTRAINT sale_line_qty_check CHECK (qty > 0);Open
migration.sqlonce and read it. It is the real contract with the database, and it should look like steps 2 and 3. Changing the schema without downtime covers how to change these tables later without taking the app down.
Bring it to your team
Use these questions when someone shows a new schema or a new column.
- "Which table is the single source of truth for this fact, and which team owns it?"
- "What is the primary key, and is there any way it could change? Where is the human code stored?"
- "Are all money columns integer cents, and all time columns timestamptz?"
- "Which rules does the database enforce, and which ones live only in the app?"
- "This value is copied from another table. Is that on purpose, and what keeps the copy correct?"
- "What happens to sales and GRN history when we close a store or stop selling a product?"
Red flags
- Prices or totals stored as float or as text
- A column holding a list, like
site_ids = '3,8,12' - The same store name or address typed into several tables
- No
UNIQUEconstraint on site codes or SKUs, so duplicates are possible - Prisma
DateTimefields without@db.Timestamptz
Practice
Give the stock table its rules
The site and article tables already exist, with two stores and two products. Add the rules to stock so that the database itself refuses bad rows: one row per store and product, both ids must exist, and the quantity must be present and never negative. The last line runs six test writes (each one is undone after the test) and reports whether Postgres accepted or rejected it. A normal row must still be accepted.
CREATE TABLE site (id bigint PRIMARY KEY, site_code text NOT NULL UNIQUE, name text NOT NULL);
CREATE TABLE article (id bigint PRIMARY KEY, sku text NOT NULL UNIQUE, name text NOT NULL);
INSERT INTO site VALUES (1, 'S001', 'Riverside'), (2, 'S002', 'Hill Road');
INSERT INTO article VALUES (1, '8901234500012', 'Rice 5kg'), (2, '8901234500029', 'Sunflower oil 1L');
-- try_write runs one statement, notes whether Postgres accepted it, then undoes it
CREATE FUNCTION try_write(stmt text) RETURNS text LANGUAGE plpgsql AS $$
BEGIN
BEGIN
EXECUTE stmt;
RAISE EXCEPTION USING ERRCODE = 'ZZ001', MESSAGE = 'undo';
EXCEPTION
WHEN SQLSTATE 'ZZ001' THEN RETURN 'accepted';
WHEN OTHERS THEN RETURN 'rejected';
END;
END $$;
CREATE FUNCTION test_stock_rules() RETURNS TABLE (attempt text, result text) LANGUAGE sql AS $$
VALUES
('a normal row', try_write('INSERT INTO stock (site_id, article_id, qty) VALUES (1, 2, 10)')),
('negative quantity', try_write('INSERT INTO stock (site_id, article_id, qty) VALUES (1, 1, -3)')),
('quantity left empty', try_write('INSERT INTO stock (site_id, article_id, qty) VALUES (1, 1, NULL)')),
('a store that does not exist', try_write('INSERT INTO stock (site_id, article_id, qty) VALUES (99, 1, 5)')),
('a product that does not exist', try_write('INSERT INTO stock (site_id, article_id, qty) VALUES (1, 99, 5)')),
('the same store and product twice', try_write('INSERT INTO stock (site_id, article_id, qty) VALUES (2, 1, 5), (2, 1, 7)'))
$$;
-- Add the rules to this table, then run. Keep the last line: it tries six writes.
CREATE TABLE stock (
site_id bigint,
article_id bigint,
qty integer
);
SELECT * FROM test_stock_rules();
[
{"attempt": "a normal row", "result": "accepted"},
{"attempt": "negative quantity", "result": "rejected"},
{"attempt": "quantity left empty", "result": "rejected"},
{"attempt": "a store that does not exist", "result": "rejected"},
{"attempt": "a product that does not exist", "result": "rejected"},
{"attempt": "the same store and product twice", "result": "rejected"}
]
Show a solution
CREATE TABLE stock (
site_id bigint NOT NULL REFERENCES site (id),
article_id bigint NOT NULL REFERENCES article (id),
qty integer NOT NULL DEFAULT 0 CHECK (qty >= 0),
PRIMARY KEY (site_id, article_id) -- one row per store per product
);
SELECT * FROM test_stock_rules();Each rule stops one bad write: the CHECK stops negative stock, NOT NULL on qty stops an empty quantity, the two foreign keys stop unknown ids, and the composite primary key stops a second row for the same pair. A primary key also makes its columns NOT NULL.
Normalize a partner order sheet
Until last month, Mangrove's wholesale team tracked partner shop orders in one shared sheet. Prices are in cents.
| order | shop | shop_phone | items | ordered_at |
|---|---|---|---|---|
| PO-501 | Ravi's Corner Shop | 555-0101 | Rice 5kg x10 @610, Oil 1L x24 @290 | 14 Oct 09:10 |
| PO-502 | Ravi's Corner Shop | 555-0110 | Sugar 1kg x12 @140 | 14 Oct 17:45 |
| PO-503 | Sunrise Mini Mart | 555-0199 | Rice 5kg x5 @610 | 15 Oct 08:05 |
Write down the tables you would create instead. For each one, give the primary key, the foreign keys, and the type of every money and time column. Then name the cell that holds a list and the fact that is typed more than once.
- Orders and order lines are separate tables, with one line row per product
- The shop's name and phone live once (in
siteor apartner_shoptable), and orders point to it by id - Each line stores the unit price in integer cents at the time of the order
ordered_atistimestamptz- The order code (PO-501) is a
UNIQUEcolumn, not the primary key - You spotted the list in
itemsand the two different phone numbers for Ravi's shop
Show a model answer
-- the shop lives once; partner shops are rows in site (format = 'PARTNER')
-- site (id PK, site_code UNIQUE, name, phone, format, ...)
CREATE TABLE partner_order (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_code text NOT NULL UNIQUE, -- 'PO-501'
site_id bigint NOT NULL REFERENCES site (id),
ordered_at timestamptz NOT NULL
);
CREATE INDEX partner_order_site_id_idx ON partner_order (site_id);
CREATE TABLE partner_order_line (
order_id bigint NOT NULL REFERENCES partner_order (id) ON DELETE CASCADE,
article_id bigint NOT NULL REFERENCES article (id),
qty integer NOT NULL CHECK (qty > 0),
unit_price_cents integer NOT NULL CHECK (unit_price_cents >= 0), -- price when ordered
PRIMARY KEY (order_id, article_id)
);The items cell holds a list (a 1NF problem), so it becomes one partner_order_line row per product. Ravi's shop appears with two phone numbers because the phone is typed on every order; in the new design it is stored once on his shop's row, and fixing it means changing one value.
Check yourself
Which column type should hold a product's selling price at Mangrove?
Whole cents are exact, so totals always add up. Floating-point types like double precision and real store approximations, so 0.1 + 0.2 becomes 0.30000000000000004 and receipts stop adding up. Text cannot be summed or checked. (numeric(12,2) would also be exact; Mangrove chose cents.)
sale_line has a unit_price_cents column, and article also has unit_price_cents. What should you do?
The product's price changes over time, but a receipt must keep the price the customer paid. Joining to article would silently rewrite old receipts. Duplication is not automatically good for speed, either; it is fine here because the meaning is different.
Each area manager covers several stores, and a store can have more than one manager. How should you store this?
Many-to-many needs a junction table, one row per pair, with foreign keys the database can check. A text list cannot be checked or joined. A manager_id column allows only one manager per store. Repeating staff rows copies their name and phone, which is the ledger mistake again.
Flashcards
Key takeaways
- Each fact lives in one row of one table; everything else refers to it by id.
- Money is integer cents (or
numeric, never float), moments aretimestamptz, business days aredate. - Use a surrogate id as the primary key and keep business codes in
UNIQUEcolumns that are allowed to change. - Foreign keys and constraints protect the data from every writer, not only your API. Index foreign key columns yourself.
- Copy data only on purpose, with a reason and a way to keep the copy correct, such as a receipt total written once.
Sources
- Numeric types, PostgreSQL documentation
- Date/time types, PostgreSQL documentation
- Constraints, PostgreSQL documentation