Skip to content
Louvre Ops DB

Design review

What the 2020 model got right, and wrong

Diagrams are easy to defend and hard to test. So the 2020 model was forward-engineered and run on a real MySQL server, filled by replaying five years of synthetic activity, and checked against every question in the brief. Each finding below cites that evidence and says how the refined schema fixes it.

tables MySQL would create
13 / 16
MySQL 26.7.0 refused Invoices, Payment Methods and Booking Ticket.
of five years' rows the tables could keep
68%
On forgiving SQLite: 20,609 of 64,130 rows refused by a key, NOT NULL or foreign key. On MySQL, 9%.
questions answerable · partly · not
6 · 2 · 2
Of the ten business questions in the brief.
findings
12
6 things it got right, too.

Fair is fair

What held up

For a first ER model, written in the fifth week of the subject, a good deal of it is sound.

  • Card details kept, card secrets not

    Card and cash payments live in separate tables, and there is no column for a CCV or a full card number, exactly as the brief demanded.

  • Fixed lists are enforced

    Entrances, wings, the 13 audio-guide languages, transport modes and payment types are all ENUMs, so a typo in the data is impossible (a typo in the list itself is another matter).

  • A real device register

    Audio Guide Device is keyed by the 16-character serial number and kept apart from hires, so a device's history survives across many visitors.

  • Hires carry everything question 4 needs

    Language, issuing and returning wing, issue and return time are all on the hire. For hires whose device could be registered, question 4 gives exactly the refined schema's answer; the hires of guides still in service are lost with their device (finding below).

  • One sales record for everything

    Tickets, audio guides and the app all go through Invoices and Product (assumption 1), rather than three unrelated purchase tables.

  • Exhibitions kept apart from general entry

    Bookings, time slots and Hall Napoleon admissions are separate entities, which is the right instinct even where the keys went wrong.

Findings

12 problems, most serious first

Impact says what each problem costs: whether the schema can be created at all, whether real activity fits in it, whether a business question can be answered, or whether it just makes the model harder to trust.

  1. Blocks creationPayment Methods

    Payment Methods has optional columns in its primary key

    The key is (Payment Method ID, EFTPOS_EFTPOS ID, Cash_Cash ID), but every payment is either a card or cash, so one of the last two is always empty. A primary key cannot contain an empty value, and nothing stops a row from having both or neither.

    • MySQLMySQL 26.7.0: CREATE TABLE Payment Methods failed with ERROR 1171: All parts of a PRIMARY KEY must be NOT NULL; if you need NULL in a key, use UNIQUE instead
    • MySQLProbe: recording a cash payment fails because the table does not exist.

    The 2026 fix

    One payment row per payment with a method column, and card_payment / cash_payment subtype tables that share its key. Triggers check that exactly one subtype exists and that it matches the method.

  2. Blocks creationTicket · Invoices · Booking Ticket · Exhibition Booking

    Foreign keys point at part of a composite key

    Ticket refers to Invoices by Invoice ID alone, but Invoices is keyed by (Invoice ID, Product ID). Booking Ticket refers to Exhibition Booking by Booking ID alone, but that table is keyed by three columns. A foreign key should identify exactly one parent row.

    • MySQLMySQL 26.7.0: CREATE TABLE Invoices failed with ERROR 6125: Failed to add the foreign key constraint. Missing unique key for constraint 'fk_Ticket_Invoices1' in the referenced table 'Invoices'
    • MySQLMySQL 26.7.0: CREATE TABLE Booking Ticket failed with ERROR 6125: Failed to add the foreign key constraint. Missing unique key for constraint 'fk_Booking Ticket_Exhibition Booking1' in the referenced table 'Exhibition Booking'
    • MySQLProbe: with foreign-key checks on, storing any ticket fails with ERROR 1452: Cannot add or update a child row: a foreign key constraint fails, because Ticket's key points at the Invoices table that was never created.
    • ReplayReplay: so on MySQL the tables could keep 5,473 of 64,130 rows; nothing that refers to a ticket can be stored.
    • MySQLMySQL 8.0, current in 2020, accepted keys like these as an InnoDB extension, so this was a latent problem then; MySQL 8.4 and later reject them by default. Run again with restrict_fk_on_non_standard_key off (the 8.0 behaviour), 15 of 16 tables are created and only Payment Methods fails.

    The 2026 fix

    Every table has a single-column key and every foreign key references a whole key: tickets point at purchase_order.order_id, bookings have their own booking_id.

  3. Blocks creationInvoices · Payment Methods

    A foreign key joins text to a number

    Invoices.Payment Method_Payment ID is VARCHAR(45) but refers to Payment Methods.Payment Method ID, an INT. MySQL never reached this error because both tables had already failed, but it would be the next one.

    • ModelRead from the model: column types of fk_Invoices_Payment Method1.

    The 2026 fix

    purchase_order.payment_id is an INTEGER referencing payment.payment_id.

  4. Loses dataEntrance · Ticket

    Re-entry through the same gate cannot be recorded

    Entrance is keyed by (Gate Name, Ticket ID), so a visitor who leaves and comes back through the same gate on the same day breaks the key. The brief explicitly allows re-entry. Transport is stored on Ticket rather than on the first entry, and the Business Day flag repeats what the date already says.

    • ReplayReplay: 276 of 9,738 entrance scans rejected (same-gate re-entries).
    • MySQLProbe: a same-gate re-entry fails with ERROR 1062: Duplicate entry 'Pyramid-1' for key 'entrance.PRIMARY'.

    The 2026 fix

    entry_scan has its own key. An is_first_entry flag, a CHECK and a partial unique index make the first scan of a ticket the only one with a transport mode; a trigger rejects re-entry on a later day.

  5. Loses dataAudio Guide Device · Hired Audio Guide

    Audio guides still in service cannot be registered

    Retired Date is NOT NULL, but a device that is still in use has no retirement date yet. The only way to store it is to invent one. The loss cascades: every hire of an unregistered device breaks its foreign key, so the hires are refused too, and question 4 can only be answered for guides that have already been retired.

    • ReplayReplay: 43 of 56 devices rejected because they were still in service.
    • ReplayReplay: 748 of 1,085 audio-guide hires then rejected by fk_Hiring Audio Guide_Audio Guide Device1, because their device was never stored.
    • MySQLProbe: ERROR 1048: Column 'Retired Date' cannot be null.

    The 2026 fix

    retired_on is nullable, with a CHECK that it is not before activated_on; a trigger refuses to hire out a device that is retired or already out.

  6. Loses dataEFTPOS

    The masked card number is stored as an integer

    The brief keeps only the first and last four digits of a card, written like 0198...8822, plus an expiry month and year. Card Number is INT(8) (the 8 is only a display width) and Expiry Date is a full DATE. An integer cannot hold the mask and silently drops leading zeros.

    • MySQLProbe: storing '0198...8822' fails with ERROR 1265: Data truncated for column 'Card Number' at row 1.
    • MySQLProbe: storing the digits 01988822 succeeds but reads back as 1988822: the leading zero is gone.

    The 2026 fix

    card_first4 and card_last4 as four-digit text with CHECK constraints, expiry_month (1 to 12) and expiry_year.

  7. Blocks a questionWings

    Wings can store only one visit per wing, ever

    Wings is keyed by Wings Name alone. Once one visitor has been recorded entering Denon, no other visit to Denon can be stored, so the table holds at most three rows. The order of wings (question 5) and visitors who saw all three (question 6) cannot be answered.

    • ReplayReplay: 3 of 19,545 wing scans stored; 19,542 rejected as duplicate keys.
    • MySQLProbe: a second Denon visit fails with ERROR 1062: Duplicate entry 'Denon' for key 'wings.PRIMARY'.

    The 2026 fix

    A wing lookup table and a wing_scan table with its own key, the ticket and the time of each scan.

  8. Blocks a questionInvoices · EFTPOS · Cash · Payment Methods

    No purchase records when it happened

    None of the purchase tables has a date or time, so sales cannot be counted per financial year (question 1), and the price actually charged is not kept either.

    • BriefQuestion 1 asks for ticket counts per payment method in the current financial year.

    The 2026 fix

    purchase_order.ordered_at, payment.paid_at and order_line.unit_price_cents.

  9. Blocks a questionTime Slot · Hall Napoleon · Exhibition Booking

    Time slots have no date and no exhibition

    Time Slot is a time of day with one capacity, shared by every day and every exhibition. The brief needs booked, admitted and remaining places for each 15-minute slot at any time, and capacity that varies by exhibition. Neither can be tracked, and nothing stops a slot being overbooked.

    • BriefSlots hold no more than 45 people, and the limit varies with the exhibition.
    • ReplayReplay: 32 time-of-day rows stand in for 2,015 bookings spread over hundreds of dated slots.

    The 2026 fix

    exhibition_slot is one exhibition, one date, one start time and its capacity. A trigger rejects the booking that would exceed it, and the slot_availability view reports booked, admitted and remaining places.

  10. Weakens the modelEFTPOS · Ticket

    Where a sale happened is only recorded for card payments

    Purchase Channel (Online / Offline) sits on EFTPOS, so cash sales have no channel, app-store sales look like any online sale, and nothing stops a cash payment online. There is also no barcode column; the ticket ID doubles as the barcode.

    • BriefCash is taken only at the museum (ticket desks and audio-guide desks); the app is sold through the app stores and paid by card.

    The 2026 fix

    purchase_order.channel (online, ticket_desk, audio_guide_desk, app_store) with the desk's entrance or wing, a trigger that refuses cash online, and ticket.barcode as a unique 13-digit code.

  11. Weakens the model(all)

    Spaces and typos in names

    Every table and many columns contain spaces (Hired Audio Guide, Payment Method_Payment ID), so every query needs quoting, and some names carry typos: Exhibiton Log ID, Vistor ID and the entrance value '99 rue de Rivioli'. 'Carrousel de Louvre' and 'Portes de Lion' follow the brief's own spelling; the real doors are Carrousel du Louvre and Porte des Lions.

    • ModelRead from the model.

    The 2026 fix

    snake_case names throughout and lookup tables with the correct entrance names.

    • (all)

Evidence

Running the script on MySQL 26.7.0

The model was forward-engineered the way MySQL Workbench does it and run, statement by statement, on a throwaway local server (scripts/mysql_check.py). 13 foreign keys were created on the tables that survived.

Which 2020 tables MySQL created
TableCreated
Ticket Yes
InvoicesERROR 6125: Failed to add the foreign key constraint. Missing unique key for constraint 'fk_Ticket_Invoices1' in the referenced table 'Invoices' No
Product Yes
EFTPOS Yes
Cash Yes
Payment MethodsERROR 1171: All parts of a PRIMARY KEY must be NOT NULL; if you need NULL in a key, use UNIQUE instead No
Entrance Yes
Wings Yes
Hired Audio Guide Yes
Audio Guide Device Yes
Exhibition Booking Yes
Booking TicketERROR 6125: Failed to add the foreign key constraint. Missing unique key for constraint 'fk_Booking Ticket_Exhibition Booking1' in the referenced table 'Exhibition Booking' No
Special Exhibition Yes
Time Slot Yes
Hall Napoleon Yes
Actual Number of Visitor Yes
  • Record a cash payment: Payment Methods row with a Cash ID and no EFTPOS ID.

    • Step 1: accepted
    • Step 2: ERROR 1146: Table 'mydb.payment methods' doesn't exist
  • Store a masked card number (first and last four digits, '0198...8822') in EFTPOS.`Card Number` (INT(8)), first as text, then as the digits 01988822.

    • Step 1: ERROR 1265: Data truncated for column 'Card Number' at row 1
    • Step 2: accepted
  • Record two visitors entering the Denon wing (Wings is keyed by wing name only).

    • Step 1: accepted
    • Step 2: ERROR 1062: Duplicate entry 'Denon' for key 'wings.PRIMARY'
  • Record a ticket re-entering through the same gate later the same day (Entrance is keyed by gate + ticket).

    • Step 1: accepted
    • Step 2: ERROR 1062: Duplicate entry 'Pyramid-1' for key 'entrance.PRIMARY'
  • Store a ticket with foreign-key checks on, as a live system would (Ticket's key points at Invoices, which was never created).

    • Step 1: ERROR 1452: Cannot add or update a child row: a foreign key constraint fails (`mydb`.`ticket`, CONSTRAINT `fk_Ticket_Invoices1` FOREIGN KEY (`Invoices_Invoice ID`) REFERENCES `invoices` (`Invoice ID`))
  • Register an audio guide that is still in service (no retired date yet).

    • Step 1: ERROR 1048: Column 'Retired Date' cannot be null

Evidence

Five years, replayed into the 2020 tables

The same synthetic activity that fills the refined database was translated into the 2020 tables, column by column, and inserted into a faithful SQLite port with keys, NOT NULL and the ENUM lists enforced. Every foreign key that points at a whole key is checked too, so a refused row takes the rows that point at it with it. The playground repeats this in your browser.

SQLite is more forgiving than MySQL. It creates Invoices, Payment Methods and Booking Ticket although MySQL refuses to, because it allows empty values in a composite primary key and does not check the three foreign keys that point at part of a key. Those tables are marked below; they hold 12,839 of the 43,521 rows kept here (68%).

On MySQL 26.7.0 with its default foreign-key checks, it is worse: Ticket's key points at the Invoices table that was never created, so no ticket can be stored, and nothing that refers to a ticket can be either. The tables could keep 5,473 of 64,130 rows (9%): products, payments, devices, exhibitions and time slots.

Rows each 2020 table accepted during the replay
2020 tableOfferedKept
Ticket9,8429,842
InvoicesSQLite only; MySQL cannot create it5,4125,412
Product44
EFTPOS4,7624,762
Cash650650
Payment MethodsSQLite only; MySQL cannot create it5,4125,412
EntranceUNIQUE constraint failed: Entrance.Gate Name, Entrance.Ticket ID (276)9,7389,462
WingsUNIQUE constraint failed: Wings.Wings Name (19,542)19,5453
Hired Audio GuideFOREIGN KEY constraint failed: fk_Hiring Audio Guide_Audio Guide Device1 (no Audio Guide Device row to point at) (748)1,085337
Audio Guide DeviceNOT NULL constraint failed: Audio Guide Device.Retired Date (43)5613
Exhibition Booking2,0152,015
Booking TicketSQLite only; MySQL cannot create it2,0152,015
Special Exhibition1212
Time Slot3232
Hall Napoleon1,7751,775
Actual Number of Visitor1,7751,775

Query the replayed 2020 database in the playground

2020 to 2026

Where each table went

The refined schema keeps the brief's scope and the 2020 entities wherever they worked. The 16 original tables become 19 tables and two views.

Refined tables and the 2020 tables they replace
2026 tableReplaces (2020)
entranceThe five entrances, spelled correctly.Entrance.Gate Name (ENUM)
wingThe three wings.Wings.Wings Name (ENUM), Hired Audio Guide wing ENUMs
languageThe 13 audio-guide languages with ISO codes.Hired Audio Guide.Language Setting
productWhat the museum sells, priced in euro cents.Product
financial_institutionCard-issuing banks with their short names and countries.EFTPOS.Financial Institution Name
paymentOne row per payment; the method is a single column.Payment Methods
card_paymentCard and wallet details: first and last four digits, expiry month and year, issuing branch.EFTPOS
cash_paymentFirst name, city and country of cash payers.Cash
purchase_orderOne sale: the channel (website, ticket desk, audio-guide desk, app store), when, and its payment.Invoices
order_lineProducts and quantities in an order, with the price charged.Invoices
ticketEach admission ticket and its unique barcode.Ticket
entry_scanEvery entrance scan; only the first of the day records how the visitor travelled.Entrance, Ticket.Transportation
wing_scanEvery scan at a wing entrance.Wings
audio_guide_deviceDevice register; retired_on stays empty while the guide is in service.Audio Guide Device
audio_guide_hireEach hire: ticket, device, language, issuing and returning wing, times and payment.Hired Audio Guide
exhibitionHall Napoleon exhibitions and their places per slot.Special Exhibition
exhibition_slotA dated 15-minute slot of one exhibition.Time Slot
exhibition_bookingA ticket's place in a slot; a trigger stops overbooking.Exhibition Booking, Booking Ticket
hall_napoleon_scanAdmission at the Hall Napoleon door, at most one per booking.Hall Napoleon, Actual Number of Visitor

Source

The DDL, three ways

All three scripts are generated or checked by the repository's tests, so they cannot drift from the original .mwb file or from the database the site runs on.

2020 design, MySQLWhat MySQL Workbench's Forward Engineer writes for the 16 diagram tables of 1118472.mwb. 305 lines.Show
Download 2020-mysql.sql
-- MySQL Workbench Forward Engineering (regenerated from coursework/1118472.mwb)

SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;
SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;
SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';

-- -----------------------------------------------------
-- Schema mydb
-- -----------------------------------------------------
CREATE SCHEMA IF NOT EXISTS `mydb` DEFAULT CHARACTER SET utf8 ;
USE `mydb` ;

-- -----------------------------------------------------
-- Table `mydb`.`Ticket`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Ticket` (
  `Ticket ID` INT NOT NULL AUTO_INCREMENT COMMENT 'Each visitor has unique ticket ID, unique bar code record ticket ID',
  `Invoices_Invoice ID` INT NOT NULL COMMENT 'Use as ticket purchase reference',
  `Transportation` ENUM('Walk', 'Metro', 'Train', 'Bus', 'Taxi', 'Private Vehicle', 'Other') NULL COMMENT 'As visitor first time enter the museum, the transportation must be record.',
  `Booked ID` INT NULL COMMENT 'Use to check special exhibition booking',
  `Exhibition Booking ID` INT NULL COMMENT 'Special exhibition book ID',
  PRIMARY KEY (`Ticket ID`),
  INDEX `fk_Ticket_Invoices1_idx` (`Invoices_Invoice ID` ASC) VISIBLE,
  INDEX `fk_Ticket_Booking Ticket1_idx` (`Booked ID` ASC, `Exhibition Booking ID` ASC) VISIBLE,
  CONSTRAINT `fk_Ticket_Invoices1`
    FOREIGN KEY (`Invoices_Invoice ID`)
    REFERENCES `mydb`.`Invoices` (`Invoice ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Ticket_Booking Ticket1`
    FOREIGN KEY (`Booked ID` , `Exhibition Booking ID`)
    REFERENCES `mydb`.`Booking Ticket` (`Booked ID` , `Exhibition Booking ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Invoices`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Invoices` (
  `Invoice ID` INT NOT NULL AUTO_INCREMENT COMMENT 'Unique invoice number',
  `Product ID` INT NOT NULL COMMENT 'Use to reference which product were purchased',
  `Payment Method_Payment ID` VARCHAR(45) NOT NULL COMMENT 'Use to reference the payment method',
  `Purchase Quantity` INT NOT NULL,
  PRIMARY KEY (`Invoice ID`, `Product ID`),
  INDEX `fk_Invoices_Payment Method1_idx` (`Payment Method_Payment ID` ASC) VISIBLE,
  INDEX `fk_Invoices_Product1_idx` (`Product ID` ASC) VISIBLE,
  CONSTRAINT `fk_Invoices_Payment Method1`
    FOREIGN KEY (`Payment Method_Payment ID`)
    REFERENCES `mydb`.`Payment Methods` (`Payment Method ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Invoices_Product1`
    FOREIGN KEY (`Product ID`)
    REFERENCES `mydb`.`Product` (`Product ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Product`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Product` (
  `Product ID` INT NOT NULL,
  `Product Name` VARCHAR(20) NOT NULL,
  `Product Price` FLOAT NOT NULL,
  PRIMARY KEY (`Product ID`))
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`EFTPOS`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`EFTPOS` (
  `EFTPOS ID` INT NOT NULL AUTO_INCREMENT,
  `EFTPOS Types` ENUM('Credit Card', 'Debit Card', 'Digital Wallet') NOT NULL COMMENT 'Record card type',
  `Purchase Channel` ENUM('Online', 'Offline') NOT NULL COMMENT 'Channel: Online / Offline',
  `Card Issued Country` VARCHAR(45) NOT NULL,
  `Financial Institution Name` VARCHAR(45) NOT NULL,
  `Issued Bank Branch` VARCHAR(45) NOT NULL,
  `Account Name` VARCHAR(30) NOT NULL,
  `Card Number` INT(8) NOT NULL COMMENT 'Card Number only record the first 4 and end 4 numbers.',
  `Expiry Date` DATE NOT NULL,
  PRIMARY KEY (`EFTPOS ID`))
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Cash`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Cash` (
  `Cash Payment ID` INT NOT NULL AUTO_INCREMENT,
  `City` VARCHAR(50) NOT NULL,
  `Country` VARCHAR(50) NOT NULL,
  `First Name` VARCHAR(30) NOT NULL,
  PRIMARY KEY (`Cash Payment ID`))
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Payment Methods`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Payment Methods` (
  `Payment Method ID` INT NOT NULL AUTO_INCREMENT COMMENT 'Check the payment method',
  `EFTPOS_EFTPOS ID` INT NULL COMMENT 'EFTPOS is for card and digital wallet payment method',
  `Cash_Cash ID` INT NULL COMMENT 'All cash payment will consider as a offline trade',
  PRIMARY KEY (`Payment Method ID`, `EFTPOS_EFTPOS ID`, `Cash_Cash ID`),
  INDEX `fk_Payment Method_EFTPOS1_idx` (`EFTPOS_EFTPOS ID` ASC) VISIBLE,
  INDEX `fk_Payment Method_Cash1_idx` (`Cash_Cash ID` ASC) VISIBLE,
  CONSTRAINT `fk_Payment Method_EFTPOS1`
    FOREIGN KEY (`EFTPOS_EFTPOS ID`)
    REFERENCES `mydb`.`EFTPOS` (`EFTPOS ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Payment Method_Cash1`
    FOREIGN KEY (`Cash_Cash ID`)
    REFERENCES `mydb`.`Cash` (`Cash Payment ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Entrance`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Entrance` (
  `Gate Name` ENUM('Pyramid', 'Carrousel de Louvre', '99 rue de Rivioli', 'Passage Richelieu', 'Portes de Lion') NOT NULL COMMENT 'Identify which gate',
  `Ticket ID` INT NOT NULL COMMENT 'Record Ticket ID',
  `Entrance Date/Time` DATETIME NOT NULL,
  `Business Day` ENUM('True', 'False') NOT NULL COMMENT 'To justify is working day or not',
  PRIMARY KEY (`Gate Name`, `Ticket ID`),
  INDEX `fk_Entrance_Ticket1_idx` (`Ticket ID` ASC) VISIBLE,
  CONSTRAINT `fk_Entrance_Ticket1`
    FOREIGN KEY (`Ticket ID`)
    REFERENCES `mydb`.`Ticket` (`Ticket ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Wings`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Wings` (
  `Wings Name` ENUM('Richelieu', 'Denon', 'Sully') NOT NULL COMMENT 'Identify which wing',
  `Ticket_Ticket ID` INT NOT NULL COMMENT 'Record ticket id',
  `Visit Time` DATETIME NOT NULL,
  PRIMARY KEY (`Wings Name`),
  INDEX `fk_Wings_Ticket1_idx` (`Ticket_Ticket ID` ASC) VISIBLE,
  CONSTRAINT `fk_Wings_Ticket1`
    FOREIGN KEY (`Ticket_Ticket ID`)
    REFERENCES `mydb`.`Ticket` (`Ticket ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Hired Audio Guide`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Hired Audio Guide` (
  `Hired ID` INT NOT NULL AUTO_INCREMENT COMMENT 'Unique hiring id',
  `Serial Number` VARCHAR(16) NOT NULL COMMENT 'Audio device serial number',
  `Language Setting` ENUM('French', 'English', 'Italian', 'Russian', 'Chinese Mandarin', 'Spanish', 'Portuguese', 'Korean', 'Dutch', 'Polish', 'Swedish', 'Norwegian', 'Finnish') NOT NULL,
  `Issued Wing` ENUM('Richelieu', 'Denon', 'Sully') NOT NULL,
  `Returned Wing` ENUM('Richelieu', 'Denon', 'Sully') NULL,
  `Issued Time` DATETIME NOT NULL,
  `Return Time` DATETIME NULL,
  `Invoice ID` INT NOT NULL,
  `Product ID` INT NOT NULL,
  `Ticket ID` INT NOT NULL,
  PRIMARY KEY (`Hired ID`, `Serial Number`),
  INDEX `fk_Hiring Audio Guide_Audio Guide Device1_idx` (`Serial Number` ASC) VISIBLE,
  INDEX `fk_Hired Audio Guide_Invoices1_idx` (`Invoice ID` ASC, `Product ID` ASC) VISIBLE,
  INDEX `fk_Hired Audio Guide_Ticket1_idx` (`Ticket ID` ASC) VISIBLE,
  CONSTRAINT `fk_Hiring Audio Guide_Audio Guide Device1`
    FOREIGN KEY (`Serial Number`)
    REFERENCES `mydb`.`Audio Guide Device` (`Serial Number`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Hired Audio Guide_Invoices1`
    FOREIGN KEY (`Invoice ID` , `Product ID`)
    REFERENCES `mydb`.`Invoices` (`Invoice ID` , `Product ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Hired Audio Guide_Ticket1`
    FOREIGN KEY (`Ticket ID`)
    REFERENCES `mydb`.`Ticket` (`Ticket ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Audio Guide Device`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Audio Guide Device` (
  `Serial Number` VARCHAR(16) NOT NULL COMMENT 'Audio device serial number',
  `Activated Date` DATE NOT NULL,
  `Retired Date` DATE NOT NULL,
  PRIMARY KEY (`Serial Number`))
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Exhibition Booking`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Exhibition Booking` (
  `Booking ID` INT NOT NULL AUTO_INCREMENT COMMENT 'Booking id',
  `Exhibition ID` INT NOT NULL COMMENT 'Identify booked exhibition',
  `Time Slot ID` INT NOT NULL COMMENT 'Identify which time slot has been selected',
  `Ticket ID` INT NULL COMMENT 'Record ticket ID / multi-value attribute',
  `Booking Date` DATE NOT NULL COMMENT 'Booking Date',
  PRIMARY KEY (`Booking ID`, `Exhibition ID`, `Time Slot ID`),
  INDEX `fk_Exhibition Booking_Ticket1_idx` (`Ticket ID` ASC) VISIBLE,
  INDEX `fk_Exhibition Booking_Time Slot1_idx` (`Time Slot ID` ASC) VISIBLE,
  INDEX `fk_Exhibition Booking_Special Exhibition1_idx` (`Exhibition ID` ASC) VISIBLE,
  CONSTRAINT `fk_Exhibition Booking_Ticket1`
    FOREIGN KEY (`Ticket ID`)
    REFERENCES `mydb`.`Ticket` (`Ticket ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Exhibition Booking_Time Slot1`
    FOREIGN KEY (`Time Slot ID`)
    REFERENCES `mydb`.`Time Slot` (`Time Slot ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Exhibition Booking_Special Exhibition1`
    FOREIGN KEY (`Exhibition ID`)
    REFERENCES `mydb`.`Special Exhibition` (`Exhibition ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Booking Ticket`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Booking Ticket` (
  `Booked ID` INT NOT NULL AUTO_INCREMENT COMMENT 'Booking ID',
  `Exhibition Booking ID` INT NOT NULL,
  PRIMARY KEY (`Booked ID`, `Exhibition Booking ID`),
  INDEX `fk_Booking Ticket_Exhibition Booking1_idx` (`Exhibition Booking ID` ASC) VISIBLE,
  CONSTRAINT `fk_Booking Ticket_Exhibition Booking1`
    FOREIGN KEY (`Exhibition Booking ID`)
    REFERENCES `mydb`.`Exhibition Booking` (`Booking ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Special Exhibition`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Special Exhibition` (
  `Exhibition ID` INT NOT NULL,
  `Exhibition Title` VARCHAR(45) NOT NULL COMMENT 'Name of exhibition',
  `Opening Date` DATETIME NOT NULL,
  `Closing Date` DATETIME NOT NULL,
  PRIMARY KEY (`Exhibition ID`))
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Time Slot`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Time Slot` (
  `Time Slot ID` INT NOT NULL COMMENT 'Available time slot number',
  `Time Period` TIME NOT NULL COMMENT 'Time slot date is fixed',
  `Time Slot Capacity` INT NOT NULL,
  PRIMARY KEY (`Time Slot ID`))
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Hall Napoleon`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Hall Napoleon` (
  `Exhibiton Log ID` INT NOT NULL AUTO_INCREMENT COMMENT 'Record how many visitor enter the hall Napoleon',
  `Time Slot ID` INT NOT NULL COMMENT 'Record the time slot',
  `Date` DATE NOT NULL COMMENT 'Record the date',
  `Ticket_Ticket ID` INT NULL COMMENT 'Record visited visitor ticket id',
  PRIMARY KEY (`Exhibiton Log ID`),
  INDEX `fk_Hall Napoleon_Ticket1_idx` (`Ticket_Ticket ID` ASC) VISIBLE,
  INDEX `fk_Hall Napoleon_Time Slot1_idx` (`Time Slot ID` ASC) VISIBLE,
  CONSTRAINT `fk_Hall Napoleon_Ticket1`
    FOREIGN KEY (`Ticket_Ticket ID`)
    REFERENCES `mydb`.`Ticket` (`Ticket ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION,
  CONSTRAINT `fk_Hall Napoleon_Time Slot1`
    FOREIGN KEY (`Time Slot ID`)
    REFERENCES `mydb`.`Time Slot` (`Time Slot ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

-- -----------------------------------------------------
-- Table `mydb`.`Actual Number of Visitor`
-- -----------------------------------------------------
CREATE TABLE IF NOT EXISTS `mydb`.`Actual Number of Visitor` (
  `Vistor ID` INT NOT NULL AUTO_INCREMENT,
  `Exhibiton Log ID` INT NOT NULL,
  `Ticket ID` INT NOT NULL,
  PRIMARY KEY (`Vistor ID`, `Exhibiton Log ID`),
  INDEX `fk_Actual Number of Visitor_Hall Napoleon1_idx` (`Exhibiton Log ID` ASC) VISIBLE,
  CONSTRAINT `fk_Actual Number of Visitor_Hall Napoleon1`
    FOREIGN KEY (`Exhibiton Log ID`)
    REFERENCES `mydb`.`Hall Napoleon` (`Exhibiton Log ID`)
    ON DELETE NO ACTION
    ON UPDATE NO ACTION)
ENGINE = InnoDB;

SET SQL_MODE=@OLD_SQL_MODE;
SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;
SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;
2020 design, SQLiteThe same 16 tables ported faithfully to SQLite; the playground's 2020 database is built from it. 178 lines.Show
Download 2020-sqlite.sql
-- Louvre Ops DB: the 2020 design, ported faithfully to SQLite
-- Generated from coursework/1118472.mwb (schema `mydb`, "EER Diagram1").
-- Names, types, NOT NULL flags, keys and foreign keys are unchanged. ENUMs are CHECK
-- constraints. VARCHAR lengths are not enforced by SQLite. Foreign keys that point at
-- part of a composite key are kept as designed; SQLite rejects them when enforcement
-- is on ("foreign key mismatch"), so this script leaves foreign_keys off.

PRAGMA foreign_keys = OFF;

CREATE TABLE "Ticket" (
  "Ticket ID" INTEGER NOT NULL, -- AUTO_INCREMENT (rowid alias); Each visitor has unique ticket ID, unique bar code record ticket ID
  "Invoices_Invoice ID" INTEGER NOT NULL, -- Use as ticket purchase reference
  "Transportation" TEXT CHECK ("Transportation" IN ('Walk', 'Metro', 'Train', 'Bus', 'Taxi', 'Private Vehicle', 'Other')), -- As visitor first time enter the museum, the transportation must be record.
  "Booked ID" INTEGER, -- Use to check special exhibition booking
  "Exhibition Booking ID" INTEGER, -- Special exhibition book ID
  PRIMARY KEY ("Ticket ID"),
  CONSTRAINT "fk_Ticket_Invoices1" FOREIGN KEY ("Invoices_Invoice ID") REFERENCES "Invoices" ("Invoice ID"),
  CONSTRAINT "fk_Ticket_Booking Ticket1" FOREIGN KEY ("Booked ID", "Exhibition Booking ID") REFERENCES "Booking Ticket" ("Booked ID", "Exhibition Booking ID")
);
CREATE INDEX "fk_Ticket_Invoices1_idx" ON "Ticket" ("Invoices_Invoice ID");
CREATE INDEX "fk_Ticket_Booking Ticket1_idx" ON "Ticket" ("Booked ID", "Exhibition Booking ID");

CREATE TABLE "Invoices" (
  "Invoice ID" INTEGER NOT NULL, -- AUTO_INCREMENT (not possible in a composite key); Unique invoice number
  "Product ID" INTEGER NOT NULL, -- Use to reference which product were purchased
  "Payment Method_Payment ID" VARCHAR(45) NOT NULL, -- Use to reference the payment method
  "Purchase Quantity" INTEGER NOT NULL,
  PRIMARY KEY ("Invoice ID", "Product ID"),
  CONSTRAINT "fk_Invoices_Payment Method1" FOREIGN KEY ("Payment Method_Payment ID") REFERENCES "Payment Methods" ("Payment Method ID"),
  CONSTRAINT "fk_Invoices_Product1" FOREIGN KEY ("Product ID") REFERENCES "Product" ("Product ID")
);
CREATE INDEX "fk_Invoices_Payment Method1_idx" ON "Invoices" ("Payment Method_Payment ID");
CREATE INDEX "fk_Invoices_Product1_idx" ON "Invoices" ("Product ID");

CREATE TABLE "Product" (
  "Product ID" INTEGER NOT NULL,
  "Product Name" VARCHAR(20) NOT NULL,
  "Product Price" FLOAT NOT NULL,
  PRIMARY KEY ("Product ID")
);

CREATE TABLE "EFTPOS" (
  "EFTPOS ID" INTEGER NOT NULL, -- AUTO_INCREMENT (rowid alias)
  "EFTPOS Types" TEXT NOT NULL CHECK ("EFTPOS Types" IN ('Credit Card', 'Debit Card', 'Digital Wallet')), -- Record card type
  "Purchase Channel" TEXT NOT NULL CHECK ("Purchase Channel" IN ('Online', 'Offline')), -- Channel: Online / Offline
  "Card Issued Country" VARCHAR(45) NOT NULL,
  "Financial Institution Name" VARCHAR(45) NOT NULL,
  "Issued Bank Branch" VARCHAR(45) NOT NULL,
  "Account Name" VARCHAR(30) NOT NULL,
  "Card Number" INTEGER NOT NULL, -- was INT(8); Card Number only record the first 4 and end 4 numbers.
  "Expiry Date" DATE NOT NULL,
  PRIMARY KEY ("EFTPOS ID")
);

CREATE TABLE "Cash" (
  "Cash Payment ID" INTEGER NOT NULL, -- AUTO_INCREMENT (rowid alias)
  "City" VARCHAR(50) NOT NULL,
  "Country" VARCHAR(50) NOT NULL,
  "First Name" VARCHAR(30) NOT NULL,
  PRIMARY KEY ("Cash Payment ID")
);

CREATE TABLE "Payment Methods" (
  "Payment Method ID" INTEGER NOT NULL, -- AUTO_INCREMENT (not possible in a composite key); Check the payment method
  "EFTPOS_EFTPOS ID" INTEGER, -- EFTPOS is for card and digital wallet payment method
  "Cash_Cash ID" INTEGER, -- All cash payment will consider as a offline trade
  PRIMARY KEY ("Payment Method ID", "EFTPOS_EFTPOS ID", "Cash_Cash ID"),
  CONSTRAINT "fk_Payment Method_EFTPOS1" FOREIGN KEY ("EFTPOS_EFTPOS ID") REFERENCES "EFTPOS" ("EFTPOS ID"),
  CONSTRAINT "fk_Payment Method_Cash1" FOREIGN KEY ("Cash_Cash ID") REFERENCES "Cash" ("Cash Payment ID")
);
CREATE INDEX "fk_Payment Method_EFTPOS1_idx" ON "Payment Methods" ("EFTPOS_EFTPOS ID");
CREATE INDEX "fk_Payment Method_Cash1_idx" ON "Payment Methods" ("Cash_Cash ID");

CREATE TABLE "Entrance" (
  "Gate Name" TEXT NOT NULL CHECK ("Gate Name" IN ('Pyramid', 'Carrousel de Louvre', '99 rue de Rivioli', 'Passage Richelieu', 'Portes de Lion')), -- Identify which gate
  "Ticket ID" INTEGER NOT NULL, -- Record Ticket ID
  "Entrance Date/Time" DATETIME NOT NULL,
  "Business Day" TEXT NOT NULL CHECK ("Business Day" IN ('True', 'False')), -- To justify is working day or not
  PRIMARY KEY ("Gate Name", "Ticket ID"),
  CONSTRAINT "fk_Entrance_Ticket1" FOREIGN KEY ("Ticket ID") REFERENCES "Ticket" ("Ticket ID")
);
CREATE INDEX "fk_Entrance_Ticket1_idx" ON "Entrance" ("Ticket ID");

CREATE TABLE "Wings" (
  "Wings Name" TEXT NOT NULL CHECK ("Wings Name" IN ('Richelieu', 'Denon', 'Sully')), -- Identify which wing
  "Ticket_Ticket ID" INTEGER NOT NULL, -- Record ticket id
  "Visit Time" DATETIME NOT NULL,
  PRIMARY KEY ("Wings Name"),
  CONSTRAINT "fk_Wings_Ticket1" FOREIGN KEY ("Ticket_Ticket ID") REFERENCES "Ticket" ("Ticket ID")
);
CREATE INDEX "fk_Wings_Ticket1_idx" ON "Wings" ("Ticket_Ticket ID");

CREATE TABLE "Hired Audio Guide" (
  "Hired ID" INTEGER NOT NULL, -- AUTO_INCREMENT (not possible in a composite key); Unique hiring id
  "Serial Number" VARCHAR(16) NOT NULL, -- Audio device serial number
  "Language Setting" TEXT NOT NULL CHECK ("Language Setting" IN ('French', 'English', 'Italian', 'Russian', 'Chinese Mandarin', 'Spanish', 'Portuguese', 'Korean', 'Dutch', 'Polish', 'Swedish', 'Norwegian', 'Finnish')),
  "Issued Wing" TEXT NOT NULL CHECK ("Issued Wing" IN ('Richelieu', 'Denon', 'Sully')),
  "Returned Wing" TEXT CHECK ("Returned Wing" IN ('Richelieu', 'Denon', 'Sully')),
  "Issued Time" DATETIME NOT NULL,
  "Return Time" DATETIME,
  "Invoice ID" INTEGER NOT NULL,
  "Product ID" INTEGER NOT NULL,
  "Ticket ID" INTEGER NOT NULL,
  PRIMARY KEY ("Hired ID", "Serial Number"),
  CONSTRAINT "fk_Hiring Audio Guide_Audio Guide Device1" FOREIGN KEY ("Serial Number") REFERENCES "Audio Guide Device" ("Serial Number"),
  CONSTRAINT "fk_Hired Audio Guide_Invoices1" FOREIGN KEY ("Invoice ID", "Product ID") REFERENCES "Invoices" ("Invoice ID", "Product ID"),
  CONSTRAINT "fk_Hired Audio Guide_Ticket1" FOREIGN KEY ("Ticket ID") REFERENCES "Ticket" ("Ticket ID")
);
CREATE INDEX "fk_Hiring Audio Guide_Audio Guide Device1_idx" ON "Hired Audio Guide" ("Serial Number");
CREATE INDEX "fk_Hired Audio Guide_Invoices1_idx" ON "Hired Audio Guide" ("Invoice ID", "Product ID");
CREATE INDEX "fk_Hired Audio Guide_Ticket1_idx" ON "Hired Audio Guide" ("Ticket ID");

CREATE TABLE "Audio Guide Device" (
  "Serial Number" VARCHAR(16) NOT NULL, -- Audio device serial number
  "Activated Date" DATE NOT NULL,
  "Retired Date" DATE NOT NULL,
  PRIMARY KEY ("Serial Number")
);

CREATE TABLE "Exhibition Booking" (
  "Booking ID" INTEGER NOT NULL, -- AUTO_INCREMENT (not possible in a composite key); Booking id
  "Exhibition ID" INTEGER NOT NULL, -- Identify booked exhibition
  "Time Slot ID" INTEGER NOT NULL, -- Identify which time slot has been selected
  "Ticket ID" INTEGER, -- Record ticket ID / multi-value attribute
  "Booking Date" DATE NOT NULL, -- Booking Date
  PRIMARY KEY ("Booking ID", "Exhibition ID", "Time Slot ID"),
  CONSTRAINT "fk_Exhibition Booking_Ticket1" FOREIGN KEY ("Ticket ID") REFERENCES "Ticket" ("Ticket ID"),
  CONSTRAINT "fk_Exhibition Booking_Time Slot1" FOREIGN KEY ("Time Slot ID") REFERENCES "Time Slot" ("Time Slot ID"),
  CONSTRAINT "fk_Exhibition Booking_Special Exhibition1" FOREIGN KEY ("Exhibition ID") REFERENCES "Special Exhibition" ("Exhibition ID")
);
CREATE INDEX "fk_Exhibition Booking_Ticket1_idx" ON "Exhibition Booking" ("Ticket ID");
CREATE INDEX "fk_Exhibition Booking_Time Slot1_idx" ON "Exhibition Booking" ("Time Slot ID");
CREATE INDEX "fk_Exhibition Booking_Special Exhibition1_idx" ON "Exhibition Booking" ("Exhibition ID");

CREATE TABLE "Booking Ticket" (
  "Booked ID" INTEGER NOT NULL, -- AUTO_INCREMENT (not possible in a composite key); Booking ID
  "Exhibition Booking ID" INTEGER NOT NULL,
  PRIMARY KEY ("Booked ID", "Exhibition Booking ID"),
  CONSTRAINT "fk_Booking Ticket_Exhibition Booking1" FOREIGN KEY ("Exhibition Booking ID") REFERENCES "Exhibition Booking" ("Booking ID")
);
CREATE INDEX "fk_Booking Ticket_Exhibition Booking1_idx" ON "Booking Ticket" ("Exhibition Booking ID");

CREATE TABLE "Special Exhibition" (
  "Exhibition ID" INTEGER NOT NULL,
  "Exhibition Title" VARCHAR(45) NOT NULL, -- Name of exhibition
  "Opening Date" DATETIME NOT NULL,
  "Closing Date" DATETIME NOT NULL,
  PRIMARY KEY ("Exhibition ID")
);

CREATE TABLE "Time Slot" (
  "Time Slot ID" INTEGER NOT NULL, -- Available time slot number
  "Time Period" TIME NOT NULL, -- Time slot date is fixed
  "Time Slot Capacity" INTEGER NOT NULL,
  PRIMARY KEY ("Time Slot ID")
);

CREATE TABLE "Hall Napoleon" (
  "Exhibiton Log ID" INTEGER NOT NULL, -- AUTO_INCREMENT (rowid alias); Record how many visitor enter the hall Napoleon
  "Time Slot ID" INTEGER NOT NULL, -- Record the time slot
  "Date" DATE NOT NULL, -- Record the date
  "Ticket_Ticket ID" INTEGER, -- Record visited visitor ticket id
  PRIMARY KEY ("Exhibiton Log ID"),
  CONSTRAINT "fk_Hall Napoleon_Ticket1" FOREIGN KEY ("Ticket_Ticket ID") REFERENCES "Ticket" ("Ticket ID"),
  CONSTRAINT "fk_Hall Napoleon_Time Slot1" FOREIGN KEY ("Time Slot ID") REFERENCES "Time Slot" ("Time Slot ID")
);
CREATE INDEX "fk_Hall Napoleon_Ticket1_idx" ON "Hall Napoleon" ("Ticket_Ticket ID");
CREATE INDEX "fk_Hall Napoleon_Time Slot1_idx" ON "Hall Napoleon" ("Time Slot ID");

CREATE TABLE "Actual Number of Visitor" (
  "Vistor ID" INTEGER NOT NULL, -- AUTO_INCREMENT (not possible in a composite key)
  "Exhibiton Log ID" INTEGER NOT NULL,
  "Ticket ID" INTEGER NOT NULL,
  PRIMARY KEY ("Vistor ID", "Exhibiton Log ID"),
  CONSTRAINT "fk_Actual Number of Visitor_Hall Napoleon1" FOREIGN KEY ("Exhibiton Log ID") REFERENCES "Hall Napoleon" ("Exhibiton Log ID")
);
CREATE INDEX "fk_Actual Number of Visitor_Hall Napoleon1_idx" ON "Actual Number of Visitor" ("Exhibiton Log ID");
2026 refined schemaThe cleaned-up SQLite schema behind the demo database, with its triggers and views. 335 lines.Show
Download refined.sql
-- Louvre Ops DB: refined schema (2026)
--
-- A cleaned-up version of the 2020 INFO20003 design, written for SQLite
-- (3.37+, STRICT tables). It keeps the same business scope and the same
-- entities wherever they worked, and fixes the problems listed on the site's
-- design-review page. Each table notes the 2020 table it replaces.
--
-- Conventions: snake_case names; money in integer euro cents; timestamps as
-- ISO-8601 text in museum local time ('YYYY-MM-DD HH:MM:SS'); dates as
-- 'YYYY-MM-DD'; enumerations as CHECK constraints or lookup tables.

PRAGMA foreign_keys = ON;

-- ---------------------------------------------------------------------------
-- Reference data
-- ---------------------------------------------------------------------------

-- 2020: the ENUM on Entrance."Gate Name" (with one spelling slip, '99 rue de Rivioli';
-- two other names followed the brief's own spelling).
CREATE TABLE entrance (
  entrance_id INTEGER PRIMARY KEY,
  name        TEXT NOT NULL UNIQUE
) STRICT;

-- 2020: the ENUM on Wings."Wings Name" and Hired Audio Guide's wing columns.
CREATE TABLE wing (
  wing_id INTEGER PRIMARY KEY,
  name    TEXT NOT NULL UNIQUE CHECK (name IN ('Richelieu', 'Denon', 'Sully'))
) STRICT;

-- 2020: the 13-value ENUM on Hired Audio Guide."Language Setting".
CREATE TABLE language (
  language_code TEXT PRIMARY KEY CHECK (length(language_code) BETWEEN 2 AND 6),
  name          TEXT NOT NULL UNIQUE
) STRICT;

-- 2020: Product (price was FLOAT).
CREATE TABLE product (
  product_id  INTEGER PRIMARY KEY,
  code        TEXT NOT NULL UNIQUE
              CHECK (code IN ('TICKET_ONLINE', 'TICKET_ON_SITE', 'AUDIO_GUIDE', 'APP')),
  name        TEXT NOT NULL,
  price_cents INTEGER NOT NULL CHECK (price_cents >= 0)
) STRICT;

-- 2020: part of EFTPOS (institution name was one free-text column).
CREATE TABLE financial_institution (
  institution_id INTEGER PRIMARY KEY,
  name           TEXT NOT NULL UNIQUE,
  short_name     TEXT NOT NULL,
  country        TEXT NOT NULL
) STRICT;

-- ---------------------------------------------------------------------------
-- Payments: one supertype row per payment, exactly one subtype row
-- 2020: Payment Methods + EFTPOS + Cash
-- ---------------------------------------------------------------------------

CREATE TABLE payment (
  payment_id   INTEGER PRIMARY KEY,
  method       TEXT NOT NULL
               CHECK (method IN ('credit_card', 'debit_card', 'digital_wallet', 'cash')),
  amount_cents INTEGER NOT NULL CHECK (amount_cents >= 0),
  paid_at      TEXT NOT NULL CHECK (paid_at GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] [0-9][0-9]:[0-9][0-9]:[0-9][0-9]')
) STRICT;

-- Cards and digital wallets. Only the first and last four digits are kept,
-- as text so leading zeros survive; no CCV column exists.
CREATE TABLE card_payment (
  payment_id     INTEGER PRIMARY KEY REFERENCES payment (payment_id),
  institution_id INTEGER NOT NULL REFERENCES financial_institution (institution_id),
  issuing_branch TEXT NOT NULL,
  issued_country TEXT NOT NULL,
  account_name   TEXT NOT NULL,
  card_first4    TEXT NOT NULL CHECK (length(card_first4) = 4 AND card_first4 NOT GLOB '*[^0-9]*'),
  card_last4     TEXT NOT NULL CHECK (length(card_last4) = 4 AND card_last4 NOT GLOB '*[^0-9]*'),
  expiry_month   INTEGER NOT NULL CHECK (expiry_month BETWEEN 1 AND 12),
  expiry_year    INTEGER NOT NULL CHECK (expiry_year BETWEEN 2000 AND 2099),
  wallet         TEXT CHECK (wallet IN ('Apple Pay', 'Google Pay'))
) STRICT;

-- Cash (euros only): first name, city and country, nothing else.
CREATE TABLE cash_payment (
  payment_id INTEGER PRIMARY KEY REFERENCES payment (payment_id),
  first_name TEXT NOT NULL,
  city       TEXT NOT NULL,
  country    TEXT NOT NULL
) STRICT;

-- A payment has exactly one subtype, and it must match payment.method.
CREATE TRIGGER card_payment_matches_method
BEFORE INSERT ON card_payment
WHEN (SELECT method FROM payment WHERE payment_id = NEW.payment_id) = 'cash'
  OR EXISTS (SELECT 1 FROM cash_payment WHERE payment_id = NEW.payment_id)
  OR ((SELECT method FROM payment WHERE payment_id = NEW.payment_id) = 'digital_wallet') <> (NEW.wallet IS NOT NULL)
BEGIN
  SELECT RAISE(ABORT, 'card details do not match the payment method');
END;

CREATE TRIGGER cash_payment_matches_method
BEFORE INSERT ON cash_payment
WHEN (SELECT method FROM payment WHERE payment_id = NEW.payment_id) <> 'cash'
  OR EXISTS (SELECT 1 FROM card_payment WHERE payment_id = NEW.payment_id)
BEGIN
  SELECT RAISE(ABORT, 'cash details recorded for a non-cash payment');
END;

-- ---------------------------------------------------------------------------
-- Orders and tickets
-- 2020: Invoices (one product per invoice, keyed by invoice + product)
-- ---------------------------------------------------------------------------

-- channel says where the sale happened: the website, a ticket desk at one of
-- the five entrances, an audio-guide desk in a wing, or an app store.
CREATE TABLE purchase_order (
  order_id    INTEGER PRIMARY KEY,
  channel     TEXT NOT NULL
              CHECK (channel IN ('online', 'ticket_desk', 'audio_guide_desk', 'app_store')),
  entrance_id INTEGER REFERENCES entrance (entrance_id),
  wing_id     INTEGER REFERENCES wing (wing_id),
  payment_id  INTEGER NOT NULL UNIQUE REFERENCES payment (payment_id),
  ordered_at  TEXT NOT NULL,
  CHECK ((channel = 'ticket_desk') = (entrance_id IS NOT NULL)),
  CHECK ((channel = 'audio_guide_desk') = (wing_id IS NOT NULL))
) STRICT;

CREATE TRIGGER purchase_order_cash_on_site_only
BEFORE INSERT ON purchase_order
WHEN NEW.channel IN ('online', 'app_store')
 AND (SELECT method FROM payment WHERE payment_id = NEW.payment_id) = 'cash'
BEGIN
  SELECT RAISE(ABORT, 'cash is accepted only at museum desks');
END;

CREATE TABLE order_line (
  order_id         INTEGER NOT NULL REFERENCES purchase_order (order_id),
  product_id       INTEGER NOT NULL REFERENCES product (product_id),
  quantity         INTEGER NOT NULL CHECK (quantity > 0),
  unit_price_cents INTEGER NOT NULL CHECK (unit_price_cents >= 0),
  PRIMARY KEY (order_id, product_id)
) STRICT;

-- 2020: Ticket (the barcode was the ticket ID; transport mode lived here).
CREATE TABLE ticket (
  ticket_id INTEGER PRIMARY KEY,
  barcode   TEXT NOT NULL UNIQUE CHECK (length(barcode) = 13 AND barcode NOT GLOB '*[^0-9]*'),
  order_id  INTEGER NOT NULL REFERENCES purchase_order (order_id)
) STRICT;
CREATE INDEX ticket_order ON ticket (order_id);

-- ---------------------------------------------------------------------------
-- Admission and movement
-- 2020: Entrance (keyed by gate + ticket), Wings (keyed by wing name)
-- ---------------------------------------------------------------------------

-- Every scan at one of the five entrances. The first scan of a ticket opens
-- its day of validity and is the only one that records the transport mode.
CREATE TABLE entry_scan (
  scan_id        INTEGER PRIMARY KEY,
  ticket_id      INTEGER NOT NULL REFERENCES ticket (ticket_id),
  entrance_id    INTEGER NOT NULL REFERENCES entrance (entrance_id),
  scanned_at     TEXT NOT NULL,
  is_first_entry INTEGER NOT NULL CHECK (is_first_entry IN (0, 1)),
  transport_mode TEXT CHECK (transport_mode IN ('Walk', 'Metro', 'Train', 'Bus', 'Taxi', 'Private Vehicle', 'Other')),
  CHECK ((is_first_entry = 1) = (transport_mode IS NOT NULL))
) STRICT;
CREATE UNIQUE INDEX entry_scan_one_first_entry ON entry_scan (ticket_id) WHERE is_first_entry = 1;

-- A ticket is valid from its first scan until closing on the same day.
CREATE TRIGGER entry_scan_same_day
BEFORE INSERT ON entry_scan
WHEN NEW.is_first_entry = 0
 AND date(NEW.scanned_at) IS NOT (
   SELECT date(scanned_at) FROM entry_scan WHERE ticket_id = NEW.ticket_id AND is_first_entry = 1
 )
BEGIN
  SELECT RAISE(ABORT, 'ticket is not valid today: re-entry is allowed only on the day of first entry');
END;

CREATE TABLE wing_scan (
  scan_id    INTEGER PRIMARY KEY,
  ticket_id  INTEGER NOT NULL REFERENCES ticket (ticket_id),
  wing_id    INTEGER NOT NULL REFERENCES wing (wing_id),
  scanned_at TEXT NOT NULL
) STRICT;
CREATE INDEX wing_scan_ticket ON wing_scan (ticket_id);

CREATE TRIGGER wing_scan_valid_ticket
BEFORE INSERT ON wing_scan
WHEN date(NEW.scanned_at) IS NOT (
  SELECT date(scanned_at) FROM entry_scan WHERE ticket_id = NEW.ticket_id AND is_first_entry = 1
)
BEGIN
  SELECT RAISE(ABORT, 'wing scan without a valid admission on that day');
END;

-- ---------------------------------------------------------------------------
-- Audio guides
-- 2020: Audio Guide Device (retired date was NOT NULL), Hired Audio Guide
-- ---------------------------------------------------------------------------

CREATE TABLE audio_guide_device (
  serial_number TEXT PRIMARY KEY
                CHECK (length(serial_number) = 16 AND serial_number NOT GLOB '*[^0-9A-Z]*'),
  activated_on  TEXT NOT NULL,
  retired_on    TEXT,
  CHECK (retired_on IS NULL OR retired_on >= activated_on)
) STRICT;

CREATE TABLE audio_guide_hire (
  hire_id          INTEGER PRIMARY KEY,
  ticket_id        INTEGER NOT NULL REFERENCES ticket (ticket_id),
  serial_number    TEXT NOT NULL REFERENCES audio_guide_device (serial_number),
  order_id         INTEGER NOT NULL UNIQUE REFERENCES purchase_order (order_id),
  language_code    TEXT NOT NULL REFERENCES language (language_code),
  issued_wing_id   INTEGER NOT NULL REFERENCES wing (wing_id),
  issued_at        TEXT NOT NULL,
  returned_wing_id INTEGER REFERENCES wing (wing_id),
  returned_at      TEXT,
  CHECK ((returned_at IS NULL) = (returned_wing_id IS NULL)),
  CHECK (returned_at IS NULL OR returned_at > issued_at)
) STRICT;
CREATE INDEX audio_guide_hire_ticket ON audio_guide_hire (ticket_id);
CREATE INDEX audio_guide_hire_issued ON audio_guide_hire (issued_at);
CREATE INDEX audio_guide_hire_device ON audio_guide_hire (serial_number, issued_at);

-- A device can be out with only one visitor at a time, and only while in service.
CREATE TRIGGER audio_guide_hire_device_available
BEFORE INSERT ON audio_guide_hire
WHEN EXISTS (
  SELECT 1 FROM audio_guide_hire h
  WHERE h.serial_number = NEW.serial_number
    AND h.issued_at < coalesce(NEW.returned_at, '9999')
    AND coalesce(h.returned_at, '9999') > NEW.issued_at
) OR NOT EXISTS (
  SELECT 1 FROM audio_guide_device d
  WHERE d.serial_number = NEW.serial_number
    AND d.activated_on <= date(NEW.issued_at)
    AND (d.retired_on IS NULL OR d.retired_on > date(NEW.issued_at))
)
BEGIN
  SELECT RAISE(ABORT, 'audio guide is already out or not in service');
END;

-- ---------------------------------------------------------------------------
-- Special exhibitions in the Hall Napoleon
-- 2020: Special Exhibition, Time Slot (no date), Exhibition Booking,
--       Booking Ticket, Hall Napoleon, Actual Number of Visitor
-- ---------------------------------------------------------------------------

CREATE TABLE exhibition (
  exhibition_id INTEGER PRIMARY KEY,
  title         TEXT NOT NULL,
  opens_on      TEXT NOT NULL,
  closes_on     TEXT NOT NULL,
  slot_capacity INTEGER NOT NULL DEFAULT 45 CHECK (slot_capacity > 0),
  CHECK (closes_on >= opens_on)
) STRICT;

-- One row per bookable 15-minute slot. Slots are created when the first
-- place in them is booked, with the exhibition's capacity at that time.
CREATE TABLE exhibition_slot (
  slot_id       INTEGER PRIMARY KEY,
  exhibition_id INTEGER NOT NULL REFERENCES exhibition (exhibition_id),
  slot_date     TEXT NOT NULL,
  start_time    TEXT NOT NULL CHECK (start_time GLOB '[0-2][0-9]:[0-5][0-9]' AND substr(start_time, 4, 2) IN ('00', '15', '30', '45')),
  capacity      INTEGER NOT NULL CHECK (capacity > 0),
  UNIQUE (exhibition_id, slot_date, start_time)
) STRICT;

CREATE TRIGGER exhibition_slot_within_run
BEFORE INSERT ON exhibition_slot
WHEN NOT EXISTS (
  SELECT 1 FROM exhibition e
  WHERE e.exhibition_id = NEW.exhibition_id AND NEW.slot_date BETWEEN e.opens_on AND e.closes_on
)
BEGIN
  SELECT RAISE(ABORT, 'slot date is outside the exhibition run');
END;

CREATE TABLE exhibition_booking (
  booking_id INTEGER PRIMARY KEY,
  slot_id    INTEGER NOT NULL REFERENCES exhibition_slot (slot_id),
  ticket_id  INTEGER NOT NULL REFERENCES ticket (ticket_id),
  booked_at  TEXT NOT NULL,
  UNIQUE (slot_id, ticket_id)
) STRICT;
CREATE INDEX exhibition_booking_ticket ON exhibition_booking (ticket_id);

-- No more than the slot's capacity can ever be booked.
CREATE TRIGGER exhibition_booking_capacity
BEFORE INSERT ON exhibition_booking
WHEN (SELECT count(*) FROM exhibition_booking WHERE slot_id = NEW.slot_id)
  >= (SELECT capacity FROM exhibition_slot WHERE slot_id = NEW.slot_id)
BEGIN
  SELECT RAISE(ABORT, 'this 15-minute slot is fully booked');
END;

-- Scans at the Hall Napoleon door; one admission per booking.
CREATE TABLE hall_napoleon_scan (
  scan_id    INTEGER PRIMARY KEY,
  booking_id INTEGER NOT NULL UNIQUE REFERENCES exhibition_booking (booking_id),
  scanned_at TEXT NOT NULL
) STRICT;

-- Booked, admitted and remaining places for every slot (replaces the 2020
-- "Actual Number of Visitor" table, which stored a count that can be derived).
CREATE VIEW slot_availability AS
SELECT
  s.slot_id,
  s.exhibition_id,
  e.title,
  s.slot_date,
  s.start_time,
  s.capacity,
  count(b.booking_id)              AS booked,
  count(h.scan_id)                 AS admitted,
  s.capacity - count(b.booking_id) AS remaining
FROM exhibition_slot s
JOIN exhibition e ON e.exhibition_id = s.exhibition_id
LEFT JOIN exhibition_booking b ON b.slot_id = s.slot_id
LEFT JOIN hall_napoleon_scan h ON h.booking_id = b.booking_id
GROUP BY s.slot_id;

-- One row per admitted ticket: the day's first entry, for convenience.
CREATE VIEW museum_visit AS
SELECT
  e.ticket_id,
  date(e.scanned_at) AS visit_date,
  e.scanned_at       AS first_entry_at,
  e.entrance_id,
  e.transport_mode
FROM entry_scan e
WHERE e.is_first_entry = 1;

How the evidence was produced: scripts/parse_mwb.py reads the original model, scripts/mysql_check.py runs it on MySQL, and web/scripts/build-db.ts generates the data and the replay (all in the project's repository).