[{"data":1,"prerenderedAt":5611},["ShallowReactive",2],{"page-\u002Fsql\u002F25-normalization":3},{"id":4,"title":5,"body":6,"description":5604,"extension":5605,"meta":5606,"navigation":158,"path":5607,"seo":5608,"stem":5609,"__hash__":5610},"content\u002Fsql\u002F25-normalization.md","25 — Normalization & Data Modeling",{"type":7,"value":8,"toc":5579},"minimark",[9,13,22,27,41,45,50,56,303,317,321,331,560,564,573,791,795,800,876,880,887,891,902,908,912,1087,1091,1094,2091,2095,2771,2775,3059,3063,3375,3379,3460,3464,3833,3837,4125,4129,4488,4492,4764,4768,4888,4892,5017,5021,5024,5085,5088,5575],[10,11,5],"h1",{"id":12},"_25-normalization-data-modeling",[14,15,16,17,21],"p",{},"Normalization is the process of organizing data to reduce redundancy and improve integrity. The goal: each fact is stored in exactly one place. Violating normalization causes ",[18,19,20],"strong",{},"anomalies"," — data inconsistencies that arise from updates, inserts, or deletes.",[23,24,26],"h2",{"id":25},"why-normalize-the-three-anomalies","Why Normalize? — The Three Anomalies",[28,29,31],"code-wrapper",{"language":30},"text",[32,33,38],"pre",{"className":34,"code":36,"language":30,"meta":37},[35],"language-text","-- Consider this unnormalized table (customer data stored with each order):\n-- orders: order_id | customer_name | customer_city | amount\n--         1        | Alice         | NYC            | 100\n--         2        | Alice         | NYC            | 200\n--         3        | Alice         | Boston         | 150   ← Alice moved, 2 rows stale\n\n-- UPDATE ANOMALY: updating Alice's city in 2 of 3 rows → which is correct?\n--   You must update ALL rows for Alice. Miss one → inconsistent data.\n--   \"How many rows do I need to update?\" is the wrong question — the fact\n--   should be in ONE place, so one update suffices.\n\n-- INSERTION ANOMALY: can't insert a new customer until they place an order.\n--   No order → no row → no customer record. Customer data is hostage to\n--   having an order.\n\n-- DELETION ANOMALY: deleting Alice's last order deletes Alice.\n--   If Alice has one order and you delete it, the row is gone → Alice's\n--   customer data is lost. Deleting one fact (the order) inadvertently\n--   deletes another (the customer).\n\n-- Normalization fixes all three: customer data in a `customers` table\n-- (one row per customer), orders in an `orders` table (FK to customers).\n-- One update to customers.city. Insert a customer without an order.\n-- Delete an order without losing the customer.\n","",[39,40,36],"code",{"__ignoreMap":37},[23,42,44],{"id":43},"the-normal-forms-code-first","The Normal Forms — Code-First",[46,47,49],"h3",{"id":48},"_1nf-atomic-values","1NF — Atomic Values",[14,51,52,55],{},[18,53,54],{},"Rule",": every column holds a single, atomic value. No repeating groups, no lists in a single cell.",[28,57,59],{"language":58},"sql",[32,60,63],{"className":61,"code":62,"language":58,"meta":37,"style":37},"language-sql shiki shiki-themes github-light github-dark","-- ❌ Not 1NF: items column holds a comma-separated list\nCREATE TABLE bad_orders (\n  id SERIAL PRIMARY KEY,\n  customer TEXT,\n  items TEXT                           -- 'apple, banana, cherry' — one cell, multiple values\n);\n-- Can't index items for \"which orders contain apple?\" (LIKE '%apple%' →\n-- full scan, false positives on 'pineapple'). Can't enforce integrity.\n-- Can't add metadata (quantity, price per item).\n\n-- ✅ 1NF: one row per item (normalize into a child table)\nCREATE TABLE orders (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  customer TEXT NOT NULL\n);\nCREATE TABLE order_items (\n  order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n  item TEXT NOT NULL,\n  quantity INTEGER NOT NULL DEFAULT 1,\n  PRIMARY KEY (order_id, item)\n);\n-- Each cell is atomic. Can index item, enforce quantity > 0, add price.\n",[39,64,65,74,92,107,118,129,135,141,147,153,160,166,178,202,212,217,229,251,263,283,292,297],{"__ignoreMap":37},[66,67,70],"span",{"class":68,"line":69},"line",1,[66,71,73],{"class":72},"sdCPZ","-- ❌ Not 1NF: items column holds a comma-separated list\n",[66,75,77,81,84,88],{"class":68,"line":76},2,[66,78,80],{"class":79},"svdQ7","CREATE",[66,82,83],{"class":79}," TABLE",[66,85,87],{"class":86},"sIsaT"," bad_orders",[66,89,91],{"class":90},"ssxIu"," (\n",[66,93,95,98,101,104],{"class":68,"line":94},3,[66,96,97],{"class":90},"  id ",[66,99,100],{"class":79},"SERIAL",[66,102,103],{"class":79}," PRIMARY KEY",[66,105,106],{"class":90},",\n",[66,108,110,113,116],{"class":68,"line":109},4,[66,111,112],{"class":90},"  customer ",[66,114,115],{"class":79},"TEXT",[66,117,106],{"class":90},[66,119,121,124,126],{"class":68,"line":120},5,[66,122,123],{"class":90},"  items ",[66,125,115],{"class":79},[66,127,128],{"class":72},"                           -- 'apple, banana, cherry' — one cell, multiple values\n",[66,130,132],{"class":68,"line":131},6,[66,133,134],{"class":90},");\n",[66,136,138],{"class":68,"line":137},7,[66,139,140],{"class":72},"-- Can't index items for \"which orders contain apple?\" (LIKE '%apple%' →\n",[66,142,144],{"class":68,"line":143},8,[66,145,146],{"class":72},"-- full scan, false positives on 'pineapple'). Can't enforce integrity.\n",[66,148,150],{"class":68,"line":149},9,[66,151,152],{"class":72},"-- Can't add metadata (quantity, price per item).\n",[66,154,156],{"class":68,"line":155},10,[66,157,159],{"emptyLinePlaceholder":158},true,"\n",[66,161,163],{"class":68,"line":162},11,[66,164,165],{"class":72},"-- ✅ 1NF: one row per item (normalize into a child table)\n",[66,167,169,171,173,176],{"class":68,"line":168},12,[66,170,80],{"class":79},[66,172,83],{"class":79},[66,174,175],{"class":86}," orders",[66,177,91],{"class":90},[66,179,181,183,186,189,192,195,198,200],{"class":68,"line":180},13,[66,182,97],{"class":90},[66,184,185],{"class":79},"BIGINT",[66,187,188],{"class":79}," GENERATED",[66,190,191],{"class":79}," ALWAYS",[66,193,194],{"class":79}," AS",[66,196,197],{"class":79}," IDENTITY",[66,199,103],{"class":79},[66,201,106],{"class":90},[66,203,205,207,209],{"class":68,"line":204},14,[66,206,112],{"class":90},[66,208,115],{"class":79},[66,210,211],{"class":79}," NOT NULL\n",[66,213,215],{"class":68,"line":214},15,[66,216,134],{"class":90},[66,218,220,222,224,227],{"class":68,"line":219},16,[66,221,80],{"class":79},[66,223,83],{"class":79},[66,225,226],{"class":86}," order_items",[66,228,91],{"class":90},[66,230,232,235,237,240,243,246,249],{"class":68,"line":231},17,[66,233,234],{"class":90},"  order_id ",[66,236,185],{"class":79},[66,238,239],{"class":79}," NOT NULL",[66,241,242],{"class":79}," REFERENCES",[66,244,245],{"class":90}," orders(id) ",[66,247,248],{"class":79},"ON DELETE CASCADE",[66,250,106],{"class":90},[66,252,254,257,259,261],{"class":68,"line":253},18,[66,255,256],{"class":90},"  item ",[66,258,115],{"class":79},[66,260,239],{"class":79},[66,262,106],{"class":90},[66,264,266,269,272,274,277,281],{"class":68,"line":265},19,[66,267,268],{"class":90},"  quantity ",[66,270,271],{"class":79},"INTEGER",[66,273,239],{"class":79},[66,275,276],{"class":79}," DEFAULT",[66,278,280],{"class":279},"snvgF"," 1",[66,282,106],{"class":90},[66,284,286,289],{"class":68,"line":285},20,[66,287,288],{"class":79},"  PRIMARY KEY",[66,290,291],{"class":90}," (order_id, item)\n",[66,293,295],{"class":68,"line":294},21,[66,296,134],{"class":90},[66,298,300],{"class":68,"line":299},22,[66,301,302],{"class":72},"-- Each cell is atomic. Can index item, enforce quantity > 0, add price.\n",[14,304,305,308,309,312,313,316],{},[18,306,307],{},"PostgreSQL exception",": ",[39,310,311],{},"TEXT[]"," (array) or ",[39,314,315],{},"JSONB"," columns are a deliberate denormalization for homogeneous lists you always read together. 1NF is a guideline, not a hard rule — arrays are acceptable when the list is atomic from the application's perspective (e.g., tags).",[46,318,320],{"id":319},"_2nf-no-partial-dependencies-on-a-composite-key","2NF — No Partial Dependencies on a Composite Key",[14,322,323,325,326,330],{},[18,324,54],{},": every non-key column depends on the ",[327,328,329],"em",{},"whole"," composite primary key, not just part of it. (Only relevant with composite keys.)",[28,332,333],{"language":58},[32,334,336],{"className":61,"code":335,"language":58,"meta":37,"style":37},"-- ❌ Not 2NF: PK is (order_id, product_id), but product_name depends\n-- only on product_id (not on order_id). If product 5's name changes,\n-- you must update every order_items row for product 5.\nCREATE TABLE bad_order_items (\n  order_id BIGINT NOT NULL,\n  product_id BIGINT NOT NULL,\n  product_name TEXT NOT NULL,          -- depends on product_id ONLY, not order_id\n  quantity INTEGER NOT NULL,\n  PRIMARY KEY (order_id, product_id)\n);\n-- product_name is duplicated across all orders containing product 5.\n-- Rename \"Apple\" to \"Apple Inc.\" → update N rows. Miss one → inconsistency.\n\n-- ✅ 2NF: move product_name to the products table\nCREATE TABLE products (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL                   -- depends on product_id (the PK of products)\n);\nCREATE TABLE order_items (\n  order_id BIGINT NOT NULL REFERENCES orders(id),\n  product_id BIGINT NOT NULL REFERENCES products(id),\n  quantity INTEGER NOT NULL,\n  PRIMARY KEY (order_id, product_id)   -- quantity depends on BOTH (which order, which product)\n);\n-- product_name lives in products (one place). Rename → one update.\n-- quantity lives in order_items (depends on the full composite key).\n",[39,337,338,343,348,353,364,374,385,400,410,417,421,426,431,435,440,451,469,482,486,496,509,522,532,543,548,554],{"__ignoreMap":37},[66,339,340],{"class":68,"line":69},[66,341,342],{"class":72},"-- ❌ Not 2NF: PK is (order_id, product_id), but product_name depends\n",[66,344,345],{"class":68,"line":76},[66,346,347],{"class":72},"-- only on product_id (not on order_id). If product 5's name changes,\n",[66,349,350],{"class":68,"line":94},[66,351,352],{"class":72},"-- you must update every order_items row for product 5.\n",[66,354,355,357,359,362],{"class":68,"line":109},[66,356,80],{"class":79},[66,358,83],{"class":79},[66,360,361],{"class":86}," bad_order_items",[66,363,91],{"class":90},[66,365,366,368,370,372],{"class":68,"line":120},[66,367,234],{"class":90},[66,369,185],{"class":79},[66,371,239],{"class":79},[66,373,106],{"class":90},[66,375,376,379,381,383],{"class":68,"line":131},[66,377,378],{"class":90},"  product_id ",[66,380,185],{"class":79},[66,382,239],{"class":79},[66,384,106],{"class":90},[66,386,387,390,392,394,397],{"class":68,"line":137},[66,388,389],{"class":90},"  product_name ",[66,391,115],{"class":79},[66,393,239],{"class":79},[66,395,396],{"class":90},",          ",[66,398,399],{"class":72},"-- depends on product_id ONLY, not order_id\n",[66,401,402,404,406,408],{"class":68,"line":143},[66,403,268],{"class":90},[66,405,271],{"class":79},[66,407,239],{"class":79},[66,409,106],{"class":90},[66,411,412,414],{"class":68,"line":149},[66,413,288],{"class":79},[66,415,416],{"class":90}," (order_id, product_id)\n",[66,418,419],{"class":68,"line":155},[66,420,134],{"class":90},[66,422,423],{"class":68,"line":162},[66,424,425],{"class":72},"-- product_name is duplicated across all orders containing product 5.\n",[66,427,428],{"class":68,"line":168},[66,429,430],{"class":72},"-- Rename \"Apple\" to \"Apple Inc.\" → update N rows. Miss one → inconsistency.\n",[66,432,433],{"class":68,"line":180},[66,434,159],{"emptyLinePlaceholder":158},[66,436,437],{"class":68,"line":204},[66,438,439],{"class":72},"-- ✅ 2NF: move product_name to the products table\n",[66,441,442,444,446,449],{"class":68,"line":214},[66,443,80],{"class":79},[66,445,83],{"class":79},[66,447,448],{"class":86}," products",[66,450,91],{"class":90},[66,452,453,455,457,459,461,463,465,467],{"class":68,"line":219},[66,454,97],{"class":90},[66,456,185],{"class":79},[66,458,188],{"class":79},[66,460,191],{"class":79},[66,462,194],{"class":79},[66,464,197],{"class":79},[66,466,103],{"class":79},[66,468,106],{"class":90},[66,470,471,474,477,479],{"class":68,"line":231},[66,472,473],{"class":79},"  name",[66,475,476],{"class":79}," TEXT",[66,478,239],{"class":79},[66,480,481],{"class":72},"                   -- depends on product_id (the PK of products)\n",[66,483,484],{"class":68,"line":253},[66,485,134],{"class":90},[66,487,488,490,492,494],{"class":68,"line":265},[66,489,80],{"class":79},[66,491,83],{"class":79},[66,493,226],{"class":86},[66,495,91],{"class":90},[66,497,498,500,502,504,506],{"class":68,"line":285},[66,499,234],{"class":90},[66,501,185],{"class":79},[66,503,239],{"class":79},[66,505,242],{"class":79},[66,507,508],{"class":90}," orders(id),\n",[66,510,511,513,515,517,519],{"class":68,"line":294},[66,512,378],{"class":90},[66,514,185],{"class":79},[66,516,239],{"class":79},[66,518,242],{"class":79},[66,520,521],{"class":90}," products(id),\n",[66,523,524,526,528,530],{"class":68,"line":299},[66,525,268],{"class":90},[66,527,271],{"class":79},[66,529,239],{"class":79},[66,531,106],{"class":90},[66,533,535,537,540],{"class":68,"line":534},23,[66,536,288],{"class":79},[66,538,539],{"class":90}," (order_id, product_id)   ",[66,541,542],{"class":72},"-- quantity depends on BOTH (which order, which product)\n",[66,544,546],{"class":68,"line":545},24,[66,547,134],{"class":90},[66,549,551],{"class":68,"line":550},25,[66,552,553],{"class":72},"-- product_name lives in products (one place). Rename → one update.\n",[66,555,557],{"class":68,"line":556},26,[66,558,559],{"class":72},"-- quantity lives in order_items (depends on the full composite key).\n",[46,561,563],{"id":562},"_3nf-no-transitive-dependencies","3NF — No Transitive Dependencies",[14,565,566,568,569,572],{},[18,567,54],{},": non-key columns depend ",[327,570,571],{},"only"," on the primary key, not on other non-key columns.",[28,574,575],{"language":58},[32,576,578],{"className":61,"code":577,"language":58,"meta":37,"style":37},"-- ❌ Not 3NF: zip_code determines city, but city isn't the PK.\n-- zip_code → city is a transitive dependency (id → zip_code → city).\nCREATE TABLE bad_customers (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL,\n  zip_code TEXT NOT NULL,\n  city TEXT NOT NULL                    -- derivable from zip_code, not from id directly\n);\n-- If zip_code 10001 maps to NYC, every customer with zip 10001 has city=NYC.\n-- Duplicated. If the mapping changes (zip boundary redraw), update every row.\n\n-- ✅ 3NF: zip → city in a separate table\nCREATE TABLE zip_codes (\n  zip TEXT PRIMARY KEY,\n  city TEXT NOT NULL,\n  state TEXT NOT NULL\n);\nCREATE TABLE customers (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL,\n  zip_code TEXT NOT NULL REFERENCES zip_codes(zip)\n);\n-- city lives in zip_codes (one place per zip). Join to get the city.\n-- \"The key, the whole key, and nothing but the key\" — city depends on\n-- the key (zip) of zip_codes, not on customers.id.\n",[39,579,580,585,590,601,619,629,640,652,656,661,666,670,675,686,697,707,716,720,731,749,759,772,776,781,786],{"__ignoreMap":37},[66,581,582],{"class":68,"line":69},[66,583,584],{"class":72},"-- ❌ Not 3NF: zip_code determines city, but city isn't the PK.\n",[66,586,587],{"class":68,"line":76},[66,588,589],{"class":72},"-- zip_code → city is a transitive dependency (id → zip_code → city).\n",[66,591,592,594,596,599],{"class":68,"line":94},[66,593,80],{"class":79},[66,595,83],{"class":79},[66,597,598],{"class":86}," bad_customers",[66,600,91],{"class":90},[66,602,603,605,607,609,611,613,615,617],{"class":68,"line":109},[66,604,97],{"class":90},[66,606,185],{"class":79},[66,608,188],{"class":79},[66,610,191],{"class":79},[66,612,194],{"class":79},[66,614,197],{"class":79},[66,616,103],{"class":79},[66,618,106],{"class":90},[66,620,621,623,625,627],{"class":68,"line":120},[66,622,473],{"class":79},[66,624,476],{"class":79},[66,626,239],{"class":79},[66,628,106],{"class":90},[66,630,631,634,636,638],{"class":68,"line":131},[66,632,633],{"class":90},"  zip_code ",[66,635,115],{"class":79},[66,637,239],{"class":79},[66,639,106],{"class":90},[66,641,642,645,647,649],{"class":68,"line":137},[66,643,644],{"class":90},"  city ",[66,646,115],{"class":79},[66,648,239],{"class":79},[66,650,651],{"class":72},"                    -- derivable from zip_code, not from id directly\n",[66,653,654],{"class":68,"line":143},[66,655,134],{"class":90},[66,657,658],{"class":68,"line":149},[66,659,660],{"class":72},"-- If zip_code 10001 maps to NYC, every customer with zip 10001 has city=NYC.\n",[66,662,663],{"class":68,"line":155},[66,664,665],{"class":72},"-- Duplicated. If the mapping changes (zip boundary redraw), update every row.\n",[66,667,668],{"class":68,"line":162},[66,669,159],{"emptyLinePlaceholder":158},[66,671,672],{"class":68,"line":168},[66,673,674],{"class":72},"-- ✅ 3NF: zip → city in a separate table\n",[66,676,677,679,681,684],{"class":68,"line":180},[66,678,80],{"class":79},[66,680,83],{"class":79},[66,682,683],{"class":86}," zip_codes",[66,685,91],{"class":90},[66,687,688,691,693,695],{"class":68,"line":204},[66,689,690],{"class":90},"  zip ",[66,692,115],{"class":79},[66,694,103],{"class":79},[66,696,106],{"class":90},[66,698,699,701,703,705],{"class":68,"line":214},[66,700,644],{"class":90},[66,702,115],{"class":79},[66,704,239],{"class":79},[66,706,106],{"class":90},[66,708,709,712,714],{"class":68,"line":219},[66,710,711],{"class":79},"  state",[66,713,476],{"class":79},[66,715,211],{"class":79},[66,717,718],{"class":68,"line":231},[66,719,134],{"class":90},[66,721,722,724,726,729],{"class":68,"line":253},[66,723,80],{"class":79},[66,725,83],{"class":79},[66,727,728],{"class":86}," customers",[66,730,91],{"class":90},[66,732,733,735,737,739,741,743,745,747],{"class":68,"line":265},[66,734,97],{"class":90},[66,736,185],{"class":79},[66,738,188],{"class":79},[66,740,191],{"class":79},[66,742,194],{"class":79},[66,744,197],{"class":79},[66,746,103],{"class":79},[66,748,106],{"class":90},[66,750,751,753,755,757],{"class":68,"line":285},[66,752,473],{"class":79},[66,754,476],{"class":79},[66,756,239],{"class":79},[66,758,106],{"class":90},[66,760,761,763,765,767,769],{"class":68,"line":294},[66,762,633],{"class":90},[66,764,115],{"class":79},[66,766,239],{"class":79},[66,768,242],{"class":79},[66,770,771],{"class":90}," zip_codes(zip)\n",[66,773,774],{"class":68,"line":299},[66,775,134],{"class":90},[66,777,778],{"class":68,"line":534},[66,779,780],{"class":72},"-- city lives in zip_codes (one place per zip). Join to get the city.\n",[66,782,783],{"class":68,"line":545},[66,784,785],{"class":72},"-- \"The key, the whole key, and nothing but the key\" — city depends on\n",[66,787,788],{"class":68,"line":550},[66,789,790],{"class":72},"-- the key (zip) of zip_codes, not on customers.id.\n",[46,792,794],{"id":793},"bcnf-boyce-codd-normal-form-stricter-3nf","BCNF — Boyce-Codd Normal Form (stricter 3NF)",[14,796,797,799],{},[18,798,54],{},": every determinant is a candidate key. 3NF allows a non-key determinant if it's not a superkey for a different dependency; BCNF doesn't.",[28,801,802],{"language":58},[32,803,805],{"className":61,"code":804,"language":58,"meta":37,"style":37},"-- BCNF matters when you have overlapping candidate keys.\n-- Example: a table of (student, course, instructor) where:\n--   - Each course has one instructor\n--   - Each instructor teaches one course\n--   - A student can take multiple courses\n-- Candidate keys: (student, course) and (student, instructor)\n-- Dependencies: course → instructor AND instructor → course\n-- This is 3NF (no transitive dependency on a non-key) but NOT BCNF\n-- (course is a determinant but not a superkey in the (student, course) key).\n-- Fix: split into (student, course) and (course, instructor).\n\n-- In practice, BCNF violations are rare — most schemas that hit 3NF\n-- are already BCNF. The difference matters in academic cases with\n-- overlapping candidate keys.\n",[39,806,807,812,817,822,827,832,837,842,847,852,857,861,866,871],{"__ignoreMap":37},[66,808,809],{"class":68,"line":69},[66,810,811],{"class":72},"-- BCNF matters when you have overlapping candidate keys.\n",[66,813,814],{"class":68,"line":76},[66,815,816],{"class":72},"-- Example: a table of (student, course, instructor) where:\n",[66,818,819],{"class":68,"line":94},[66,820,821],{"class":72},"--   - Each course has one instructor\n",[66,823,824],{"class":68,"line":109},[66,825,826],{"class":72},"--   - Each instructor teaches one course\n",[66,828,829],{"class":68,"line":120},[66,830,831],{"class":72},"--   - A student can take multiple courses\n",[66,833,834],{"class":68,"line":131},[66,835,836],{"class":72},"-- Candidate keys: (student, course) and (student, instructor)\n",[66,838,839],{"class":68,"line":137},[66,840,841],{"class":72},"-- Dependencies: course → instructor AND instructor → course\n",[66,843,844],{"class":68,"line":143},[66,845,846],{"class":72},"-- This is 3NF (no transitive dependency on a non-key) but NOT BCNF\n",[66,848,849],{"class":68,"line":149},[66,850,851],{"class":72},"-- (course is a determinant but not a superkey in the (student, course) key).\n",[66,853,854],{"class":68,"line":155},[66,855,856],{"class":72},"-- Fix: split into (student, course) and (course, instructor).\n",[66,858,859],{"class":68,"line":162},[66,860,159],{"emptyLinePlaceholder":158},[66,862,863],{"class":68,"line":168},[66,864,865],{"class":72},"-- In practice, BCNF violations are rare — most schemas that hit 3NF\n",[66,867,868],{"class":68,"line":180},[66,869,870],{"class":72},"-- are already BCNF. The difference matters in academic cases with\n",[66,872,873],{"class":68,"line":204},[66,874,875],{"class":72},"-- overlapping candidate keys.\n",[46,877,879],{"id":878},"_4nf-5nf-6nf","4NF, 5NF, 6NF",[14,881,882,883,886],{},"Higher normal forms deal with multi-valued dependencies (4NF) and join dependencies (5NF). They're academic for most applications — ",[18,884,885],{},"3NF\u002FBCNF is the practical target",". Going beyond BCNF rarely improves integrity and can hurt read performance (more joins).",[23,888,890],{"id":889},"the-practical-takeaway-the-mantra","The Practical Takeaway — the mantra",[892,893,894,899],"blockquote",{},[14,895,896],{},[18,897,898],{},"The key, the whole key, and nothing but the key, so help me Codd.",[14,900,901],{},"— Every non-key column depends on the key (1NF\u002F2NF), the whole key (2NF), and nothing but the key (3NF).",[14,903,904,907],{},[18,905,906],{},"Aim for 3NF\u002FBCNF by default."," Each fact stored once, no redundancy, no anomalies.",[23,909,911],{"id":910},"surrogate-vs-natural-keys","Surrogate vs Natural Keys",[28,913,914],{"language":58},[32,915,917],{"className":61,"code":916,"language":58,"meta":37,"style":37},"-- Surrogate key: artificial, stable, simple. The default for most tables.\nCREATE TABLE customers (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- surrogate: no business meaning\n  email TEXT NOT NULL UNIQUE                           -- natural key as a unique constraint\n);\n-- Pros: never changes, simple joins (single bigint), no cascade issues.\n-- Cons: no business meaning (can't tell anything from id=42).\n\n-- Natural key: meaningful, derived from the domain.\nCREATE TABLE countries (\n  iso_code CHAR(2) PRIMARY KEY,         -- 'US', 'GB', 'JP' — natural, stable, meaningful\n  name TEXT NOT NULL\n);\n-- Pros: meaningful, no extra column, self-documenting.\n-- Cons: mutable (email rename → cascade to every FK), may not be truly unique.\n\n-- Rule of thumb:\n-- - Use surrogate keys by default (BIGINT IDENTITY or UUID).\n-- - Use natural keys only for genuinely stable, unique identifiers (country codes, ISBN).\n-- - Keep natural keys as UNIQUE constraints, not PKs, if they might change.\n-- - The PK choice affects every FK that references it — changing a PK type\n--   is a massive migration. Choose wisely up front.\n",[39,918,919,924,934,956,971,975,980,985,989,994,1005,1031,1039,1043,1048,1053,1057,1062,1067,1072,1077,1082],{"__ignoreMap":37},[66,920,921],{"class":68,"line":69},[66,922,923],{"class":72},"-- Surrogate key: artificial, stable, simple. The default for most tables.\n",[66,925,926,928,930,932],{"class":68,"line":76},[66,927,80],{"class":79},[66,929,83],{"class":79},[66,931,728],{"class":86},[66,933,91],{"class":90},[66,935,936,938,940,942,944,946,948,950,953],{"class":68,"line":94},[66,937,97],{"class":90},[66,939,185],{"class":79},[66,941,188],{"class":79},[66,943,191],{"class":79},[66,945,194],{"class":79},[66,947,197],{"class":79},[66,949,103],{"class":79},[66,951,952],{"class":90},",  ",[66,954,955],{"class":72},"-- surrogate: no business meaning\n",[66,957,958,961,963,965,968],{"class":68,"line":109},[66,959,960],{"class":90},"  email ",[66,962,115],{"class":79},[66,964,239],{"class":79},[66,966,967],{"class":79}," UNIQUE",[66,969,970],{"class":72},"                           -- natural key as a unique constraint\n",[66,972,973],{"class":68,"line":120},[66,974,134],{"class":90},[66,976,977],{"class":68,"line":131},[66,978,979],{"class":72},"-- Pros: never changes, simple joins (single bigint), no cascade issues.\n",[66,981,982],{"class":68,"line":137},[66,983,984],{"class":72},"-- Cons: no business meaning (can't tell anything from id=42).\n",[66,986,987],{"class":68,"line":143},[66,988,159],{"emptyLinePlaceholder":158},[66,990,991],{"class":68,"line":149},[66,992,993],{"class":72},"-- Natural key: meaningful, derived from the domain.\n",[66,995,996,998,1000,1003],{"class":68,"line":155},[66,997,80],{"class":79},[66,999,83],{"class":79},[66,1001,1002],{"class":86}," countries",[66,1004,91],{"class":90},[66,1006,1007,1010,1013,1016,1019,1022,1025,1028],{"class":68,"line":162},[66,1008,1009],{"class":90},"  iso_code ",[66,1011,1012],{"class":79},"CHAR",[66,1014,1015],{"class":90},"(",[66,1017,1018],{"class":279},"2",[66,1020,1021],{"class":90},") ",[66,1023,1024],{"class":79},"PRIMARY KEY",[66,1026,1027],{"class":90},",         ",[66,1029,1030],{"class":72},"-- 'US', 'GB', 'JP' — natural, stable, meaningful\n",[66,1032,1033,1035,1037],{"class":68,"line":168},[66,1034,473],{"class":79},[66,1036,476],{"class":79},[66,1038,211],{"class":79},[66,1040,1041],{"class":68,"line":180},[66,1042,134],{"class":90},[66,1044,1045],{"class":68,"line":204},[66,1046,1047],{"class":72},"-- Pros: meaningful, no extra column, self-documenting.\n",[66,1049,1050],{"class":68,"line":214},[66,1051,1052],{"class":72},"-- Cons: mutable (email rename → cascade to every FK), may not be truly unique.\n",[66,1054,1055],{"class":68,"line":219},[66,1056,159],{"emptyLinePlaceholder":158},[66,1058,1059],{"class":68,"line":231},[66,1060,1061],{"class":72},"-- Rule of thumb:\n",[66,1063,1064],{"class":68,"line":253},[66,1065,1066],{"class":72},"-- - Use surrogate keys by default (BIGINT IDENTITY or UUID).\n",[66,1068,1069],{"class":68,"line":265},[66,1070,1071],{"class":72},"-- - Use natural keys only for genuinely stable, unique identifiers (country codes, ISBN).\n",[66,1073,1074],{"class":68,"line":285},[66,1075,1076],{"class":72},"-- - Keep natural keys as UNIQUE constraints, not PKs, if they might change.\n",[66,1078,1079],{"class":68,"line":294},[66,1080,1081],{"class":72},"-- - The PK choice affects every FK that references it — changing a PK type\n",[66,1083,1084],{"class":68,"line":299},[66,1085,1086],{"class":72},"--   is a massive migration. Choose wisely up front.\n",[23,1088,1090],{"id":1089},"complex-implementation-normalized-e-commerce-schema","Complex Implementation — Normalized E-Commerce Schema",[14,1092,1093],{},"A fully normalized e-commerce schema with each normal form applied and annotated reasoning, followed by a denormalized analytics-ready version for comparison.",[28,1095,1096],{"language":58},[32,1097,1099],{"className":61,"code":1098,"language":58,"meta":37,"style":37},"-- ── NORMALIZED (3NF) — for the transactional (OLTP) system ──────\n\n-- users: one row per user. 1NF (atomic), 2NF (single-column PK, N\u002FA),\n-- 3NF (all columns depend on user id alone).\nCREATE TABLE users (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  email TEXT NOT NULL UNIQUE,           -- natural key as unique constraint (not PK)\n  name TEXT NOT NULL,\n  created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\n-- addresses: one user can have multiple addresses (1:N).\n-- Separate table (not columns on users) because it's multi-valued (1NF).\n-- zip_id FK to zip_codes (3NF: city derivable from zip, not stored here).\nCREATE TABLE addresses (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n  line1 TEXT NOT NULL,\n  line2 TEXT,\n  zip_id TEXT NOT NULL REFERENCES zip_codes(zip),\n  label TEXT NOT NULL DEFAULT 'home',   -- 'home', 'work', 'shipping'\n  created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\n-- zip_codes: the 3NF fix — city\u002Fstate derivable from zip, stored once.\nCREATE TABLE zip_codes (\n  zip TEXT PRIMARY KEY,\n  city TEXT NOT NULL,\n  state TEXT NOT NULL,\n  country TEXT NOT NULL DEFAULT 'US'\n);\n\n-- categories: self-referencing for hierarchy (adjacency list).\n-- parent_id is NULL for root categories.\nCREATE TABLE categories (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  parent_id BIGINT REFERENCES categories(id) ON DELETE SET NULL,\n  name TEXT NOT NULL,\n  slug TEXT NOT NULL UNIQUE\n);\n\n-- products: name\u002Fprice depend on product id (3NF).\n-- category_id FK (not storing category name here — 2NF\u002F3NF).\nCREATE TABLE products (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  category_id BIGINT NOT NULL REFERENCES categories(id),\n  name TEXT NOT NULL,\n  price NUMERIC(10,2) NOT NULL CHECK (price >= 0),\n  sku TEXT NOT NULL UNIQUE,\n  created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\n-- products_to_categories: many-to-many (a product can be in multiple categories).\n-- Junction table with composite PK (no duplicate pairs).\nCREATE TABLE products_to_categories (\n  product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,\n  category_id BIGINT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,\n  PRIMARY KEY (product_id, category_id)\n);\n\n-- orders: one row per order. FK to user (not storing user name\u002Femail — 3NF).\n-- shipping_address_id FK (not copying address fields — 3NF).\nCREATE TABLE orders (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  user_id BIGINT NOT NULL REFERENCES users(id),\n  shipping_address_id BIGINT NOT NULL REFERENCES addresses(id),\n  status TEXT NOT NULL DEFAULT 'pending'\n    CHECK (status IN ('pending','paid','shipped','delivered','cancelled')),\n  total NUMERIC(10,2) NOT NULL CHECK (total >= 0),\n  created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\n-- order_items: the line items. Composite-relevant: quantity depends on\n-- (order_id, product_id) — the whole composite key (2NF).\n-- product_name is NOT here (it's in products — 2NF fix).\n-- unit_price IS here (a snapshot of the price at order time — not a\n-- 3NF violation because the current product.price may change, but the\n-- historical order price shouldn't. This is a deliberate denormalization\n-- for historical accuracy).\nCREATE TABLE order_items (\n  order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,\n  product_id BIGINT NOT NULL REFERENCES products(id),\n  quantity INTEGER NOT NULL CHECK (quantity > 0),\n  unit_price NUMERIC(10,2) NOT NULL CHECK (unit_price >= 0),  -- price snapshot\n  PRIMARY KEY (order_id, product_id)\n);\n-- The unit_price snapshot is a common pattern: store the price AT THE TIME\n-- of the order, not a FK to a changing products.price. This is deliberate,\n-- documented denormalization (historical accuracy > normalization purity).\n",[39,1100,1101,1106,1110,1115,1120,1131,1149,1165,1175,1193,1197,1201,1206,1211,1216,1227,1245,1263,1274,1283,1297,1318,1332,1336,1340,1345,1355,1366,1377,1388,1403,1408,1413,1419,1425,1437,1456,1480,1491,1504,1509,1514,1520,1526,1537,1556,1571,1582,1621,1635,1650,1655,1660,1666,1672,1684,1702,1719,1727,1732,1737,1743,1749,1760,1779,1793,1808,1823,1866,1897,1912,1917,1922,1928,1934,1940,1946,1952,1958,1964,1975,1992,2005,2026,2061,2068,2073,2079,2085],{"__ignoreMap":37},[66,1102,1103],{"class":68,"line":69},[66,1104,1105],{"class":72},"-- ── NORMALIZED (3NF) — for the transactional (OLTP) system ──────\n",[66,1107,1108],{"class":68,"line":76},[66,1109,159],{"emptyLinePlaceholder":158},[66,1111,1112],{"class":68,"line":94},[66,1113,1114],{"class":72},"-- users: one row per user. 1NF (atomic), 2NF (single-column PK, N\u002FA),\n",[66,1116,1117],{"class":68,"line":109},[66,1118,1119],{"class":72},"-- 3NF (all columns depend on user id alone).\n",[66,1121,1122,1124,1126,1129],{"class":68,"line":120},[66,1123,80],{"class":79},[66,1125,83],{"class":79},[66,1127,1128],{"class":86}," users",[66,1130,91],{"class":90},[66,1132,1133,1135,1137,1139,1141,1143,1145,1147],{"class":68,"line":131},[66,1134,97],{"class":90},[66,1136,185],{"class":79},[66,1138,188],{"class":79},[66,1140,191],{"class":79},[66,1142,194],{"class":79},[66,1144,197],{"class":79},[66,1146,103],{"class":79},[66,1148,106],{"class":90},[66,1150,1151,1153,1155,1157,1159,1162],{"class":68,"line":137},[66,1152,960],{"class":90},[66,1154,115],{"class":79},[66,1156,239],{"class":79},[66,1158,967],{"class":79},[66,1160,1161],{"class":90},",           ",[66,1163,1164],{"class":72},"-- natural key as unique constraint (not PK)\n",[66,1166,1167,1169,1171,1173],{"class":68,"line":143},[66,1168,473],{"class":79},[66,1170,476],{"class":79},[66,1172,239],{"class":79},[66,1174,106],{"class":90},[66,1176,1177,1180,1183,1185,1187,1190],{"class":68,"line":149},[66,1178,1179],{"class":90},"  created_at ",[66,1181,1182],{"class":79},"TIMESTAMPTZ",[66,1184,239],{"class":79},[66,1186,276],{"class":79},[66,1188,1189],{"class":79}," now",[66,1191,1192],{"class":90},"()\n",[66,1194,1195],{"class":68,"line":155},[66,1196,134],{"class":90},[66,1198,1199],{"class":68,"line":162},[66,1200,159],{"emptyLinePlaceholder":158},[66,1202,1203],{"class":68,"line":168},[66,1204,1205],{"class":72},"-- addresses: one user can have multiple addresses (1:N).\n",[66,1207,1208],{"class":68,"line":180},[66,1209,1210],{"class":72},"-- Separate table (not columns on users) because it's multi-valued (1NF).\n",[66,1212,1213],{"class":68,"line":204},[66,1214,1215],{"class":72},"-- zip_id FK to zip_codes (3NF: city derivable from zip, not stored here).\n",[66,1217,1218,1220,1222,1225],{"class":68,"line":214},[66,1219,80],{"class":79},[66,1221,83],{"class":79},[66,1223,1224],{"class":86}," addresses",[66,1226,91],{"class":90},[66,1228,1229,1231,1233,1235,1237,1239,1241,1243],{"class":68,"line":219},[66,1230,97],{"class":90},[66,1232,185],{"class":79},[66,1234,188],{"class":79},[66,1236,191],{"class":79},[66,1238,194],{"class":79},[66,1240,197],{"class":79},[66,1242,103],{"class":79},[66,1244,106],{"class":90},[66,1246,1247,1250,1252,1254,1256,1259,1261],{"class":68,"line":231},[66,1248,1249],{"class":90},"  user_id ",[66,1251,185],{"class":79},[66,1253,239],{"class":79},[66,1255,242],{"class":79},[66,1257,1258],{"class":90}," users(id) ",[66,1260,248],{"class":79},[66,1262,106],{"class":90},[66,1264,1265,1268,1270,1272],{"class":68,"line":253},[66,1266,1267],{"class":90},"  line1 ",[66,1269,115],{"class":79},[66,1271,239],{"class":79},[66,1273,106],{"class":90},[66,1275,1276,1279,1281],{"class":68,"line":265},[66,1277,1278],{"class":90},"  line2 ",[66,1280,115],{"class":79},[66,1282,106],{"class":90},[66,1284,1285,1288,1290,1292,1294],{"class":68,"line":285},[66,1286,1287],{"class":90},"  zip_id ",[66,1289,115],{"class":79},[66,1291,239],{"class":79},[66,1293,242],{"class":79},[66,1295,1296],{"class":90}," zip_codes(zip),\n",[66,1298,1299,1302,1304,1306,1308,1312,1315],{"class":68,"line":294},[66,1300,1301],{"class":90},"  label ",[66,1303,115],{"class":79},[66,1305,239],{"class":79},[66,1307,276],{"class":79},[66,1309,1311],{"class":1310},"sJ6F3"," 'home'",[66,1313,1314],{"class":90},",   ",[66,1316,1317],{"class":72},"-- 'home', 'work', 'shipping'\n",[66,1319,1320,1322,1324,1326,1328,1330],{"class":68,"line":299},[66,1321,1179],{"class":90},[66,1323,1182],{"class":79},[66,1325,239],{"class":79},[66,1327,276],{"class":79},[66,1329,1189],{"class":79},[66,1331,1192],{"class":90},[66,1333,1334],{"class":68,"line":534},[66,1335,134],{"class":90},[66,1337,1338],{"class":68,"line":545},[66,1339,159],{"emptyLinePlaceholder":158},[66,1341,1342],{"class":68,"line":550},[66,1343,1344],{"class":72},"-- zip_codes: the 3NF fix — city\u002Fstate derivable from zip, stored once.\n",[66,1346,1347,1349,1351,1353],{"class":68,"line":556},[66,1348,80],{"class":79},[66,1350,83],{"class":79},[66,1352,683],{"class":86},[66,1354,91],{"class":90},[66,1356,1358,1360,1362,1364],{"class":68,"line":1357},27,[66,1359,690],{"class":90},[66,1361,115],{"class":79},[66,1363,103],{"class":79},[66,1365,106],{"class":90},[66,1367,1369,1371,1373,1375],{"class":68,"line":1368},28,[66,1370,644],{"class":90},[66,1372,115],{"class":79},[66,1374,239],{"class":79},[66,1376,106],{"class":90},[66,1378,1380,1382,1384,1386],{"class":68,"line":1379},29,[66,1381,711],{"class":79},[66,1383,476],{"class":79},[66,1385,239],{"class":79},[66,1387,106],{"class":90},[66,1389,1391,1394,1396,1398,1400],{"class":68,"line":1390},30,[66,1392,1393],{"class":90},"  country ",[66,1395,115],{"class":79},[66,1397,239],{"class":79},[66,1399,276],{"class":79},[66,1401,1402],{"class":1310}," 'US'\n",[66,1404,1406],{"class":68,"line":1405},31,[66,1407,134],{"class":90},[66,1409,1411],{"class":68,"line":1410},32,[66,1412,159],{"emptyLinePlaceholder":158},[66,1414,1416],{"class":68,"line":1415},33,[66,1417,1418],{"class":72},"-- categories: self-referencing for hierarchy (adjacency list).\n",[66,1420,1422],{"class":68,"line":1421},34,[66,1423,1424],{"class":72},"-- parent_id is NULL for root categories.\n",[66,1426,1428,1430,1432,1435],{"class":68,"line":1427},35,[66,1429,80],{"class":79},[66,1431,83],{"class":79},[66,1433,1434],{"class":86}," categories",[66,1436,91],{"class":90},[66,1438,1440,1442,1444,1446,1448,1450,1452,1454],{"class":68,"line":1439},36,[66,1441,97],{"class":90},[66,1443,185],{"class":79},[66,1445,188],{"class":79},[66,1447,191],{"class":79},[66,1449,194],{"class":79},[66,1451,197],{"class":79},[66,1453,103],{"class":79},[66,1455,106],{"class":90},[66,1457,1459,1462,1464,1466,1469,1472,1475,1478],{"class":68,"line":1458},37,[66,1460,1461],{"class":90},"  parent_id ",[66,1463,185],{"class":79},[66,1465,242],{"class":79},[66,1467,1468],{"class":90}," categories(id) ",[66,1470,1471],{"class":79},"ON DELETE",[66,1473,1474],{"class":79}," SET",[66,1476,1477],{"class":79}," NULL",[66,1479,106],{"class":90},[66,1481,1483,1485,1487,1489],{"class":68,"line":1482},38,[66,1484,473],{"class":79},[66,1486,476],{"class":79},[66,1488,239],{"class":79},[66,1490,106],{"class":90},[66,1492,1494,1497,1499,1501],{"class":68,"line":1493},39,[66,1495,1496],{"class":90},"  slug ",[66,1498,115],{"class":79},[66,1500,239],{"class":79},[66,1502,1503],{"class":79}," UNIQUE\n",[66,1505,1507],{"class":68,"line":1506},40,[66,1508,134],{"class":90},[66,1510,1512],{"class":68,"line":1511},41,[66,1513,159],{"emptyLinePlaceholder":158},[66,1515,1517],{"class":68,"line":1516},42,[66,1518,1519],{"class":72},"-- products: name\u002Fprice depend on product id (3NF).\n",[66,1521,1523],{"class":68,"line":1522},43,[66,1524,1525],{"class":72},"-- category_id FK (not storing category name here — 2NF\u002F3NF).\n",[66,1527,1529,1531,1533,1535],{"class":68,"line":1528},44,[66,1530,80],{"class":79},[66,1532,83],{"class":79},[66,1534,448],{"class":86},[66,1536,91],{"class":90},[66,1538,1540,1542,1544,1546,1548,1550,1552,1554],{"class":68,"line":1539},45,[66,1541,97],{"class":90},[66,1543,185],{"class":79},[66,1545,188],{"class":79},[66,1547,191],{"class":79},[66,1549,194],{"class":79},[66,1551,197],{"class":79},[66,1553,103],{"class":79},[66,1555,106],{"class":90},[66,1557,1559,1562,1564,1566,1568],{"class":68,"line":1558},46,[66,1560,1561],{"class":90},"  category_id ",[66,1563,185],{"class":79},[66,1565,239],{"class":79},[66,1567,242],{"class":79},[66,1569,1570],{"class":90}," categories(id),\n",[66,1572,1574,1576,1578,1580],{"class":68,"line":1573},47,[66,1575,473],{"class":79},[66,1577,476],{"class":79},[66,1579,239],{"class":79},[66,1581,106],{"class":90},[66,1583,1585,1588,1591,1593,1596,1599,1601,1603,1606,1609,1612,1615,1618],{"class":68,"line":1584},48,[66,1586,1587],{"class":90},"  price ",[66,1589,1590],{"class":79},"NUMERIC",[66,1592,1015],{"class":90},[66,1594,1595],{"class":279},"10",[66,1597,1598],{"class":90},",",[66,1600,1018],{"class":279},[66,1602,1021],{"class":90},[66,1604,1605],{"class":79},"NOT NULL",[66,1607,1608],{"class":79}," CHECK",[66,1610,1611],{"class":90}," (price ",[66,1613,1614],{"class":79},">=",[66,1616,1617],{"class":279}," 0",[66,1619,1620],{"class":90},"),\n",[66,1622,1624,1627,1629,1631,1633],{"class":68,"line":1623},49,[66,1625,1626],{"class":90},"  sku ",[66,1628,115],{"class":79},[66,1630,239],{"class":79},[66,1632,967],{"class":79},[66,1634,106],{"class":90},[66,1636,1638,1640,1642,1644,1646,1648],{"class":68,"line":1637},50,[66,1639,1179],{"class":90},[66,1641,1182],{"class":79},[66,1643,239],{"class":79},[66,1645,276],{"class":79},[66,1647,1189],{"class":79},[66,1649,1192],{"class":90},[66,1651,1653],{"class":68,"line":1652},51,[66,1654,134],{"class":90},[66,1656,1658],{"class":68,"line":1657},52,[66,1659,159],{"emptyLinePlaceholder":158},[66,1661,1663],{"class":68,"line":1662},53,[66,1664,1665],{"class":72},"-- products_to_categories: many-to-many (a product can be in multiple categories).\n",[66,1667,1669],{"class":68,"line":1668},54,[66,1670,1671],{"class":72},"-- Junction table with composite PK (no duplicate pairs).\n",[66,1673,1675,1677,1679,1682],{"class":68,"line":1674},55,[66,1676,80],{"class":79},[66,1678,83],{"class":79},[66,1680,1681],{"class":86}," products_to_categories",[66,1683,91],{"class":90},[66,1685,1687,1689,1691,1693,1695,1698,1700],{"class":68,"line":1686},56,[66,1688,378],{"class":90},[66,1690,185],{"class":79},[66,1692,239],{"class":79},[66,1694,242],{"class":79},[66,1696,1697],{"class":90}," products(id) ",[66,1699,248],{"class":79},[66,1701,106],{"class":90},[66,1703,1705,1707,1709,1711,1713,1715,1717],{"class":68,"line":1704},57,[66,1706,1561],{"class":90},[66,1708,185],{"class":79},[66,1710,239],{"class":79},[66,1712,242],{"class":79},[66,1714,1468],{"class":90},[66,1716,248],{"class":79},[66,1718,106],{"class":90},[66,1720,1722,1724],{"class":68,"line":1721},58,[66,1723,288],{"class":79},[66,1725,1726],{"class":90}," (product_id, category_id)\n",[66,1728,1730],{"class":68,"line":1729},59,[66,1731,134],{"class":90},[66,1733,1735],{"class":68,"line":1734},60,[66,1736,159],{"emptyLinePlaceholder":158},[66,1738,1740],{"class":68,"line":1739},61,[66,1741,1742],{"class":72},"-- orders: one row per order. FK to user (not storing user name\u002Femail — 3NF).\n",[66,1744,1746],{"class":68,"line":1745},62,[66,1747,1748],{"class":72},"-- shipping_address_id FK (not copying address fields — 3NF).\n",[66,1750,1752,1754,1756,1758],{"class":68,"line":1751},63,[66,1753,80],{"class":79},[66,1755,83],{"class":79},[66,1757,175],{"class":86},[66,1759,91],{"class":90},[66,1761,1763,1765,1767,1769,1771,1773,1775,1777],{"class":68,"line":1762},64,[66,1764,97],{"class":90},[66,1766,185],{"class":79},[66,1768,188],{"class":79},[66,1770,191],{"class":79},[66,1772,194],{"class":79},[66,1774,197],{"class":79},[66,1776,103],{"class":79},[66,1778,106],{"class":90},[66,1780,1782,1784,1786,1788,1790],{"class":68,"line":1781},65,[66,1783,1249],{"class":90},[66,1785,185],{"class":79},[66,1787,239],{"class":79},[66,1789,242],{"class":79},[66,1791,1792],{"class":90}," users(id),\n",[66,1794,1796,1799,1801,1803,1805],{"class":68,"line":1795},66,[66,1797,1798],{"class":90},"  shipping_address_id ",[66,1800,185],{"class":79},[66,1802,239],{"class":79},[66,1804,242],{"class":79},[66,1806,1807],{"class":90}," addresses(id),\n",[66,1809,1811,1814,1816,1818,1820],{"class":68,"line":1810},67,[66,1812,1813],{"class":79},"  status",[66,1815,476],{"class":79},[66,1817,239],{"class":79},[66,1819,276],{"class":79},[66,1821,1822],{"class":1310}," 'pending'\n",[66,1824,1826,1829,1832,1835,1838,1840,1843,1845,1848,1850,1853,1855,1858,1860,1863],{"class":68,"line":1825},68,[66,1827,1828],{"class":79},"    CHECK",[66,1830,1831],{"class":90}," (",[66,1833,1834],{"class":79},"status",[66,1836,1837],{"class":79}," IN",[66,1839,1831],{"class":90},[66,1841,1842],{"class":1310},"'pending'",[66,1844,1598],{"class":90},[66,1846,1847],{"class":1310},"'paid'",[66,1849,1598],{"class":90},[66,1851,1852],{"class":1310},"'shipped'",[66,1854,1598],{"class":90},[66,1856,1857],{"class":1310},"'delivered'",[66,1859,1598],{"class":90},[66,1861,1862],{"class":1310},"'cancelled'",[66,1864,1865],{"class":90},")),\n",[66,1867,1869,1872,1874,1876,1878,1880,1882,1884,1886,1888,1891,1893,1895],{"class":68,"line":1868},69,[66,1870,1871],{"class":90},"  total ",[66,1873,1590],{"class":79},[66,1875,1015],{"class":90},[66,1877,1595],{"class":279},[66,1879,1598],{"class":90},[66,1881,1018],{"class":279},[66,1883,1021],{"class":90},[66,1885,1605],{"class":79},[66,1887,1608],{"class":79},[66,1889,1890],{"class":90}," (total ",[66,1892,1614],{"class":79},[66,1894,1617],{"class":279},[66,1896,1620],{"class":90},[66,1898,1900,1902,1904,1906,1908,1910],{"class":68,"line":1899},70,[66,1901,1179],{"class":90},[66,1903,1182],{"class":79},[66,1905,239],{"class":79},[66,1907,276],{"class":79},[66,1909,1189],{"class":79},[66,1911,1192],{"class":90},[66,1913,1915],{"class":68,"line":1914},71,[66,1916,134],{"class":90},[66,1918,1920],{"class":68,"line":1919},72,[66,1921,159],{"emptyLinePlaceholder":158},[66,1923,1925],{"class":68,"line":1924},73,[66,1926,1927],{"class":72},"-- order_items: the line items. Composite-relevant: quantity depends on\n",[66,1929,1931],{"class":68,"line":1930},74,[66,1932,1933],{"class":72},"-- (order_id, product_id) — the whole composite key (2NF).\n",[66,1935,1937],{"class":68,"line":1936},75,[66,1938,1939],{"class":72},"-- product_name is NOT here (it's in products — 2NF fix).\n",[66,1941,1943],{"class":68,"line":1942},76,[66,1944,1945],{"class":72},"-- unit_price IS here (a snapshot of the price at order time — not a\n",[66,1947,1949],{"class":68,"line":1948},77,[66,1950,1951],{"class":72},"-- 3NF violation because the current product.price may change, but the\n",[66,1953,1955],{"class":68,"line":1954},78,[66,1956,1957],{"class":72},"-- historical order price shouldn't. This is a deliberate denormalization\n",[66,1959,1961],{"class":68,"line":1960},79,[66,1962,1963],{"class":72},"-- for historical accuracy).\n",[66,1965,1967,1969,1971,1973],{"class":68,"line":1966},80,[66,1968,80],{"class":79},[66,1970,83],{"class":79},[66,1972,226],{"class":86},[66,1974,91],{"class":90},[66,1976,1978,1980,1982,1984,1986,1988,1990],{"class":68,"line":1977},81,[66,1979,234],{"class":90},[66,1981,185],{"class":79},[66,1983,239],{"class":79},[66,1985,242],{"class":79},[66,1987,245],{"class":90},[66,1989,248],{"class":79},[66,1991,106],{"class":90},[66,1993,1995,1997,1999,2001,2003],{"class":68,"line":1994},82,[66,1996,378],{"class":90},[66,1998,185],{"class":79},[66,2000,239],{"class":79},[66,2002,242],{"class":79},[66,2004,521],{"class":90},[66,2006,2008,2010,2012,2014,2016,2019,2022,2024],{"class":68,"line":2007},83,[66,2009,268],{"class":90},[66,2011,271],{"class":79},[66,2013,239],{"class":79},[66,2015,1608],{"class":79},[66,2017,2018],{"class":90}," (quantity ",[66,2020,2021],{"class":79},">",[66,2023,1617],{"class":279},[66,2025,1620],{"class":90},[66,2027,2029,2032,2034,2036,2038,2040,2042,2044,2046,2048,2051,2053,2055,2058],{"class":68,"line":2028},84,[66,2030,2031],{"class":90},"  unit_price ",[66,2033,1590],{"class":79},[66,2035,1015],{"class":90},[66,2037,1595],{"class":279},[66,2039,1598],{"class":90},[66,2041,1018],{"class":279},[66,2043,1021],{"class":90},[66,2045,1605],{"class":79},[66,2047,1608],{"class":79},[66,2049,2050],{"class":90}," (unit_price ",[66,2052,1614],{"class":79},[66,2054,1617],{"class":279},[66,2056,2057],{"class":90},"),  ",[66,2059,2060],{"class":72},"-- price snapshot\n",[66,2062,2064,2066],{"class":68,"line":2063},85,[66,2065,288],{"class":79},[66,2067,416],{"class":90},[66,2069,2071],{"class":68,"line":2070},86,[66,2072,134],{"class":90},[66,2074,2076],{"class":68,"line":2075},87,[66,2077,2078],{"class":72},"-- The unit_price snapshot is a common pattern: store the price AT THE TIME\n",[66,2080,2082],{"class":68,"line":2081},88,[66,2083,2084],{"class":72},"-- of the order, not a FK to a changing products.price. This is deliberate,\n",[66,2086,2088],{"class":68,"line":2087},89,[66,2089,2090],{"class":72},"-- documented denormalization (historical accuracy > normalization purity).\n",[46,2092,2094],{"id":2093},"denormalized-analytics-version-for-the-olap-system","Denormalized Analytics Version — for the OLAP system",[28,2096,2097],{"language":58},[32,2098,2100],{"className":61,"code":2099,"language":58,"meta":37,"style":37},"-- ── DENORMALIZED (Star Schema) — for the analytics\u002FOLAP system ──\n-- The transactional schema is normalized (good for writes, integrity).\n-- The analytics schema is denormalized (good for reads, aggregations).\n-- They are SEPARATE databases (or schemas). Don't mix OLTP and OLAP\n-- in the same schema — they have different access patterns.\n\n-- Fact table: one row per order item (grain = line item).\n-- Contains FKs to dimension tables + measures (quantity, amount).\nCREATE TABLE fact_order_items (\n  order_item_id BIGINT PRIMARY KEY,\n  order_id BIGINT NOT NULL,\n  order_date DATE NOT NULL,             -- denormalized from orders.created_at\n  user_id BIGINT NOT NULL,\n  user_email TEXT,                      -- denormalized from users (for grouping)\n  user_city TEXT,                       -- denormalized from zip_codes via addresses\n  product_id BIGINT NOT NULL,\n  product_name TEXT,                    -- denormalized from products\n  category_name TEXT,                   -- denormalized from categories\n  category_parent_name TEXT,            -- denormalized (hierarchy flattened)\n  quantity INTEGER NOT NULL,\n  unit_price NUMERIC(10,2) NOT NULL,\n  amount NUMERIC(10,2) NOT NULL,        -- quantity * unit_price (precomputed)\n  order_status TEXT NOT NULL\n);\n\n-- Dimension tables (smaller, denormalized, for filtering\u002Fgrouping):\nCREATE TABLE dim_users (\n  user_id BIGINT PRIMARY KEY,\n  email TEXT,\n  city TEXT,\n  state TEXT,\n  first_order_date DATE,\n  total_orders INTEGER,\n  total_spent NUMERIC(10,2)\n);\n\nCREATE TABLE dim_products (\n  product_id BIGINT PRIMARY KEY,\n  product_name TEXT,\n  category_name TEXT,\n  category_path TEXT,                   -- 'electronics > phones > accessories'\n  current_price NUMERIC(10,2)\n);\n\nCREATE TABLE dim_dates (\n  date DATE PRIMARY KEY,\n  year INTEGER, month INTEGER, quarter INTEGER,\n  day_of_week TEXT, is_weekend BOOLEAN,\n  is_holiday BOOLEAN, holiday_name TEXT\n);\n\n-- Analytics query: total sales by city for Q3 2026 — ONE join, fast.\nSELECT u.city, sum(f.amount) AS total\nFROM fact_order_items f\nJOIN dim_dates d ON d.date = f.order_date\nJOIN dim_users u ON u.user_id = f.user_id\nWHERE d.quarter = 3 AND d.year = 2026\nGROUP BY u.city ORDER BY total DESC;\n-- In the normalized schema, this would need 5+ joins (orders → users →\n-- addresses → zip_codes, + order_items → products → categories). The\n-- star schema pre-joins into the fact table for read performance.\n",[39,2101,2102,2107,2112,2117,2122,2127,2131,2136,2141,2152,2163,2173,2189,2199,2212,2225,2235,2247,2260,2273,2283,2303,2328,2337,2341,2345,2350,2361,2371,2379,2387,2395,2404,2413,2431,2435,2439,2450,2460,2468,2476,2488,2505,2509,2513,2524,2536,2561,2576,2589,2593,2597,2602,2639,2647,2677,2702,2733,2756,2761,2766],{"__ignoreMap":37},[66,2103,2104],{"class":68,"line":69},[66,2105,2106],{"class":72},"-- ── DENORMALIZED (Star Schema) — for the analytics\u002FOLAP system ──\n",[66,2108,2109],{"class":68,"line":76},[66,2110,2111],{"class":72},"-- The transactional schema is normalized (good for writes, integrity).\n",[66,2113,2114],{"class":68,"line":94},[66,2115,2116],{"class":72},"-- The analytics schema is denormalized (good for reads, aggregations).\n",[66,2118,2119],{"class":68,"line":109},[66,2120,2121],{"class":72},"-- They are SEPARATE databases (or schemas). Don't mix OLTP and OLAP\n",[66,2123,2124],{"class":68,"line":120},[66,2125,2126],{"class":72},"-- in the same schema — they have different access patterns.\n",[66,2128,2129],{"class":68,"line":131},[66,2130,159],{"emptyLinePlaceholder":158},[66,2132,2133],{"class":68,"line":137},[66,2134,2135],{"class":72},"-- Fact table: one row per order item (grain = line item).\n",[66,2137,2138],{"class":68,"line":143},[66,2139,2140],{"class":72},"-- Contains FKs to dimension tables + measures (quantity, amount).\n",[66,2142,2143,2145,2147,2150],{"class":68,"line":149},[66,2144,80],{"class":79},[66,2146,83],{"class":79},[66,2148,2149],{"class":86}," fact_order_items",[66,2151,91],{"class":90},[66,2153,2154,2157,2159,2161],{"class":68,"line":155},[66,2155,2156],{"class":90},"  order_item_id ",[66,2158,185],{"class":79},[66,2160,103],{"class":79},[66,2162,106],{"class":90},[66,2164,2165,2167,2169,2171],{"class":68,"line":162},[66,2166,234],{"class":90},[66,2168,185],{"class":79},[66,2170,239],{"class":79},[66,2172,106],{"class":90},[66,2174,2175,2178,2181,2183,2186],{"class":68,"line":168},[66,2176,2177],{"class":90},"  order_date ",[66,2179,2180],{"class":79},"DATE",[66,2182,239],{"class":79},[66,2184,2185],{"class":90},",             ",[66,2187,2188],{"class":72},"-- denormalized from orders.created_at\n",[66,2190,2191,2193,2195,2197],{"class":68,"line":180},[66,2192,1249],{"class":90},[66,2194,185],{"class":79},[66,2196,239],{"class":79},[66,2198,106],{"class":90},[66,2200,2201,2204,2206,2209],{"class":68,"line":204},[66,2202,2203],{"class":90},"  user_email ",[66,2205,115],{"class":79},[66,2207,2208],{"class":90},",                      ",[66,2210,2211],{"class":72},"-- denormalized from users (for grouping)\n",[66,2213,2214,2217,2219,2222],{"class":68,"line":214},[66,2215,2216],{"class":90},"  user_city ",[66,2218,115],{"class":79},[66,2220,2221],{"class":90},",                       ",[66,2223,2224],{"class":72},"-- denormalized from zip_codes via addresses\n",[66,2226,2227,2229,2231,2233],{"class":68,"line":219},[66,2228,378],{"class":90},[66,2230,185],{"class":79},[66,2232,239],{"class":79},[66,2234,106],{"class":90},[66,2236,2237,2239,2241,2244],{"class":68,"line":231},[66,2238,389],{"class":90},[66,2240,115],{"class":79},[66,2242,2243],{"class":90},",                    ",[66,2245,2246],{"class":72},"-- denormalized from products\n",[66,2248,2249,2252,2254,2257],{"class":68,"line":253},[66,2250,2251],{"class":90},"  category_name ",[66,2253,115],{"class":79},[66,2255,2256],{"class":90},",                   ",[66,2258,2259],{"class":72},"-- denormalized from categories\n",[66,2261,2262,2265,2267,2270],{"class":68,"line":265},[66,2263,2264],{"class":90},"  category_parent_name ",[66,2266,115],{"class":79},[66,2268,2269],{"class":90},",            ",[66,2271,2272],{"class":72},"-- denormalized (hierarchy flattened)\n",[66,2274,2275,2277,2279,2281],{"class":68,"line":285},[66,2276,268],{"class":90},[66,2278,271],{"class":79},[66,2280,239],{"class":79},[66,2282,106],{"class":90},[66,2284,2285,2287,2289,2291,2293,2295,2297,2299,2301],{"class":68,"line":294},[66,2286,2031],{"class":90},[66,2288,1590],{"class":79},[66,2290,1015],{"class":90},[66,2292,1595],{"class":279},[66,2294,1598],{"class":90},[66,2296,1018],{"class":279},[66,2298,1021],{"class":90},[66,2300,1605],{"class":79},[66,2302,106],{"class":90},[66,2304,2305,2308,2310,2312,2314,2316,2318,2320,2322,2325],{"class":68,"line":299},[66,2306,2307],{"class":90},"  amount ",[66,2309,1590],{"class":79},[66,2311,1015],{"class":90},[66,2313,1595],{"class":279},[66,2315,1598],{"class":90},[66,2317,1018],{"class":279},[66,2319,1021],{"class":90},[66,2321,1605],{"class":79},[66,2323,2324],{"class":90},",        ",[66,2326,2327],{"class":72},"-- quantity * unit_price (precomputed)\n",[66,2329,2330,2333,2335],{"class":68,"line":534},[66,2331,2332],{"class":90},"  order_status ",[66,2334,115],{"class":79},[66,2336,211],{"class":79},[66,2338,2339],{"class":68,"line":545},[66,2340,134],{"class":90},[66,2342,2343],{"class":68,"line":550},[66,2344,159],{"emptyLinePlaceholder":158},[66,2346,2347],{"class":68,"line":556},[66,2348,2349],{"class":72},"-- Dimension tables (smaller, denormalized, for filtering\u002Fgrouping):\n",[66,2351,2352,2354,2356,2359],{"class":68,"line":1357},[66,2353,80],{"class":79},[66,2355,83],{"class":79},[66,2357,2358],{"class":86}," dim_users",[66,2360,91],{"class":90},[66,2362,2363,2365,2367,2369],{"class":68,"line":1368},[66,2364,1249],{"class":90},[66,2366,185],{"class":79},[66,2368,103],{"class":79},[66,2370,106],{"class":90},[66,2372,2373,2375,2377],{"class":68,"line":1379},[66,2374,960],{"class":90},[66,2376,115],{"class":79},[66,2378,106],{"class":90},[66,2380,2381,2383,2385],{"class":68,"line":1390},[66,2382,644],{"class":90},[66,2384,115],{"class":79},[66,2386,106],{"class":90},[66,2388,2389,2391,2393],{"class":68,"line":1405},[66,2390,711],{"class":79},[66,2392,476],{"class":79},[66,2394,106],{"class":90},[66,2396,2397,2400,2402],{"class":68,"line":1410},[66,2398,2399],{"class":90},"  first_order_date ",[66,2401,2180],{"class":79},[66,2403,106],{"class":90},[66,2405,2406,2409,2411],{"class":68,"line":1415},[66,2407,2408],{"class":90},"  total_orders ",[66,2410,271],{"class":79},[66,2412,106],{"class":90},[66,2414,2415,2418,2420,2422,2424,2426,2428],{"class":68,"line":1421},[66,2416,2417],{"class":90},"  total_spent ",[66,2419,1590],{"class":79},[66,2421,1015],{"class":90},[66,2423,1595],{"class":279},[66,2425,1598],{"class":90},[66,2427,1018],{"class":279},[66,2429,2430],{"class":90},")\n",[66,2432,2433],{"class":68,"line":1427},[66,2434,134],{"class":90},[66,2436,2437],{"class":68,"line":1439},[66,2438,159],{"emptyLinePlaceholder":158},[66,2440,2441,2443,2445,2448],{"class":68,"line":1458},[66,2442,80],{"class":79},[66,2444,83],{"class":79},[66,2446,2447],{"class":86}," dim_products",[66,2449,91],{"class":90},[66,2451,2452,2454,2456,2458],{"class":68,"line":1482},[66,2453,378],{"class":90},[66,2455,185],{"class":79},[66,2457,103],{"class":79},[66,2459,106],{"class":90},[66,2461,2462,2464,2466],{"class":68,"line":1493},[66,2463,389],{"class":90},[66,2465,115],{"class":79},[66,2467,106],{"class":90},[66,2469,2470,2472,2474],{"class":68,"line":1506},[66,2471,2251],{"class":90},[66,2473,115],{"class":79},[66,2475,106],{"class":90},[66,2477,2478,2481,2483,2485],{"class":68,"line":1511},[66,2479,2480],{"class":90},"  category_path ",[66,2482,115],{"class":79},[66,2484,2256],{"class":90},[66,2486,2487],{"class":72},"-- 'electronics > phones > accessories'\n",[66,2489,2490,2493,2495,2497,2499,2501,2503],{"class":68,"line":1516},[66,2491,2492],{"class":90},"  current_price ",[66,2494,1590],{"class":79},[66,2496,1015],{"class":90},[66,2498,1595],{"class":279},[66,2500,1598],{"class":90},[66,2502,1018],{"class":279},[66,2504,2430],{"class":90},[66,2506,2507],{"class":68,"line":1522},[66,2508,134],{"class":90},[66,2510,2511],{"class":68,"line":1528},[66,2512,159],{"emptyLinePlaceholder":158},[66,2514,2515,2517,2519,2522],{"class":68,"line":1539},[66,2516,80],{"class":79},[66,2518,83],{"class":79},[66,2520,2521],{"class":86}," dim_dates",[66,2523,91],{"class":90},[66,2525,2526,2529,2532,2534],{"class":68,"line":1558},[66,2527,2528],{"class":79},"  date",[66,2530,2531],{"class":79}," DATE",[66,2533,103],{"class":79},[66,2535,106],{"class":90},[66,2537,2538,2541,2544,2547,2550,2552,2554,2557,2559],{"class":68,"line":1573},[66,2539,2540],{"class":79},"  year",[66,2542,2543],{"class":79}," INTEGER",[66,2545,2546],{"class":90},", ",[66,2548,2549],{"class":79},"month",[66,2551,2543],{"class":79},[66,2553,2546],{"class":90},[66,2555,2556],{"class":79},"quarter",[66,2558,2543],{"class":79},[66,2560,106],{"class":90},[66,2562,2563,2566,2568,2571,2574],{"class":68,"line":1584},[66,2564,2565],{"class":90},"  day_of_week ",[66,2567,115],{"class":79},[66,2569,2570],{"class":90},", is_weekend ",[66,2572,2573],{"class":79},"BOOLEAN",[66,2575,106],{"class":90},[66,2577,2578,2581,2583,2586],{"class":68,"line":1623},[66,2579,2580],{"class":90},"  is_holiday ",[66,2582,2573],{"class":79},[66,2584,2585],{"class":90},", holiday_name ",[66,2587,2588],{"class":79},"TEXT\n",[66,2590,2591],{"class":68,"line":1637},[66,2592,134],{"class":90},[66,2594,2595],{"class":68,"line":1652},[66,2596,159],{"emptyLinePlaceholder":158},[66,2598,2599],{"class":68,"line":1657},[66,2600,2601],{"class":72},"-- Analytics query: total sales by city for Q3 2026 — ONE join, fast.\n",[66,2603,2604,2607,2610,2613,2616,2618,2621,2623,2626,2628,2631,2633,2636],{"class":68,"line":1662},[66,2605,2606],{"class":79},"SELECT",[66,2608,2609],{"class":279}," u",[66,2611,2612],{"class":90},".",[66,2614,2615],{"class":279},"city",[66,2617,2546],{"class":90},[66,2619,2620],{"class":279},"sum",[66,2622,1015],{"class":90},[66,2624,2625],{"class":279},"f",[66,2627,2612],{"class":90},[66,2629,2630],{"class":279},"amount",[66,2632,1021],{"class":90},[66,2634,2635],{"class":79},"AS",[66,2637,2638],{"class":90}," total\n",[66,2640,2641,2644],{"class":68,"line":1668},[66,2642,2643],{"class":79},"FROM",[66,2645,2646],{"class":90}," fact_order_items f\n",[66,2648,2649,2652,2655,2658,2661,2663,2666,2669,2672,2674],{"class":68,"line":1674},[66,2650,2651],{"class":79},"JOIN",[66,2653,2654],{"class":90}," dim_dates d ",[66,2656,2657],{"class":79},"ON",[66,2659,2660],{"class":279}," d",[66,2662,2612],{"class":90},[66,2664,2665],{"class":279},"date",[66,2667,2668],{"class":79}," =",[66,2670,2671],{"class":279}," f",[66,2673,2612],{"class":90},[66,2675,2676],{"class":279},"order_date\n",[66,2678,2679,2681,2684,2686,2688,2690,2693,2695,2697,2699],{"class":68,"line":1686},[66,2680,2651],{"class":79},[66,2682,2683],{"class":90}," dim_users u ",[66,2685,2657],{"class":79},[66,2687,2609],{"class":279},[66,2689,2612],{"class":90},[66,2691,2692],{"class":279},"user_id",[66,2694,2668],{"class":79},[66,2696,2671],{"class":279},[66,2698,2612],{"class":90},[66,2700,2701],{"class":279},"user_id\n",[66,2703,2704,2707,2709,2711,2713,2715,2718,2721,2723,2725,2728,2730],{"class":68,"line":1704},[66,2705,2706],{"class":79},"WHERE",[66,2708,2660],{"class":279},[66,2710,2612],{"class":90},[66,2712,2556],{"class":279},[66,2714,2668],{"class":79},[66,2716,2717],{"class":279}," 3",[66,2719,2720],{"class":79}," AND",[66,2722,2660],{"class":279},[66,2724,2612],{"class":90},[66,2726,2727],{"class":279},"year",[66,2729,2668],{"class":79},[66,2731,2732],{"class":279}," 2026\n",[66,2734,2735,2738,2740,2742,2744,2747,2750,2753],{"class":68,"line":1721},[66,2736,2737],{"class":79},"GROUP BY",[66,2739,2609],{"class":279},[66,2741,2612],{"class":90},[66,2743,2615],{"class":279},[66,2745,2746],{"class":79}," ORDER BY",[66,2748,2749],{"class":90}," total ",[66,2751,2752],{"class":79},"DESC",[66,2754,2755],{"class":90},";\n",[66,2757,2758],{"class":68,"line":1729},[66,2759,2760],{"class":72},"-- In the normalized schema, this would need 5+ joins (orders → users →\n",[66,2762,2763],{"class":68,"line":1734},[66,2764,2765],{"class":72},"-- addresses → zip_codes, + order_items → products → categories). The\n",[66,2767,2768],{"class":68,"line":1739},[66,2769,2770],{"class":72},"-- star schema pre-joins into the fact table for read performance.\n",[46,2772,2774],{"id":2773},"slowly-changing-dimensions-scd","Slowly Changing Dimensions (SCD)",[28,2776,2777],{"language":58},[32,2778,2780],{"className":61,"code":2779,"language":58,"meta":37,"style":37},"-- Dimension data changes over time (a user moves cities, a product is\n-- reclassified). How do you track history in the analytics schema?\n\n-- SCD Type 1: overwrite (no history). Simple, loses the old value.\nUPDATE dim_users SET city = 'Boston' WHERE user_id = 42;\n-- Lost: the fact that user 42 used to live in NYC.\n\n-- SCD Type 2: track history with validity dates (the standard approach).\nCREATE TABLE dim_users_scd2 (\n  user_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,  -- surrogate key (not user_id)\n  user_id BIGINT NOT NULL,                  -- natural key (business key)\n  email TEXT,\n  city TEXT,\n  valid_from TIMESTAMPTZ NOT NULL DEFAULT now(),\n  valid_to TIMESTAMPTZ,                     -- NULL = current version\n  is_current BOOLEAN NOT NULL DEFAULT true\n);\n-- When user 42 moves from NYC to Boston:\nUPDATE dim_users_scd2 SET valid_to = now(), is_current = false\nWHERE user_id = 42 AND is_current;         -- close the old version\nINSERT INTO dim_users_scd2 (user_id, email, city)\nVALUES (42, 'alice@example.com', 'Boston'); -- open a new version\n-- Fact table records use user_sk (the surrogate) so each fact points to\n-- the correct version of the user at the time of the event.\n-- Query \"where did user 42 live when they placed order X?\":\n-- JOIN fact_order_items f ON f.user_sk = u.user_sk (point-in-time join).\n",[39,2781,2782,2787,2792,2796,2801,2834,2839,2843,2848,2859,2881,2895,2903,2911,2927,2940,2954,2958,2963,2987,3005,3013,3039,3044,3049,3054],{"__ignoreMap":37},[66,2783,2784],{"class":68,"line":69},[66,2785,2786],{"class":72},"-- Dimension data changes over time (a user moves cities, a product is\n",[66,2788,2789],{"class":68,"line":76},[66,2790,2791],{"class":72},"-- reclassified). How do you track history in the analytics schema?\n",[66,2793,2794],{"class":68,"line":94},[66,2795,159],{"emptyLinePlaceholder":158},[66,2797,2798],{"class":68,"line":109},[66,2799,2800],{"class":72},"-- SCD Type 1: overwrite (no history). Simple, loses the old value.\n",[66,2802,2803,2806,2809,2812,2815,2818,2821,2824,2827,2829,2832],{"class":68,"line":120},[66,2804,2805],{"class":79},"UPDATE",[66,2807,2808],{"class":90}," dim_users ",[66,2810,2811],{"class":79},"SET",[66,2813,2814],{"class":90}," city ",[66,2816,2817],{"class":79},"=",[66,2819,2820],{"class":1310}," 'Boston'",[66,2822,2823],{"class":79}," WHERE",[66,2825,2826],{"class":90}," user_id ",[66,2828,2817],{"class":79},[66,2830,2831],{"class":279}," 42",[66,2833,2755],{"class":90},[66,2835,2836],{"class":68,"line":131},[66,2837,2838],{"class":72},"-- Lost: the fact that user 42 used to live in NYC.\n",[66,2840,2841],{"class":68,"line":137},[66,2842,159],{"emptyLinePlaceholder":158},[66,2844,2845],{"class":68,"line":143},[66,2846,2847],{"class":72},"-- SCD Type 2: track history with validity dates (the standard approach).\n",[66,2849,2850,2852,2854,2857],{"class":68,"line":149},[66,2851,80],{"class":79},[66,2853,83],{"class":79},[66,2855,2856],{"class":86}," dim_users_scd2",[66,2858,91],{"class":90},[66,2860,2861,2864,2866,2868,2870,2872,2874,2876,2878],{"class":68,"line":155},[66,2862,2863],{"class":90},"  user_sk ",[66,2865,185],{"class":79},[66,2867,188],{"class":79},[66,2869,191],{"class":79},[66,2871,194],{"class":79},[66,2873,197],{"class":79},[66,2875,103],{"class":79},[66,2877,952],{"class":90},[66,2879,2880],{"class":72},"-- surrogate key (not user_id)\n",[66,2882,2883,2885,2887,2889,2892],{"class":68,"line":162},[66,2884,1249],{"class":90},[66,2886,185],{"class":79},[66,2888,239],{"class":79},[66,2890,2891],{"class":90},",                  ",[66,2893,2894],{"class":72},"-- natural key (business key)\n",[66,2896,2897,2899,2901],{"class":68,"line":168},[66,2898,960],{"class":90},[66,2900,115],{"class":79},[66,2902,106],{"class":90},[66,2904,2905,2907,2909],{"class":68,"line":180},[66,2906,644],{"class":90},[66,2908,115],{"class":79},[66,2910,106],{"class":90},[66,2912,2913,2916,2918,2920,2922,2924],{"class":68,"line":204},[66,2914,2915],{"class":90},"  valid_from ",[66,2917,1182],{"class":79},[66,2919,239],{"class":79},[66,2921,276],{"class":79},[66,2923,1189],{"class":79},[66,2925,2926],{"class":90},"(),\n",[66,2928,2929,2932,2934,2937],{"class":68,"line":214},[66,2930,2931],{"class":90},"  valid_to ",[66,2933,1182],{"class":79},[66,2935,2936],{"class":90},",                     ",[66,2938,2939],{"class":72},"-- NULL = current version\n",[66,2941,2942,2945,2947,2949,2951],{"class":68,"line":219},[66,2943,2944],{"class":90},"  is_current ",[66,2946,2573],{"class":79},[66,2948,239],{"class":79},[66,2950,276],{"class":79},[66,2952,2953],{"class":90}," true\n",[66,2955,2956],{"class":68,"line":231},[66,2957,134],{"class":90},[66,2959,2960],{"class":68,"line":253},[66,2961,2962],{"class":72},"-- When user 42 moves from NYC to Boston:\n",[66,2964,2965,2967,2970,2972,2975,2977,2979,2982,2984],{"class":68,"line":265},[66,2966,2805],{"class":79},[66,2968,2969],{"class":90}," dim_users_scd2 ",[66,2971,2811],{"class":79},[66,2973,2974],{"class":90}," valid_to ",[66,2976,2817],{"class":79},[66,2978,1189],{"class":79},[66,2980,2981],{"class":90},"(), is_current ",[66,2983,2817],{"class":79},[66,2985,2986],{"class":90}," false\n",[66,2988,2989,2991,2993,2995,2997,2999,3002],{"class":68,"line":285},[66,2990,2706],{"class":79},[66,2992,2826],{"class":90},[66,2994,2817],{"class":79},[66,2996,2831],{"class":279},[66,2998,2720],{"class":79},[66,3000,3001],{"class":90}," is_current;         ",[66,3003,3004],{"class":72},"-- close the old version\n",[66,3006,3007,3010],{"class":68,"line":294},[66,3008,3009],{"class":79},"INSERT INTO",[66,3011,3012],{"class":90}," dim_users_scd2 (user_id, email, city)\n",[66,3014,3015,3018,3020,3023,3025,3028,3030,3033,3036],{"class":68,"line":299},[66,3016,3017],{"class":79},"VALUES",[66,3019,1831],{"class":90},[66,3021,3022],{"class":279},"42",[66,3024,2546],{"class":90},[66,3026,3027],{"class":1310},"'alice@example.com'",[66,3029,2546],{"class":90},[66,3031,3032],{"class":1310},"'Boston'",[66,3034,3035],{"class":90},"); ",[66,3037,3038],{"class":72},"-- open a new version\n",[66,3040,3041],{"class":68,"line":534},[66,3042,3043],{"class":72},"-- Fact table records use user_sk (the surrogate) so each fact points to\n",[66,3045,3046],{"class":68,"line":545},[66,3047,3048],{"class":72},"-- the correct version of the user at the time of the event.\n",[66,3050,3051],{"class":68,"line":550},[66,3052,3053],{"class":72},"-- Query \"where did user 42 live when they placed order X?\":\n",[66,3055,3056],{"class":68,"line":556},[66,3057,3058],{"class":72},"-- JOIN fact_order_items f ON f.user_sk = u.user_sk (point-in-time join).\n",[23,3060,3062],{"id":3061},"denormalization-when-and-how","Denormalization — When and How",[28,3064,3065],{"language":58},[32,3066,3068],{"className":61,"code":3067,"language":58,"meta":37,"style":37},"-- Denormalize ONLY when you have a measured performance problem.\n-- Each denormalization adds a consistency burden (how do you keep it in sync?).\n\n-- 1. Precomputed aggregates (materialized views or trigger-maintained columns)\nCREATE MATERIALIZED VIEW daily_sales AS\n  SELECT date_trunc('day', created_at)::date AS sale_date,\n         count(*) AS order_count,\n         sum(total) AS revenue\n  FROM orders GROUP BY 1;\n-- Refresh periodically: REFRESH MATERIALIZED VIEW daily_sales;\n-- Or use a trigger-maintained counter column on customers (order_count).\n\n-- 2. Duplicate columns (with a sync mechanism)\nALTER TABLE orders ADD COLUMN customer_name TEXT;  -- denormalized from users\n-- Sync via trigger (see chapter 21) or application code.\n-- Cost: if users.name changes, orders.customer_name is stale until synced.\n\n-- 3. JSONB for variable structure (deliberate denormalization)\nCREATE TABLE products (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL,\n  attributes JSONB NOT NULL DEFAULT '{}'  -- variable key-value pairs\n);\n-- Better than EAV (see anti-patterns). Indexable with GIN, queryable with ->>.\nCREATE INDEX products_attrs_idx ON products USING GIN (attributes);\n\n-- 4. Summary tables (scheduled refresh)\nCREATE TABLE monthly_revenue (\n  month DATE PRIMARY KEY,\n  revenue NUMERIC NOT NULL,\n  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n-- Updated by a nightly job: INSERT ... ON CONFLICT (month) DO UPDATE ...\n",[39,3069,3070,3075,3080,3084,3089,3099,3120,3137,3150,3164,3169,3174,3178,3183,3206,3211,3216,3220,3225,3235,3253,3263,3278,3282,3287,3309,3313,3318,3329,3340,3351,3366,3370],{"__ignoreMap":37},[66,3071,3072],{"class":68,"line":69},[66,3073,3074],{"class":72},"-- Denormalize ONLY when you have a measured performance problem.\n",[66,3076,3077],{"class":68,"line":76},[66,3078,3079],{"class":72},"-- Each denormalization adds a consistency burden (how do you keep it in sync?).\n",[66,3081,3082],{"class":68,"line":94},[66,3083,159],{"emptyLinePlaceholder":158},[66,3085,3086],{"class":68,"line":109},[66,3087,3088],{"class":72},"-- 1. Precomputed aggregates (materialized views or trigger-maintained columns)\n",[66,3090,3091,3093,3096],{"class":68,"line":120},[66,3092,80],{"class":79},[66,3094,3095],{"class":90}," MATERIALIZED VIEW daily_sales ",[66,3097,3098],{"class":79},"AS\n",[66,3100,3101,3104,3107,3110,3113,3115,3117],{"class":68,"line":131},[66,3102,3103],{"class":79},"  SELECT",[66,3105,3106],{"class":90}," date_trunc(",[66,3108,3109],{"class":1310},"'day'",[66,3111,3112],{"class":90},", created_at)::",[66,3114,2665],{"class":79},[66,3116,194],{"class":79},[66,3118,3119],{"class":90}," sale_date,\n",[66,3121,3122,3125,3127,3130,3132,3134],{"class":68,"line":137},[66,3123,3124],{"class":279},"         count",[66,3126,1015],{"class":90},[66,3128,3129],{"class":79},"*",[66,3131,1021],{"class":90},[66,3133,2635],{"class":79},[66,3135,3136],{"class":90}," order_count,\n",[66,3138,3139,3142,3145,3147],{"class":68,"line":143},[66,3140,3141],{"class":279},"         sum",[66,3143,3144],{"class":90},"(total) ",[66,3146,2635],{"class":79},[66,3148,3149],{"class":90}," revenue\n",[66,3151,3152,3155,3158,3160,3162],{"class":68,"line":149},[66,3153,3154],{"class":79},"  FROM",[66,3156,3157],{"class":90}," orders ",[66,3159,2737],{"class":79},[66,3161,280],{"class":279},[66,3163,2755],{"class":90},[66,3165,3166],{"class":68,"line":155},[66,3167,3168],{"class":72},"-- Refresh periodically: REFRESH MATERIALIZED VIEW daily_sales;\n",[66,3170,3171],{"class":68,"line":162},[66,3172,3173],{"class":72},"-- Or use a trigger-maintained counter column on customers (order_count).\n",[66,3175,3176],{"class":68,"line":168},[66,3177,159],{"emptyLinePlaceholder":158},[66,3179,3180],{"class":68,"line":180},[66,3181,3182],{"class":72},"-- 2. Duplicate columns (with a sync mechanism)\n",[66,3184,3185,3188,3190,3192,3195,3198,3200,3203],{"class":68,"line":204},[66,3186,3187],{"class":79},"ALTER",[66,3189,83],{"class":79},[66,3191,3157],{"class":90},[66,3193,3194],{"class":79},"ADD",[66,3196,3197],{"class":90}," COLUMN customer_name ",[66,3199,115],{"class":79},[66,3201,3202],{"class":90},";  ",[66,3204,3205],{"class":72},"-- denormalized from users\n",[66,3207,3208],{"class":68,"line":214},[66,3209,3210],{"class":72},"-- Sync via trigger (see chapter 21) or application code.\n",[66,3212,3213],{"class":68,"line":219},[66,3214,3215],{"class":72},"-- Cost: if users.name changes, orders.customer_name is stale until synced.\n",[66,3217,3218],{"class":68,"line":231},[66,3219,159],{"emptyLinePlaceholder":158},[66,3221,3222],{"class":68,"line":253},[66,3223,3224],{"class":72},"-- 3. JSONB for variable structure (deliberate denormalization)\n",[66,3226,3227,3229,3231,3233],{"class":68,"line":265},[66,3228,80],{"class":79},[66,3230,83],{"class":79},[66,3232,448],{"class":86},[66,3234,91],{"class":90},[66,3236,3237,3239,3241,3243,3245,3247,3249,3251],{"class":68,"line":285},[66,3238,97],{"class":90},[66,3240,185],{"class":79},[66,3242,188],{"class":79},[66,3244,191],{"class":79},[66,3246,194],{"class":79},[66,3248,197],{"class":79},[66,3250,103],{"class":79},[66,3252,106],{"class":90},[66,3254,3255,3257,3259,3261],{"class":68,"line":294},[66,3256,473],{"class":79},[66,3258,476],{"class":79},[66,3260,239],{"class":79},[66,3262,106],{"class":90},[66,3264,3265,3268,3270,3272,3275],{"class":68,"line":299},[66,3266,3267],{"class":90},"  attributes JSONB ",[66,3269,1605],{"class":79},[66,3271,276],{"class":79},[66,3273,3274],{"class":1310}," '{}'",[66,3276,3277],{"class":72},"  -- variable key-value pairs\n",[66,3279,3280],{"class":68,"line":534},[66,3281,134],{"class":90},[66,3283,3284],{"class":68,"line":545},[66,3285,3286],{"class":72},"-- Better than EAV (see anti-patterns). Indexable with GIN, queryable with ->>.\n",[66,3288,3289,3291,3294,3297,3300,3303,3306],{"class":68,"line":550},[66,3290,80],{"class":79},[66,3292,3293],{"class":79}," INDEX",[66,3295,3296],{"class":86}," products_attrs_idx",[66,3298,3299],{"class":79}," ON",[66,3301,3302],{"class":90}," products ",[66,3304,3305],{"class":79},"USING",[66,3307,3308],{"class":90}," GIN (attributes);\n",[66,3310,3311],{"class":68,"line":556},[66,3312,159],{"emptyLinePlaceholder":158},[66,3314,3315],{"class":68,"line":1357},[66,3316,3317],{"class":72},"-- 4. Summary tables (scheduled refresh)\n",[66,3319,3320,3322,3324,3327],{"class":68,"line":1368},[66,3321,80],{"class":79},[66,3323,83],{"class":79},[66,3325,3326],{"class":86}," monthly_revenue",[66,3328,91],{"class":90},[66,3330,3331,3334,3336,3338],{"class":68,"line":1379},[66,3332,3333],{"class":79},"  month",[66,3335,2531],{"class":79},[66,3337,103],{"class":79},[66,3339,106],{"class":90},[66,3341,3342,3345,3347,3349],{"class":68,"line":1390},[66,3343,3344],{"class":90},"  revenue ",[66,3346,1590],{"class":79},[66,3348,239],{"class":79},[66,3350,106],{"class":90},[66,3352,3353,3356,3358,3360,3362,3364],{"class":68,"line":1405},[66,3354,3355],{"class":90},"  updated_at ",[66,3357,1182],{"class":79},[66,3359,239],{"class":79},[66,3361,276],{"class":79},[66,3363,1189],{"class":79},[66,3365,1192],{"class":90},[66,3367,3368],{"class":68,"line":1410},[66,3369,134],{"class":90},[66,3371,3372],{"class":68,"line":1415},[66,3373,3374],{"class":72},"-- Updated by a nightly job: INSERT ... ON CONFLICT (month) DO UPDATE ...\n",[23,3376,3378],{"id":3377},"anti-pattern-premature-denormalization","Anti-Pattern: Premature Denormalization",[28,3380,3381],{"language":58},[32,3382,3384],{"className":61,"code":3383,"language":58,"meta":37,"style":37},"-- ❌ WRONG: denormalizing before you have a performance problem.\n-- \"Let's copy customer_name into orders to avoid a join\" — before\n-- measuring whether the join is actually slow.\n-- Costs:\n-- 1. Consistency burden: a trigger or app code must sync customer_name.\n-- 2. Storage: duplicated data.\n-- 3. Complexity: two sources of truth for the same fact.\n-- 4. The join might be fast (indexed FK, small table) — the denormalization\n--    added complexity for zero gain.\n\n-- ✅ RIGHT: normalize first. Denormalize ONLY when you have a MEASURED\n-- performance problem (EXPLAIN shows a slow join, profiling shows it's\n-- the bottleneck). Then denormalize with a consistency mechanism.\n-- Most \"joins are slow\" fears are unfounded — indexed FKs on small-ish\n-- dimension tables are fast. Measure, don't guess.\n",[39,3385,3386,3391,3396,3401,3406,3411,3416,3421,3426,3431,3435,3440,3445,3450,3455],{"__ignoreMap":37},[66,3387,3388],{"class":68,"line":69},[66,3389,3390],{"class":72},"-- ❌ WRONG: denormalizing before you have a performance problem.\n",[66,3392,3393],{"class":68,"line":76},[66,3394,3395],{"class":72},"-- \"Let's copy customer_name into orders to avoid a join\" — before\n",[66,3397,3398],{"class":68,"line":94},[66,3399,3400],{"class":72},"-- measuring whether the join is actually slow.\n",[66,3402,3403],{"class":68,"line":109},[66,3404,3405],{"class":72},"-- Costs:\n",[66,3407,3408],{"class":68,"line":120},[66,3409,3410],{"class":72},"-- 1. Consistency burden: a trigger or app code must sync customer_name.\n",[66,3412,3413],{"class":68,"line":131},[66,3414,3415],{"class":72},"-- 2. Storage: duplicated data.\n",[66,3417,3418],{"class":68,"line":137},[66,3419,3420],{"class":72},"-- 3. Complexity: two sources of truth for the same fact.\n",[66,3422,3423],{"class":68,"line":143},[66,3424,3425],{"class":72},"-- 4. The join might be fast (indexed FK, small table) — the denormalization\n",[66,3427,3428],{"class":68,"line":149},[66,3429,3430],{"class":72},"--    added complexity for zero gain.\n",[66,3432,3433],{"class":68,"line":155},[66,3434,159],{"emptyLinePlaceholder":158},[66,3436,3437],{"class":68,"line":162},[66,3438,3439],{"class":72},"-- ✅ RIGHT: normalize first. Denormalize ONLY when you have a MEASURED\n",[66,3441,3442],{"class":68,"line":168},[66,3443,3444],{"class":72},"-- performance problem (EXPLAIN shows a slow join, profiling shows it's\n",[66,3446,3447],{"class":68,"line":180},[66,3448,3449],{"class":72},"-- the bottleneck). Then denormalize with a consistency mechanism.\n",[66,3451,3452],{"class":68,"line":204},[66,3453,3454],{"class":72},"-- Most \"joins are slow\" fears are unfounded — indexed FKs on small-ish\n",[66,3456,3457],{"class":68,"line":214},[66,3458,3459],{"class":72},"-- dimension tables are fast. Measure, don't guess.\n",[23,3461,3463],{"id":3462},"anti-pattern-entity-attribute-value-eav","Anti-Pattern: Entity-Attribute-Value (EAV)",[28,3465,3466],{"language":58},[32,3467,3469],{"className":61,"code":3468,"language":58,"meta":37,"style":37},"-- ❌ WRONG: EAV — a \"flexible\" properties table\nCREATE TABLE product_attributes (\n  product_id BIGINT REFERENCES products(id),\n  attribute_name TEXT,           -- 'color', 'weight', 'screen_size'\n  attribute_value TEXT           -- 'red', '1.5kg', '15\"'\n);\n-- Querying \"all red products weighing \u003C 2kg\" requires 3+ self-joins:\nSELECT p.* FROM products p\nJOIN product_attributes a1 ON a1.product_id = p.id AND a1.attribute_name = 'color' AND a1.attribute_value = 'red'\nJOIN product_attributes a2 ON a2.product_id = p.id AND a2.attribute_name = 'weight' AND a2.attribute_value::numeric \u003C 2;\n-- Problems: no typing (everything is TEXT), no indexes (can't index by\n-- attribute_name+value efficiently), query complexity explodes, no\n-- integrity (typos: 'Color' vs 'color'), can't enforce NOT NULL on\n-- a specific attribute.\n\n-- ✅ RIGHT: use JSONB for variable attributes (typed, indexable, queryable)\nCREATE TABLE products (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL,\n  attributes JSONB NOT NULL DEFAULT '{}'\n  -- {\"color\": \"red\", \"weight_kg\": 1.5, \"screen_size\": 15}\n);\nCREATE INDEX products_attrs_idx ON products USING GIN (attributes);\n-- Query: all red products \u003C 2kg\nSELECT * FROM products\nWHERE attributes->>'color' = 'red'\n  AND (attributes->>'weight_kg')::numeric \u003C 2;\n-- Indexable (GIN), typed at query time, no self-joins. If attributes are\n-- well-known and fixed, use real columns instead of JSONB (fully typed,\n-- indexable by default, constraint-checkable).\n",[39,3470,3471,3476,3487,3497,3509,3519,3523,3528,3543,3598,3657,3662,3667,3672,3677,3681,3686,3696,3714,3724,3735,3740,3744,3760,3765,3777,3794,3818,3823,3828],{"__ignoreMap":37},[66,3472,3473],{"class":68,"line":69},[66,3474,3475],{"class":72},"-- ❌ WRONG: EAV — a \"flexible\" properties table\n",[66,3477,3478,3480,3482,3485],{"class":68,"line":76},[66,3479,80],{"class":79},[66,3481,83],{"class":79},[66,3483,3484],{"class":86}," product_attributes",[66,3486,91],{"class":90},[66,3488,3489,3491,3493,3495],{"class":68,"line":94},[66,3490,378],{"class":90},[66,3492,185],{"class":79},[66,3494,242],{"class":79},[66,3496,521],{"class":90},[66,3498,3499,3502,3504,3506],{"class":68,"line":109},[66,3500,3501],{"class":90},"  attribute_name ",[66,3503,115],{"class":79},[66,3505,1161],{"class":90},[66,3507,3508],{"class":72},"-- 'color', 'weight', 'screen_size'\n",[66,3510,3511,3514,3516],{"class":68,"line":120},[66,3512,3513],{"class":90},"  attribute_value ",[66,3515,115],{"class":79},[66,3517,3518],{"class":72},"           -- 'red', '1.5kg', '15\"'\n",[66,3520,3521],{"class":68,"line":131},[66,3522,134],{"class":90},[66,3524,3525],{"class":68,"line":137},[66,3526,3527],{"class":72},"-- Querying \"all red products weighing \u003C 2kg\" requires 3+ self-joins:\n",[66,3529,3530,3532,3535,3537,3540],{"class":68,"line":143},[66,3531,2606],{"class":79},[66,3533,3534],{"class":90}," p.",[66,3536,3129],{"class":79},[66,3538,3539],{"class":79}," FROM",[66,3541,3542],{"class":90}," products p\n",[66,3544,3545,3547,3550,3552,3555,3557,3560,3562,3565,3567,3570,3572,3574,3576,3579,3581,3584,3586,3588,3590,3593,3595],{"class":68,"line":149},[66,3546,2651],{"class":79},[66,3548,3549],{"class":90}," product_attributes a1 ",[66,3551,2657],{"class":79},[66,3553,3554],{"class":279}," a1",[66,3556,2612],{"class":90},[66,3558,3559],{"class":279},"product_id",[66,3561,2668],{"class":79},[66,3563,3564],{"class":279}," p",[66,3566,2612],{"class":90},[66,3568,3569],{"class":279},"id",[66,3571,2720],{"class":79},[66,3573,3554],{"class":279},[66,3575,2612],{"class":90},[66,3577,3578],{"class":279},"attribute_name",[66,3580,2668],{"class":79},[66,3582,3583],{"class":1310}," 'color'",[66,3585,2720],{"class":79},[66,3587,3554],{"class":279},[66,3589,2612],{"class":90},[66,3591,3592],{"class":279},"attribute_value",[66,3594,2668],{"class":79},[66,3596,3597],{"class":1310}," 'red'\n",[66,3599,3600,3602,3605,3607,3610,3612,3614,3616,3618,3620,3622,3624,3626,3628,3630,3632,3635,3637,3639,3641,3643,3646,3649,3652,3655],{"class":68,"line":155},[66,3601,2651],{"class":79},[66,3603,3604],{"class":90}," product_attributes a2 ",[66,3606,2657],{"class":79},[66,3608,3609],{"class":279}," a2",[66,3611,2612],{"class":90},[66,3613,3559],{"class":279},[66,3615,2668],{"class":79},[66,3617,3564],{"class":279},[66,3619,2612],{"class":90},[66,3621,3569],{"class":279},[66,3623,2720],{"class":79},[66,3625,3609],{"class":279},[66,3627,2612],{"class":90},[66,3629,3578],{"class":279},[66,3631,2668],{"class":79},[66,3633,3634],{"class":1310}," 'weight'",[66,3636,2720],{"class":79},[66,3638,3609],{"class":279},[66,3640,2612],{"class":90},[66,3642,3592],{"class":279},[66,3644,3645],{"class":90},"::",[66,3647,3648],{"class":79},"numeric",[66,3650,3651],{"class":79}," \u003C",[66,3653,3654],{"class":279}," 2",[66,3656,2755],{"class":90},[66,3658,3659],{"class":68,"line":162},[66,3660,3661],{"class":72},"-- Problems: no typing (everything is TEXT), no indexes (can't index by\n",[66,3663,3664],{"class":68,"line":168},[66,3665,3666],{"class":72},"-- attribute_name+value efficiently), query complexity explodes, no\n",[66,3668,3669],{"class":68,"line":180},[66,3670,3671],{"class":72},"-- integrity (typos: 'Color' vs 'color'), can't enforce NOT NULL on\n",[66,3673,3674],{"class":68,"line":204},[66,3675,3676],{"class":72},"-- a specific attribute.\n",[66,3678,3679],{"class":68,"line":214},[66,3680,159],{"emptyLinePlaceholder":158},[66,3682,3683],{"class":68,"line":219},[66,3684,3685],{"class":72},"-- ✅ RIGHT: use JSONB for variable attributes (typed, indexable, queryable)\n",[66,3687,3688,3690,3692,3694],{"class":68,"line":231},[66,3689,80],{"class":79},[66,3691,83],{"class":79},[66,3693,448],{"class":86},[66,3695,91],{"class":90},[66,3697,3698,3700,3702,3704,3706,3708,3710,3712],{"class":68,"line":253},[66,3699,97],{"class":90},[66,3701,185],{"class":79},[66,3703,188],{"class":79},[66,3705,191],{"class":79},[66,3707,194],{"class":79},[66,3709,197],{"class":79},[66,3711,103],{"class":79},[66,3713,106],{"class":90},[66,3715,3716,3718,3720,3722],{"class":68,"line":265},[66,3717,473],{"class":79},[66,3719,476],{"class":79},[66,3721,239],{"class":79},[66,3723,106],{"class":90},[66,3725,3726,3728,3730,3732],{"class":68,"line":285},[66,3727,3267],{"class":90},[66,3729,1605],{"class":79},[66,3731,276],{"class":79},[66,3733,3734],{"class":1310}," '{}'\n",[66,3736,3737],{"class":68,"line":294},[66,3738,3739],{"class":72},"  -- {\"color\": \"red\", \"weight_kg\": 1.5, \"screen_size\": 15}\n",[66,3741,3742],{"class":68,"line":299},[66,3743,134],{"class":90},[66,3745,3746,3748,3750,3752,3754,3756,3758],{"class":68,"line":534},[66,3747,80],{"class":79},[66,3749,3293],{"class":79},[66,3751,3296],{"class":86},[66,3753,3299],{"class":79},[66,3755,3302],{"class":90},[66,3757,3305],{"class":79},[66,3759,3308],{"class":90},[66,3761,3762],{"class":68,"line":545},[66,3763,3764],{"class":72},"-- Query: all red products \u003C 2kg\n",[66,3766,3767,3769,3772,3774],{"class":68,"line":550},[66,3768,2606],{"class":79},[66,3770,3771],{"class":79}," *",[66,3773,3539],{"class":79},[66,3775,3776],{"class":90}," products\n",[66,3778,3779,3781,3784,3787,3790,3792],{"class":68,"line":556},[66,3780,2706],{"class":79},[66,3782,3783],{"class":90}," attributes",[66,3785,3786],{"class":79},"->>",[66,3788,3789],{"class":1310},"'color'",[66,3791,2668],{"class":79},[66,3793,3597],{"class":1310},[66,3795,3796,3799,3802,3804,3807,3810,3812,3814,3816],{"class":68,"line":1357},[66,3797,3798],{"class":79},"  AND",[66,3800,3801],{"class":90}," (attributes",[66,3803,3786],{"class":79},[66,3805,3806],{"class":1310},"'weight_kg'",[66,3808,3809],{"class":90},")::",[66,3811,3648],{"class":79},[66,3813,3651],{"class":79},[66,3815,3654],{"class":279},[66,3817,2755],{"class":90},[66,3819,3820],{"class":68,"line":1368},[66,3821,3822],{"class":72},"-- Indexable (GIN), typed at query time, no self-joins. If attributes are\n",[66,3824,3825],{"class":68,"line":1379},[66,3826,3827],{"class":72},"-- well-known and fixed, use real columns instead of JSONB (fully typed,\n",[66,3829,3830],{"class":68,"line":1390},[66,3831,3832],{"class":72},"-- indexable by default, constraint-checkable).\n",[23,3834,3836],{"id":3835},"anti-pattern-polymorphic-associations","Anti-Pattern: Polymorphic Associations",[28,3838,3839],{"language":58},[32,3840,3842],{"className":61,"code":3841,"language":58,"meta":37,"style":37},"-- ❌ WRONG: polymorphic association — commentable_type + commentable_id\nCREATE TABLE comments (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  body TEXT NOT NULL,\n  commentable_type TEXT NOT NULL,   -- 'post' or 'video' or 'product'\n  commentable_id BIGINT NOT NULL    -- FK to... which table?\n);\n-- No FK possible — commentable_id could reference posts, videos, or products.\n-- Referential integrity is NOT enforced. A deleted post leaves dangling comments.\n-- Queries need dynamic SQL or CASE to join the right table.\n\n-- ✅ RIGHT: separate tables per type, or nullable FKs with a CHECK constraint\n-- Option 1: separate comment tables (simple, clear)\nCREATE TABLE post_comments (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,\n  body TEXT NOT NULL\n);\n\n-- Option 2: nullable FKs with a CHECK (exactly one non-NULL)\nCREATE TABLE comments (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  body TEXT NOT NULL,\n  post_id BIGINT REFERENCES posts(id) ON DELETE CASCADE,\n  video_id BIGINT REFERENCES videos(id) ON DELETE CASCADE,\n  product_id BIGINT REFERENCES products(id) ON DELETE CASCADE,\n  CHECK (num_nonnull(post_id, video_id, product_id) = 1)  -- exactly one target\n);\n-- FKs enforce integrity. CHECK ensures exactly one. No polymorphic magic.\n",[39,3843,3844,3849,3860,3878,3889,3903,3915,3919,3924,3929,3934,3938,3943,3948,3959,3977,3995,4003,4007,4011,4016,4026,4044,4054,4068,4084,4098,4116,4120],{"__ignoreMap":37},[66,3845,3846],{"class":68,"line":69},[66,3847,3848],{"class":72},"-- ❌ WRONG: polymorphic association — commentable_type + commentable_id\n",[66,3850,3851,3853,3855,3858],{"class":68,"line":76},[66,3852,80],{"class":79},[66,3854,83],{"class":79},[66,3856,3857],{"class":86}," comments",[66,3859,91],{"class":90},[66,3861,3862,3864,3866,3868,3870,3872,3874,3876],{"class":68,"line":94},[66,3863,97],{"class":90},[66,3865,185],{"class":79},[66,3867,188],{"class":79},[66,3869,191],{"class":79},[66,3871,194],{"class":79},[66,3873,197],{"class":79},[66,3875,103],{"class":79},[66,3877,106],{"class":90},[66,3879,3880,3883,3885,3887],{"class":68,"line":109},[66,3881,3882],{"class":90},"  body ",[66,3884,115],{"class":79},[66,3886,239],{"class":79},[66,3888,106],{"class":90},[66,3890,3891,3894,3896,3898,3900],{"class":68,"line":120},[66,3892,3893],{"class":90},"  commentable_type ",[66,3895,115],{"class":79},[66,3897,239],{"class":79},[66,3899,1314],{"class":90},[66,3901,3902],{"class":72},"-- 'post' or 'video' or 'product'\n",[66,3904,3905,3908,3910,3912],{"class":68,"line":131},[66,3906,3907],{"class":90},"  commentable_id ",[66,3909,185],{"class":79},[66,3911,239],{"class":79},[66,3913,3914],{"class":72},"    -- FK to... which table?\n",[66,3916,3917],{"class":68,"line":137},[66,3918,134],{"class":90},[66,3920,3921],{"class":68,"line":143},[66,3922,3923],{"class":72},"-- No FK possible — commentable_id could reference posts, videos, or products.\n",[66,3925,3926],{"class":68,"line":149},[66,3927,3928],{"class":72},"-- Referential integrity is NOT enforced. A deleted post leaves dangling comments.\n",[66,3930,3931],{"class":68,"line":155},[66,3932,3933],{"class":72},"-- Queries need dynamic SQL or CASE to join the right table.\n",[66,3935,3936],{"class":68,"line":162},[66,3937,159],{"emptyLinePlaceholder":158},[66,3939,3940],{"class":68,"line":168},[66,3941,3942],{"class":72},"-- ✅ RIGHT: separate tables per type, or nullable FKs with a CHECK constraint\n",[66,3944,3945],{"class":68,"line":180},[66,3946,3947],{"class":72},"-- Option 1: separate comment tables (simple, clear)\n",[66,3949,3950,3952,3954,3957],{"class":68,"line":204},[66,3951,80],{"class":79},[66,3953,83],{"class":79},[66,3955,3956],{"class":86}," post_comments",[66,3958,91],{"class":90},[66,3960,3961,3963,3965,3967,3969,3971,3973,3975],{"class":68,"line":214},[66,3962,97],{"class":90},[66,3964,185],{"class":79},[66,3966,188],{"class":79},[66,3968,191],{"class":79},[66,3970,194],{"class":79},[66,3972,197],{"class":79},[66,3974,103],{"class":79},[66,3976,106],{"class":90},[66,3978,3979,3982,3984,3986,3988,3991,3993],{"class":68,"line":219},[66,3980,3981],{"class":90},"  post_id ",[66,3983,185],{"class":79},[66,3985,239],{"class":79},[66,3987,242],{"class":79},[66,3989,3990],{"class":90}," posts(id) ",[66,3992,248],{"class":79},[66,3994,106],{"class":90},[66,3996,3997,3999,4001],{"class":68,"line":231},[66,3998,3882],{"class":90},[66,4000,115],{"class":79},[66,4002,211],{"class":79},[66,4004,4005],{"class":68,"line":253},[66,4006,134],{"class":90},[66,4008,4009],{"class":68,"line":265},[66,4010,159],{"emptyLinePlaceholder":158},[66,4012,4013],{"class":68,"line":285},[66,4014,4015],{"class":72},"-- Option 2: nullable FKs with a CHECK (exactly one non-NULL)\n",[66,4017,4018,4020,4022,4024],{"class":68,"line":294},[66,4019,80],{"class":79},[66,4021,83],{"class":79},[66,4023,3857],{"class":86},[66,4025,91],{"class":90},[66,4027,4028,4030,4032,4034,4036,4038,4040,4042],{"class":68,"line":299},[66,4029,97],{"class":90},[66,4031,185],{"class":79},[66,4033,188],{"class":79},[66,4035,191],{"class":79},[66,4037,194],{"class":79},[66,4039,197],{"class":79},[66,4041,103],{"class":79},[66,4043,106],{"class":90},[66,4045,4046,4048,4050,4052],{"class":68,"line":534},[66,4047,3882],{"class":90},[66,4049,115],{"class":79},[66,4051,239],{"class":79},[66,4053,106],{"class":90},[66,4055,4056,4058,4060,4062,4064,4066],{"class":68,"line":545},[66,4057,3981],{"class":90},[66,4059,185],{"class":79},[66,4061,242],{"class":79},[66,4063,3990],{"class":90},[66,4065,248],{"class":79},[66,4067,106],{"class":90},[66,4069,4070,4073,4075,4077,4080,4082],{"class":68,"line":550},[66,4071,4072],{"class":90},"  video_id ",[66,4074,185],{"class":79},[66,4076,242],{"class":79},[66,4078,4079],{"class":90}," videos(id) ",[66,4081,248],{"class":79},[66,4083,106],{"class":90},[66,4085,4086,4088,4090,4092,4094,4096],{"class":68,"line":556},[66,4087,378],{"class":90},[66,4089,185],{"class":79},[66,4091,242],{"class":79},[66,4093,1697],{"class":90},[66,4095,248],{"class":79},[66,4097,106],{"class":90},[66,4099,4100,4103,4106,4108,4110,4113],{"class":68,"line":1357},[66,4101,4102],{"class":79},"  CHECK",[66,4104,4105],{"class":90}," (num_nonnull(post_id, video_id, product_id) ",[66,4107,2817],{"class":79},[66,4109,280],{"class":279},[66,4111,4112],{"class":90},")  ",[66,4114,4115],{"class":72},"-- exactly one target\n",[66,4117,4118],{"class":68,"line":1368},[66,4119,134],{"class":90},[66,4121,4122],{"class":68,"line":1379},[66,4123,4124],{"class":72},"-- FKs enforce integrity. CHECK ensures exactly one. No polymorphic magic.\n",[23,4126,4128],{"id":4127},"relationship-patterns-11-1n-nn","Relationship Patterns — 1:1, 1:N, N:N",[28,4130,4131],{"language":58},[32,4132,4134],{"className":61,"code":4133,"language":58,"meta":37,"style":37},"-- One-to-Many: FK on the \"many\" side\nCREATE TABLE customers (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT);\nCREATE TABLE orders (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  customer_id BIGINT NOT NULL REFERENCES customers(id),  -- FK on the many side\n  amount NUMERIC NOT NULL\n);\n\n-- One-to-One: FK that's also the PK (UNIQUE + NOT NULL)\nCREATE TABLE users (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email TEXT UNIQUE);\nCREATE TABLE user_profiles (\n  user_id BIGINT PRIMARY KEY REFERENCES users(id),  -- PK → UNIQUE + NOT NULL → 1:1\n  bio TEXT,\n  avatar_url TEXT\n);\n-- Use 1:1 to: split wide tables, isolate sensitive columns (passwords),\n-- model optional subclassing (users + admins).\n\n-- Many-to-Many: junction table with composite PK\nCREATE TABLE students (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT);\nCREATE TABLE courses  (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title TEXT);\nCREATE TABLE enrollments (\n  student_id BIGINT NOT NULL REFERENCES students(id) ON DELETE CASCADE,\n  course_id  BIGINT NOT NULL REFERENCES courses(id) ON DELETE CASCADE,\n  enrolled_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n  grade TEXT,\n  PRIMARY KEY (student_id, course_id)  -- prevents duplicate (student, course) pairs\n);\n-- The junction table can carry relationship attributes (enrolled_at, grade).\n",[39,4135,4136,4141,4173,4183,4201,4218,4226,4230,4234,4239,4270,4281,4297,4306,4313,4317,4322,4327,4331,4336,4367,4398,4409,4427,4445,4460,4469,4479,4483],{"__ignoreMap":37},[66,4137,4138],{"class":68,"line":69},[66,4139,4140],{"class":72},"-- One-to-Many: FK on the \"many\" side\n",[66,4142,4143,4145,4147,4149,4152,4154,4156,4158,4160,4162,4164,4166,4169,4171],{"class":68,"line":76},[66,4144,80],{"class":79},[66,4146,83],{"class":79},[66,4148,728],{"class":86},[66,4150,4151],{"class":90}," (id ",[66,4153,185],{"class":79},[66,4155,188],{"class":79},[66,4157,191],{"class":79},[66,4159,194],{"class":79},[66,4161,197],{"class":79},[66,4163,103],{"class":79},[66,4165,2546],{"class":90},[66,4167,4168],{"class":79},"name",[66,4170,476],{"class":79},[66,4172,134],{"class":90},[66,4174,4175,4177,4179,4181],{"class":68,"line":94},[66,4176,80],{"class":79},[66,4178,83],{"class":79},[66,4180,175],{"class":86},[66,4182,91],{"class":90},[66,4184,4185,4187,4189,4191,4193,4195,4197,4199],{"class":68,"line":109},[66,4186,97],{"class":90},[66,4188,185],{"class":79},[66,4190,188],{"class":79},[66,4192,191],{"class":79},[66,4194,194],{"class":79},[66,4196,197],{"class":79},[66,4198,103],{"class":79},[66,4200,106],{"class":90},[66,4202,4203,4206,4208,4210,4212,4215],{"class":68,"line":120},[66,4204,4205],{"class":90},"  customer_id ",[66,4207,185],{"class":79},[66,4209,239],{"class":79},[66,4211,242],{"class":79},[66,4213,4214],{"class":90}," customers(id),  ",[66,4216,4217],{"class":72},"-- FK on the many side\n",[66,4219,4220,4222,4224],{"class":68,"line":131},[66,4221,2307],{"class":90},[66,4223,1590],{"class":79},[66,4225,211],{"class":79},[66,4227,4228],{"class":68,"line":137},[66,4229,134],{"class":90},[66,4231,4232],{"class":68,"line":143},[66,4233,159],{"emptyLinePlaceholder":158},[66,4235,4236],{"class":68,"line":149},[66,4237,4238],{"class":72},"-- One-to-One: FK that's also the PK (UNIQUE + NOT NULL)\n",[66,4240,4241,4243,4245,4247,4249,4251,4253,4255,4257,4259,4261,4264,4266,4268],{"class":68,"line":155},[66,4242,80],{"class":79},[66,4244,83],{"class":79},[66,4246,1128],{"class":86},[66,4248,4151],{"class":90},[66,4250,185],{"class":79},[66,4252,188],{"class":79},[66,4254,191],{"class":79},[66,4256,194],{"class":79},[66,4258,197],{"class":79},[66,4260,103],{"class":79},[66,4262,4263],{"class":90},", email ",[66,4265,115],{"class":79},[66,4267,967],{"class":79},[66,4269,134],{"class":90},[66,4271,4272,4274,4276,4279],{"class":68,"line":162},[66,4273,80],{"class":79},[66,4275,83],{"class":79},[66,4277,4278],{"class":86}," user_profiles",[66,4280,91],{"class":90},[66,4282,4283,4285,4287,4289,4291,4294],{"class":68,"line":168},[66,4284,1249],{"class":90},[66,4286,185],{"class":79},[66,4288,103],{"class":79},[66,4290,242],{"class":79},[66,4292,4293],{"class":90}," users(id),  ",[66,4295,4296],{"class":72},"-- PK → UNIQUE + NOT NULL → 1:1\n",[66,4298,4299,4302,4304],{"class":68,"line":180},[66,4300,4301],{"class":90},"  bio ",[66,4303,115],{"class":79},[66,4305,106],{"class":90},[66,4307,4308,4311],{"class":68,"line":204},[66,4309,4310],{"class":90},"  avatar_url ",[66,4312,2588],{"class":79},[66,4314,4315],{"class":68,"line":214},[66,4316,134],{"class":90},[66,4318,4319],{"class":68,"line":219},[66,4320,4321],{"class":72},"-- Use 1:1 to: split wide tables, isolate sensitive columns (passwords),\n",[66,4323,4324],{"class":68,"line":231},[66,4325,4326],{"class":72},"-- model optional subclassing (users + admins).\n",[66,4328,4329],{"class":68,"line":253},[66,4330,159],{"emptyLinePlaceholder":158},[66,4332,4333],{"class":68,"line":265},[66,4334,4335],{"class":72},"-- Many-to-Many: junction table with composite PK\n",[66,4337,4338,4340,4342,4345,4347,4349,4351,4353,4355,4357,4359,4361,4363,4365],{"class":68,"line":285},[66,4339,80],{"class":79},[66,4341,83],{"class":79},[66,4343,4344],{"class":86}," students",[66,4346,4151],{"class":90},[66,4348,185],{"class":79},[66,4350,188],{"class":79},[66,4352,191],{"class":79},[66,4354,194],{"class":79},[66,4356,197],{"class":79},[66,4358,103],{"class":79},[66,4360,2546],{"class":90},[66,4362,4168],{"class":79},[66,4364,476],{"class":79},[66,4366,134],{"class":90},[66,4368,4369,4371,4373,4376,4379,4381,4383,4385,4387,4389,4391,4394,4396],{"class":68,"line":294},[66,4370,80],{"class":79},[66,4372,83],{"class":79},[66,4374,4375],{"class":86}," courses",[66,4377,4378],{"class":90},"  (id ",[66,4380,185],{"class":79},[66,4382,188],{"class":79},[66,4384,191],{"class":79},[66,4386,194],{"class":79},[66,4388,197],{"class":79},[66,4390,103],{"class":79},[66,4392,4393],{"class":90},", title ",[66,4395,115],{"class":79},[66,4397,134],{"class":90},[66,4399,4400,4402,4404,4407],{"class":68,"line":299},[66,4401,80],{"class":79},[66,4403,83],{"class":79},[66,4405,4406],{"class":86}," enrollments",[66,4408,91],{"class":90},[66,4410,4411,4414,4416,4418,4420,4423,4425],{"class":68,"line":534},[66,4412,4413],{"class":90},"  student_id ",[66,4415,185],{"class":79},[66,4417,239],{"class":79},[66,4419,242],{"class":79},[66,4421,4422],{"class":90}," students(id) ",[66,4424,248],{"class":79},[66,4426,106],{"class":90},[66,4428,4429,4432,4434,4436,4438,4441,4443],{"class":68,"line":545},[66,4430,4431],{"class":90},"  course_id  ",[66,4433,185],{"class":79},[66,4435,239],{"class":79},[66,4437,242],{"class":79},[66,4439,4440],{"class":90}," courses(id) ",[66,4442,248],{"class":79},[66,4444,106],{"class":90},[66,4446,4447,4450,4452,4454,4456,4458],{"class":68,"line":550},[66,4448,4449],{"class":90},"  enrolled_at ",[66,4451,1182],{"class":79},[66,4453,239],{"class":79},[66,4455,276],{"class":79},[66,4457,1189],{"class":79},[66,4459,2926],{"class":90},[66,4461,4462,4465,4467],{"class":68,"line":556},[66,4463,4464],{"class":90},"  grade ",[66,4466,115],{"class":79},[66,4468,106],{"class":90},[66,4470,4471,4473,4476],{"class":68,"line":1357},[66,4472,288],{"class":79},[66,4474,4475],{"class":90}," (student_id, course_id)  ",[66,4477,4478],{"class":72},"-- prevents duplicate (student, course) pairs\n",[66,4480,4481],{"class":68,"line":1368},[66,4482,134],{"class":90},[66,4484,4485],{"class":68,"line":1379},[66,4486,4487],{"class":72},"-- The junction table can carry relationship attributes (enrolled_at, grade).\n",[23,4489,4491],{"id":4490},"hierarchical-data-adjacency-list-recursive-cte","Hierarchical Data — adjacency list + recursive CTE",[28,4493,4494],{"language":58},[32,4495,4497],{"className":61,"code":4496,"language":58,"meta":37,"style":37},"-- Adjacency list: each row has parent_id. Simple to maintain, query\n-- subtrees with a recursive CTE (chapter 19).\nCREATE TABLE categories (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  parent_id BIGINT REFERENCES categories(id),  -- NULL = root\n  name TEXT NOT NULL\n);\nCREATE INDEX categories_parent_idx ON categories(parent_id);\n\n-- Recursive CTE to get a subtree:\nWITH RECURSIVE subtree AS (\n  SELECT id, name, parent_id, 0 AS depth\n  FROM categories WHERE id = 1                  -- start node\n  UNION ALL\n  SELECT c.id, c.name, c.parent_id, s.depth + 1\n  FROM categories c\n  JOIN subtree s ON c.parent_id = s.id          -- recurse up the tree\n)\nSELECT * FROM subtree ORDER BY depth, name;\n-- Other options: path enumeration (\u002F1\u002F4\u002F7\u002F), nested sets (lft\u002Frgt),\n-- materialized path (ARRAY[1,4,7]). Adjacency list + recursive CTE\n-- is the simplest and most flexible for most use cases.\n",[39,4498,4499,4504,4509,4519,4537,4551,4559,4563,4577,4581,4586,4601,4621,4640,4645,4690,4697,4725,4729,4749,4754,4759],{"__ignoreMap":37},[66,4500,4501],{"class":68,"line":69},[66,4502,4503],{"class":72},"-- Adjacency list: each row has parent_id. Simple to maintain, query\n",[66,4505,4506],{"class":68,"line":76},[66,4507,4508],{"class":72},"-- subtrees with a recursive CTE (chapter 19).\n",[66,4510,4511,4513,4515,4517],{"class":68,"line":94},[66,4512,80],{"class":79},[66,4514,83],{"class":79},[66,4516,1434],{"class":86},[66,4518,91],{"class":90},[66,4520,4521,4523,4525,4527,4529,4531,4533,4535],{"class":68,"line":109},[66,4522,97],{"class":90},[66,4524,185],{"class":79},[66,4526,188],{"class":79},[66,4528,191],{"class":79},[66,4530,194],{"class":79},[66,4532,197],{"class":79},[66,4534,103],{"class":79},[66,4536,106],{"class":90},[66,4538,4539,4541,4543,4545,4548],{"class":68,"line":120},[66,4540,1461],{"class":90},[66,4542,185],{"class":79},[66,4544,242],{"class":79},[66,4546,4547],{"class":90}," categories(id),  ",[66,4549,4550],{"class":72},"-- NULL = root\n",[66,4552,4553,4555,4557],{"class":68,"line":131},[66,4554,473],{"class":79},[66,4556,476],{"class":79},[66,4558,211],{"class":79},[66,4560,4561],{"class":68,"line":137},[66,4562,134],{"class":90},[66,4564,4565,4567,4569,4572,4574],{"class":68,"line":143},[66,4566,80],{"class":79},[66,4568,3293],{"class":79},[66,4570,4571],{"class":86}," categories_parent_idx",[66,4573,3299],{"class":79},[66,4575,4576],{"class":90}," categories(parent_id);\n",[66,4578,4579],{"class":68,"line":149},[66,4580,159],{"emptyLinePlaceholder":158},[66,4582,4583],{"class":68,"line":155},[66,4584,4585],{"class":72},"-- Recursive CTE to get a subtree:\n",[66,4587,4588,4591,4594,4597,4599],{"class":68,"line":162},[66,4589,4590],{"class":79},"WITH",[66,4592,4593],{"class":79}," RECURSIVE",[66,4595,4596],{"class":90}," subtree ",[66,4598,2635],{"class":79},[66,4600,91],{"class":90},[66,4602,4603,4605,4608,4610,4613,4616,4618],{"class":68,"line":168},[66,4604,3103],{"class":79},[66,4606,4607],{"class":90}," id, ",[66,4609,4168],{"class":79},[66,4611,4612],{"class":90},", parent_id, ",[66,4614,4615],{"class":279},"0",[66,4617,194],{"class":79},[66,4619,4620],{"class":90}," depth\n",[66,4622,4623,4625,4628,4630,4633,4635,4637],{"class":68,"line":180},[66,4624,3154],{"class":79},[66,4626,4627],{"class":90}," categories ",[66,4629,2706],{"class":79},[66,4631,4632],{"class":90}," id ",[66,4634,2817],{"class":79},[66,4636,280],{"class":279},[66,4638,4639],{"class":72},"                  -- start node\n",[66,4641,4642],{"class":68,"line":204},[66,4643,4644],{"class":79},"  UNION ALL\n",[66,4646,4647,4649,4652,4654,4656,4658,4661,4663,4665,4667,4669,4671,4674,4676,4679,4681,4684,4687],{"class":68,"line":214},[66,4648,3103],{"class":79},[66,4650,4651],{"class":279}," c",[66,4653,2612],{"class":90},[66,4655,3569],{"class":279},[66,4657,2546],{"class":90},[66,4659,4660],{"class":279},"c",[66,4662,2612],{"class":90},[66,4664,4168],{"class":279},[66,4666,2546],{"class":90},[66,4668,4660],{"class":279},[66,4670,2612],{"class":90},[66,4672,4673],{"class":279},"parent_id",[66,4675,2546],{"class":90},[66,4677,4678],{"class":279},"s",[66,4680,2612],{"class":90},[66,4682,4683],{"class":279},"depth",[66,4685,4686],{"class":79}," +",[66,4688,4689],{"class":279}," 1\n",[66,4691,4692,4694],{"class":68,"line":219},[66,4693,3154],{"class":79},[66,4695,4696],{"class":90}," categories c\n",[66,4698,4699,4702,4705,4707,4709,4711,4713,4715,4718,4720,4722],{"class":68,"line":231},[66,4700,4701],{"class":79},"  JOIN",[66,4703,4704],{"class":90}," subtree s ",[66,4706,2657],{"class":79},[66,4708,4651],{"class":279},[66,4710,2612],{"class":90},[66,4712,4673],{"class":279},[66,4714,2668],{"class":79},[66,4716,4717],{"class":279}," s",[66,4719,2612],{"class":90},[66,4721,3569],{"class":279},[66,4723,4724],{"class":72},"          -- recurse up the tree\n",[66,4726,4727],{"class":68,"line":253},[66,4728,2430],{"class":90},[66,4730,4731,4733,4735,4737,4739,4742,4745,4747],{"class":68,"line":265},[66,4732,2606],{"class":79},[66,4734,3771],{"class":79},[66,4736,3539],{"class":79},[66,4738,4596],{"class":90},[66,4740,4741],{"class":79},"ORDER BY",[66,4743,4744],{"class":90}," depth, ",[66,4746,4168],{"class":79},[66,4748,2755],{"class":90},[66,4750,4751],{"class":68,"line":285},[66,4752,4753],{"class":72},"-- Other options: path enumeration (\u002F1\u002F4\u002F7\u002F), nested sets (lft\u002Frgt),\n",[66,4755,4756],{"class":68,"line":294},[66,4757,4758],{"class":72},"-- materialized path (ARRAY[1,4,7]). Adjacency list + recursive CTE\n",[66,4760,4761],{"class":68,"line":299},[66,4762,4763],{"class":72},"-- is the simplest and most flexible for most use cases.\n",[23,4765,4767],{"id":4766},"tips-tricks","💡 Tips & Tricks",[4769,4770,4771,4786,4802,4822,4835,4846,4858,4879],"ul",{},[4772,4773,4774,4777,4778,4781,4782,4785],"li",{},[18,4775,4776],{},"Idiom",": aim for ",[18,4779,4780],{},"3NF\u002FBCNF by default"," — each fact stored once, no anomalies. Denormalize ",[327,4783,4784],{},"deliberately",", for a measured read-performance gain, with a consistency mechanism (trigger, generated column, materialized view, scheduled refresh). Untracked denormalization corrupts data.",[4772,4787,4788,4790,4791,1831,4794,4797,4798,4801],{},[18,4789,4776],{},": prefer ",[18,4792,4793],{},"surrogate keys",[39,4795,4796],{},"BIGINT IDENTITY"," or ",[39,4799,4800],{},"UUID",") over natural keys — natural keys that change (email, ISBN) force cascading updates across every referencing FK, and \"immutable\" natural keys have a habit of turning mutable. Use natural keys only for genuinely stable, unique identifiers (country codes).",[4772,4803,4804,4806,4807,4810,4811,4814,4815,2546,4818,4821],{},[18,4805,4776],{},": use a ",[18,4808,4809],{},"junction table"," for many-to-many relationships, with a composite PK ",[39,4812,4813],{},"(a_id, b_id)"," — this enforces uniqueness (no duplicate pairs) and gives you a place to put relationship attributes (",[39,4816,4817],{},"enrolled_at",[39,4819,4820],{},"role","). Don't model M:N with a delimited string.",[4772,4823,4824,4826,4827,4830,4831,4834],{},[18,4825,4776],{},": separate ",[18,4828,4829],{},"transactional (normalized, OLTP)"," from ",[18,4832,4833],{},"analytical (denormalized, OLAP)"," schemas — they have different access patterns (writes vs reads, integrity vs aggregation). Use a star schema (fact + dimension tables) for analytics. Don't mix them in the same schema.",[4772,4836,4837,4839,4840,1831,4843,4845],{},[18,4838,4776],{},": model hierarchical data with an ",[18,4841,4842],{},"adjacency list",[39,4844,4673],{}," FK) + recursive CTEs for traversal — it's the simplest, most flexible approach. Reserve path enumeration \u002F nested sets for read-heavy trees that rarely change.",[4772,4847,4848,4806,4850,4853,4854,4857],{},[18,4849,4776],{},[18,4851,4852],{},"view layer"," for denormalization in read-heavy systems — create a view that joins the normalized tables, grant access on the view, revoke on base tables. The view is always consistent (computed on read), no sync mechanism needed. Materialize it (",[39,4855,4856],{},"CREATE MATERIALIZED VIEW",") if the joins are expensive.",[4772,4859,4860,4862,4863,4866,4867,4870,4871,4874,4875,4878],{},[18,4861,4776],{},": store ",[18,4864,4865],{},"price snapshots"," in order\u002Fline-item tables — ",[39,4868,4869],{},"unit_price"," in ",[39,4872,4873],{},"order_items"," is the price at order time, not a FK to ",[39,4876,4877],{},"products.price",". The current price changes; historical orders shouldn't. This is deliberate, documented denormalization for historical accuracy.",[4772,4880,4881,4883,4884,4887],{},[18,4882,4776],{},": use ",[18,4885,4886],{},"SCD Type 2"," (valid_from\u002Fvalid_to) for dimension history in analytics — when a user moves cities or a product is reclassified, close the old dimension row and open a new one. Fact records point to the correct version via a surrogate key.",[23,4889,4891],{"id":4890},"️-edge-cases-gotchas","⚠️ Edge Cases & Gotchas",[4769,4893,4894,4900,4911,4917,4923,4937,4953,4965,4971,4977,4991,5011],{},[4772,4895,4896,4899],{},[18,4897,4898],{},"Over-normalization",": splitting data into too many tiny tables makes every query a 5-table join. Normalization is a tool, not a religion — 3NF is usually enough; 5NF\u002F6NF rarely improve anything practical and can hurt read performance.",[4772,4901,4902,308,4905,4907,4908,4910],{},[18,4903,4904],{},"1NF and PostgreSQL arrays",[39,4906,311],{}," and ",[39,4909,315],{}," columns are a deliberate denormalization — acceptable when the list is atomic from the application's perspective (tags, variable attributes). 1NF is a guideline for reducing redundancy, not a hard rule against composite types.",[4772,4912,4913,4916],{},[18,4914,4915],{},"BCNF vs 3NF difference",": BCNF is stricter — every determinant must be a candidate key. 3NF allows a non-key determinant if it's not a superkey for a different dependency. The difference matters only with overlapping candidate keys — rare in practice.",[4772,4918,4919,4922],{},[18,4920,4921],{},"Surrogate key vs natural key in replication",": surrogate keys (bigint\u002FUUID) replicate cleanly (no collision across nodes). Natural keys (email) may collide if two nodes insert the same email independently before syncing. For distributed\u002Fmulti-master systems, use UUIDs.",[4772,4924,4925,4928,4929,4932,4933,4936],{},[18,4926,4927],{},"Denormalization consistency maintenance",": copying ",[39,4930,4931],{},"customer_name"," into ",[39,4934,4935],{},"orders"," without a trigger means a customer rename leaves stale names in orders. Always pair denormalization with a trigger, generated column, materialized view, or scheduled refresh. Untracked denormalization = data corruption.",[4772,4938,4939,4942,4943,4946,4947,4949,4950,4952],{},[18,4940,4941],{},"EAV anti-pattern",": \"flexible\" ",[39,4944,4945],{},"(entity, attribute, value)"," tables destroy typing, defeat indexes, and make queries a mess of self-joins. Use ",[39,4948,315],{}," for variable attributes — it's typed, indexable (GIN), and queryable (",[39,4951,3786],{},").",[4772,4954,4955,308,4958,4961,4962,2612],{},[18,4956,4957],{},"Polymorphic associations break FKs",[39,4959,4960],{},"commentable_type + commentable_id"," can't have a real FK (the id could reference any of several tables). Use per-type tables or nullable FKs with ",[39,4963,4964],{},"CHECK (num_nonnull(...) = 1)",[4772,4966,4967,4970],{},[18,4968,4969],{},"NULL and normalization",": NULL is not a value — it's the absence of one. A column full of NULLs isn't a normalization violation, but it may indicate a missing entity (consider splitting into a 1:1 table for the optional attributes).",[4772,4972,4973,4976],{},[18,4974,4975],{},"Multi-valued dependencies (4NF)",": if a table has two independent multi-valued attributes (e.g., a teacher's skills and courses, where skills and courses are independent), storing them in one table causes combinatorial explosion. Split into two tables. Rare in practice.",[4772,4978,4979,4982,4983,4986,4987,4990],{},[18,4980,4981],{},"Soft deletes and FKs",": a ",[39,4984,4985],{},"deleted_at"," column (soft delete) doesn't remove the row, so FKs still point to it. Queries must filter ",[39,4988,4989],{},"WHERE deleted_at IS NULL"," everywhere — a common source of \"I forgot to filter soft-deleted rows\" bugs. Consider RLS or a view to enforce the filter.",[4772,4992,4993,308,4996,4999,5000,5003,5004,5007,5008,5010],{},[18,4994,4995],{},"Composite PK column order",[39,4997,4998],{},"(tenant_id, id)"," serves ",[39,5001,5002],{},"WHERE tenant_id = ?"," (leftmost prefix) but not ",[39,5005,5006],{},"WHERE id = ?"," alone. Order by query pattern and selectivity. For B-tree indexes, the leftmost column must be in the ",[39,5009,2706],{}," clause for the index to be used.",[4772,5012,5013,5016],{},[18,5014,5015],{},"God table anti-pattern",": one table with 200 columns covering multiple entity types. Split it. If different rows use different subsets of columns (mostly NULL), that's a missing-entity smell — extract the optional columns into a 1:1 table.",[23,5018,5020],{"id":5019},"spot-the-bug","🧠 Spot the Bug",[14,5022,5023],{},"A team stores product categories as a delimited string to \"avoid a join table\":",[28,5025,5026],{"language":58},[32,5027,5029],{"className":61,"code":5028,"language":58,"meta":37,"style":37},"CREATE TABLE products (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL,\n  categories TEXT NOT NULL   -- 'electronics,tech,accessories'\n);\n",[39,5030,5031,5041,5059,5069,5081],{"__ignoreMap":37},[66,5032,5033,5035,5037,5039],{"class":68,"line":69},[66,5034,80],{"class":79},[66,5036,83],{"class":79},[66,5038,448],{"class":86},[66,5040,91],{"class":90},[66,5042,5043,5045,5047,5049,5051,5053,5055,5057],{"class":68,"line":76},[66,5044,97],{"class":90},[66,5046,185],{"class":79},[66,5048,188],{"class":79},[66,5050,191],{"class":79},[66,5052,194],{"class":79},[66,5054,197],{"class":79},[66,5056,103],{"class":79},[66,5058,106],{"class":90},[66,5060,5061,5063,5065,5067],{"class":68,"line":94},[66,5062,473],{"class":79},[66,5064,476],{"class":79},[66,5066,239],{"class":79},[66,5068,106],{"class":90},[66,5070,5071,5074,5076,5078],{"class":68,"line":109},[66,5072,5073],{"class":90},"  categories ",[66,5075,115],{"class":79},[66,5077,239],{"class":79},[66,5079,5080],{"class":72},"   -- 'electronics,tech,accessories'\n",[66,5082,5083],{"class":68,"line":120},[66,5084,134],{"class":90},[14,5086,5087],{},"They need a report of all products in the \"tech\" category. What's wrong, and what's the fix?",[5089,5090,5091,5095,5098,5159,5162,5456,5462,5569],"details",{},[5092,5093,5094],"summary",{},"Answer",[14,5096,5097],{},"Multiple problems:",[5099,5100,5101,5115,5128,5144,5153],"ol",{},[4772,5102,5103,5106,5107,5110,5111,5114],{},[18,5104,5105],{},"Can't index for category lookup"," — ",[39,5108,5109],{},"WHERE categories LIKE '%tech%'"," does a full table scan. A B-tree index on ",[39,5112,5113],{},"categories"," can't help a substring search.",[4772,5116,5117,5106,5120,5123,5124,5127],{},[18,5118,5119],{},"False positives",[39,5121,5122],{},"LIKE '%tech%'"," matches \"biotech,\" \"techwear.\" Even delimiter-aware logic (",[39,5125,5126],{},"WHERE ',' || categories || ',' LIKE '%,tech,%'",") is fragile and can't use an index.",[4772,5129,5130,5133,5134,2546,5137,2546,5140,5143],{},[18,5131,5132],{},"No integrity"," — nothing prevents typos: ",[39,5135,5136],{},"'tech'",[39,5138,5139],{},"'Tech'",[39,5141,5142],{},"'TECH'"," are three \"different\" categories. No FK, no enum, no validation.",[4772,5145,5146,5149,5150,5152],{},[18,5147,5148],{},"Update anomalies"," — renaming \"tech\" → \"technology\" requires updating every product's ",[39,5151,5113],{}," string, with no guarantee of consistency.",[4772,5154,5155,5158],{},[18,5156,5157],{},"No metadata"," — the category has no attributes (description, parent category) because it's not an entity.",[14,5160,5161],{},"The fix — normalize with a junction table:",[28,5163,5164],{"language":58},[32,5165,5167],{"className":61,"code":5166,"language":58,"meta":37,"style":37},"CREATE TABLE categories (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL UNIQUE,            -- integrity: no duplicates\n  parent_id BIGINT REFERENCES categories(id)  -- hierarchy\n);\n\nCREATE TABLE products (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL\n);\n\nCREATE TABLE product_categories (\n  product_id  BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,\n  category_id BIGINT NOT NULL REFERENCES categories(id) ON DELETE CASCADE,\n  PRIMARY KEY (product_id, category_id)  -- no duplicate pairs\n);\n\nCREATE INDEX product_categories_cat_idx ON product_categories(category_id);\n-- Index on category_id → fast \"products in category X\" lookup\n\n-- Query: products in \"tech\" — indexable, no false positives, has integrity\nSELECT p.* FROM products p\nJOIN product_categories pc ON pc.product_id = p.id\nJOIN categories c ON c.id = pc.category_id\nWHERE c.name = 'tech';\n-- This is a 2-join query, but both joins use indexes (PK + the cat_idx).\n-- Renaming \"tech\" → \"technology\" is a one-row UPDATE on categories.\n",[39,5168,5169,5179,5197,5212,5226,5230,5234,5244,5262,5270,5274,5278,5289,5306,5322,5332,5336,5340,5354,5359,5363,5368,5380,5405,5429,5446,5451],{"__ignoreMap":37},[66,5170,5171,5173,5175,5177],{"class":68,"line":69},[66,5172,80],{"class":79},[66,5174,83],{"class":79},[66,5176,1434],{"class":86},[66,5178,91],{"class":90},[66,5180,5181,5183,5185,5187,5189,5191,5193,5195],{"class":68,"line":76},[66,5182,97],{"class":90},[66,5184,185],{"class":79},[66,5186,188],{"class":79},[66,5188,191],{"class":79},[66,5190,194],{"class":79},[66,5192,197],{"class":79},[66,5194,103],{"class":79},[66,5196,106],{"class":90},[66,5198,5199,5201,5203,5205,5207,5209],{"class":68,"line":94},[66,5200,473],{"class":79},[66,5202,476],{"class":79},[66,5204,239],{"class":79},[66,5206,967],{"class":79},[66,5208,2269],{"class":90},[66,5210,5211],{"class":72},"-- integrity: no duplicates\n",[66,5213,5214,5216,5218,5220,5223],{"class":68,"line":109},[66,5215,1461],{"class":90},[66,5217,185],{"class":79},[66,5219,242],{"class":79},[66,5221,5222],{"class":90}," categories(id)  ",[66,5224,5225],{"class":72},"-- hierarchy\n",[66,5227,5228],{"class":68,"line":120},[66,5229,134],{"class":90},[66,5231,5232],{"class":68,"line":131},[66,5233,159],{"emptyLinePlaceholder":158},[66,5235,5236,5238,5240,5242],{"class":68,"line":137},[66,5237,80],{"class":79},[66,5239,83],{"class":79},[66,5241,448],{"class":86},[66,5243,91],{"class":90},[66,5245,5246,5248,5250,5252,5254,5256,5258,5260],{"class":68,"line":143},[66,5247,97],{"class":90},[66,5249,185],{"class":79},[66,5251,188],{"class":79},[66,5253,191],{"class":79},[66,5255,194],{"class":79},[66,5257,197],{"class":79},[66,5259,103],{"class":79},[66,5261,106],{"class":90},[66,5263,5264,5266,5268],{"class":68,"line":149},[66,5265,473],{"class":79},[66,5267,476],{"class":79},[66,5269,211],{"class":79},[66,5271,5272],{"class":68,"line":155},[66,5273,134],{"class":90},[66,5275,5276],{"class":68,"line":162},[66,5277,159],{"emptyLinePlaceholder":158},[66,5279,5280,5282,5284,5287],{"class":68,"line":168},[66,5281,80],{"class":79},[66,5283,83],{"class":79},[66,5285,5286],{"class":86}," product_categories",[66,5288,91],{"class":90},[66,5290,5291,5294,5296,5298,5300,5302,5304],{"class":68,"line":180},[66,5292,5293],{"class":90},"  product_id  ",[66,5295,185],{"class":79},[66,5297,239],{"class":79},[66,5299,242],{"class":79},[66,5301,1697],{"class":90},[66,5303,248],{"class":79},[66,5305,106],{"class":90},[66,5307,5308,5310,5312,5314,5316,5318,5320],{"class":68,"line":204},[66,5309,1561],{"class":90},[66,5311,185],{"class":79},[66,5313,239],{"class":79},[66,5315,242],{"class":79},[66,5317,1468],{"class":90},[66,5319,248],{"class":79},[66,5321,106],{"class":90},[66,5323,5324,5326,5329],{"class":68,"line":214},[66,5325,288],{"class":79},[66,5327,5328],{"class":90}," (product_id, category_id)  ",[66,5330,5331],{"class":72},"-- no duplicate pairs\n",[66,5333,5334],{"class":68,"line":219},[66,5335,134],{"class":90},[66,5337,5338],{"class":68,"line":231},[66,5339,159],{"emptyLinePlaceholder":158},[66,5341,5342,5344,5346,5349,5351],{"class":68,"line":253},[66,5343,80],{"class":79},[66,5345,3293],{"class":79},[66,5347,5348],{"class":86}," product_categories_cat_idx",[66,5350,3299],{"class":79},[66,5352,5353],{"class":90}," product_categories(category_id);\n",[66,5355,5356],{"class":68,"line":265},[66,5357,5358],{"class":72},"-- Index on category_id → fast \"products in category X\" lookup\n",[66,5360,5361],{"class":68,"line":285},[66,5362,159],{"emptyLinePlaceholder":158},[66,5364,5365],{"class":68,"line":294},[66,5366,5367],{"class":72},"-- Query: products in \"tech\" — indexable, no false positives, has integrity\n",[66,5369,5370,5372,5374,5376,5378],{"class":68,"line":299},[66,5371,2606],{"class":79},[66,5373,3534],{"class":90},[66,5375,3129],{"class":79},[66,5377,3539],{"class":79},[66,5379,3542],{"class":90},[66,5381,5382,5384,5387,5389,5392,5394,5396,5398,5400,5402],{"class":68,"line":534},[66,5383,2651],{"class":79},[66,5385,5386],{"class":90}," product_categories pc ",[66,5388,2657],{"class":79},[66,5390,5391],{"class":279}," pc",[66,5393,2612],{"class":90},[66,5395,3559],{"class":279},[66,5397,2668],{"class":79},[66,5399,3564],{"class":279},[66,5401,2612],{"class":90},[66,5403,5404],{"class":279},"id\n",[66,5406,5407,5409,5412,5414,5416,5418,5420,5422,5424,5426],{"class":68,"line":545},[66,5408,2651],{"class":79},[66,5410,5411],{"class":90}," categories c ",[66,5413,2657],{"class":79},[66,5415,4651],{"class":279},[66,5417,2612],{"class":90},[66,5419,3569],{"class":279},[66,5421,2668],{"class":79},[66,5423,5391],{"class":279},[66,5425,2612],{"class":90},[66,5427,5428],{"class":279},"category_id\n",[66,5430,5431,5433,5435,5437,5439,5441,5444],{"class":68,"line":550},[66,5432,2706],{"class":79},[66,5434,4651],{"class":279},[66,5436,2612],{"class":90},[66,5438,4168],{"class":279},[66,5440,2668],{"class":79},[66,5442,5443],{"class":1310}," 'tech'",[66,5445,2755],{"class":90},[66,5447,5448],{"class":68,"line":556},[66,5449,5450],{"class":72},"-- This is a 2-join query, but both joins use indexes (PK + the cat_idx).\n",[66,5452,5453],{"class":68,"line":1357},[66,5454,5455],{"class":72},"-- Renaming \"tech\" → \"technology\" is a one-row UPDATE on categories.\n",[14,5457,5458,5459,5461],{},"For a simpler variant if you don't need category metadata, a PostgreSQL ",[39,5460,311],{}," array with a GIN index is a reasonable middle ground:",[28,5463,5464],{"language":58},[32,5465,5467],{"className":61,"code":5466,"language":58,"meta":37,"style":37},"CREATE TABLE products (\n  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  name TEXT NOT NULL,\n  categories TEXT[] NOT NULL DEFAULT '{}'  -- ARRAY['electronics', 'tech']\n);\nCREATE INDEX products_categories_idx ON products USING GIN (categories);\nSELECT * FROM products WHERE 'tech' = ANY(categories);  -- uses the GIN index\n",[39,5468,5469,5479,5497,5507,5525,5529,5547],{"__ignoreMap":37},[66,5470,5471,5473,5475,5477],{"class":68,"line":69},[66,5472,80],{"class":79},[66,5474,83],{"class":79},[66,5476,448],{"class":86},[66,5478,91],{"class":90},[66,5480,5481,5483,5485,5487,5489,5491,5493,5495],{"class":68,"line":76},[66,5482,97],{"class":90},[66,5484,185],{"class":79},[66,5486,188],{"class":79},[66,5488,191],{"class":79},[66,5490,194],{"class":79},[66,5492,197],{"class":79},[66,5494,103],{"class":79},[66,5496,106],{"class":90},[66,5498,5499,5501,5503,5505],{"class":68,"line":94},[66,5500,473],{"class":79},[66,5502,476],{"class":79},[66,5504,239],{"class":79},[66,5506,106],{"class":90},[66,5508,5509,5511,5513,5516,5518,5520,5522],{"class":68,"line":109},[66,5510,5073],{"class":90},[66,5512,115],{"class":79},[66,5514,5515],{"class":90},"[] ",[66,5517,1605],{"class":79},[66,5519,276],{"class":79},[66,5521,3274],{"class":1310},[66,5523,5524],{"class":72},"  -- ARRAY['electronics', 'tech']\n",[66,5526,5527],{"class":68,"line":120},[66,5528,134],{"class":90},[66,5530,5531,5533,5535,5538,5540,5542,5544],{"class":68,"line":131},[66,5532,80],{"class":79},[66,5534,3293],{"class":79},[66,5536,5537],{"class":86}," products_categories_idx",[66,5539,3299],{"class":79},[66,5541,3302],{"class":90},[66,5543,3305],{"class":79},[66,5545,5546],{"class":90}," GIN (categories);\n",[66,5548,5549,5551,5553,5555,5557,5559,5561,5563,5566],{"class":68,"line":137},[66,5550,2606],{"class":79},[66,5552,3771],{"class":79},[66,5554,3539],{"class":79},[66,5556,3302],{"class":90},[66,5558,2706],{"class":79},[66,5560,5443],{"class":1310},[66,5562,2668],{"class":79},[66,5564,5565],{"class":90}," ANY(categories);  ",[66,5567,5568],{"class":72},"-- uses the GIN index\n",[14,5570,5571,5574],{},[18,5572,5573],{},"The lesson",": delimited strings for multi-valued attributes defeat indexes, integrity, and queries. Use a junction table (normalized, with FKs and metadata) or an array column with a GIN index (PostgreSQL, lighter, no metadata).",[5576,5577,5578],"style",{},"html pre.shiki code .sdCPZ, html code.shiki .sdCPZ{--shiki-default:#6A737D;--shiki-github-dark:#6A737D}html pre.shiki code .svdQ7, html code.shiki .svdQ7{--shiki-default:#D73A49;--shiki-github-dark:#F97583}html pre.shiki code .sIsaT, html code.shiki .sIsaT{--shiki-default:#6F42C1;--shiki-github-dark:#B392F0}html pre.shiki code .ssxIu, html code.shiki .ssxIu{--shiki-default:#24292E;--shiki-github-dark:#E1E4E8}html pre.shiki code .snvgF, html code.shiki .snvgF{--shiki-default:#005CC5;--shiki-github-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html.github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html pre.shiki code .sJ6F3, html code.shiki .sJ6F3{--shiki-default:#032F62;--shiki-github-dark:#9ECBFF}",{"title":37,"searchDepth":76,"depth":76,"links":5580},[5581,5582,5589,5590,5591,5595,5596,5597,5598,5599,5600,5601,5602,5603],{"id":25,"depth":76,"text":26},{"id":43,"depth":76,"text":44,"children":5583},[5584,5585,5586,5587,5588],{"id":48,"depth":94,"text":49},{"id":319,"depth":94,"text":320},{"id":562,"depth":94,"text":563},{"id":793,"depth":94,"text":794},{"id":878,"depth":94,"text":879},{"id":889,"depth":76,"text":890},{"id":910,"depth":76,"text":911},{"id":1089,"depth":76,"text":1090,"children":5592},[5593,5594],{"id":2093,"depth":94,"text":2094},{"id":2773,"depth":94,"text":2774},{"id":3061,"depth":76,"text":3062},{"id":3377,"depth":76,"text":3378},{"id":3462,"depth":76,"text":3463},{"id":3835,"depth":76,"text":3836},{"id":4127,"depth":76,"text":4128},{"id":4490,"depth":76,"text":4491},{"id":4766,"depth":76,"text":4767},{"id":4890,"depth":76,"text":4891},{"id":5019,"depth":76,"text":5020},"Why normalize (anomaly elimination), 1NF–BCNF with code examples, denormalization tradeoffs (read performance vs write complexity), surrogate vs natural keys, star schema for analytics, slowly changing dimensions — code-first reference with a normalized e-commerce schema and anti-patterns for premature denormalization and EAV.","md",{},"\u002Fsql\u002F25-normalization",{"title":5,"description":5604},"sql\u002F25-normalization","bTJbw4fA3ZjynX1qq-urdpVq0dF0jL5YwHeuUPH1FY8",1789924655028]