| [d8ce4e2] | 1 | # Relational Design
|
|---|
| 2 |
|
|---|
| [1549dae] | 3 | This page transforms [ERModel](../P1-ConceptualModel/ERModel.md) **v05** into
|
|---|
| 4 | relations. Every relation below corresponds to exactly one entity set of the
|
|---|
| 5 | model, and every foreign key corresponds to exactly one relationship, so the
|
|---|
| 6 | two diagrams can be compared box for box and line for line (see
|
|---|
| 7 | [Relational diagram](#relational-diagram)).
|
|---|
| 8 |
|
|---|
| [d8ce4e2] | 9 | ## Descriptive representation of the relational schema
|
|---|
| 10 |
|
|---|
| [1549dae] | 11 | Notation: **bold** = primary key, *italic* = foreign key. After each foreign key
|
|---|
| 12 | comes the ER relationship it implements.
|
|---|
| [d8ce4e2] | 13 |
|
|---|
| [1549dae] | 14 | - **Users**(<u>**id**</u>, username, email, full_name, password_hash, available_balance, invested_balance, reserved_balance, created_at, updated_at)
|
|---|
| 15 | - Entity set `Users`. Candidate keys: `{id}`, `{username}`, `{email}`. `UNIQUE(username)`, `UNIQUE(email)`.
|
|---|
| [d8ce4e2] | 16 | - **Crypto**(<u>**id**</u>, symbol, name, created_at)
|
|---|
| [1549dae] | 17 | - Entity set `Cryptos`. Candidate keys: `{id}`, `{symbol}`. `UNIQUE(symbol)`.
|
|---|
| 18 | - **Markets**(<u>**id**</u>, *crypto_id* [`QuotedOn`], quote_currency, is_active, created_at)
|
|---|
| 19 | - Entity set `Markets`. Candidate keys: `{id}`, `{crypto_id, quote_currency}`
|
|---|
| 20 | (the model's rule "a crypto is quoted at most once per currency"),
|
|---|
| 21 | enforced with `UNIQUE(crypto_id, quote_currency)`.
|
|---|
| 22 | - **Holdings**(<u>**id**</u>, *user_id* [`Holds`], *crypto_id* [`PositionIn`], quantity, reserved_quantity, avg_price, created_at, updated_at)
|
|---|
| 23 | - Entity set `Holdings`. Candidate keys: `{id}` and `{user_id, crypto_id}`
|
|---|
| 24 | (the model's rule "one holding per user and crypto"), enforced with
|
|---|
| 25 | `UNIQUE(user_id, crypto_id)`.
|
|---|
| [d8ce4e2] | 26 | - `avg_price` is `NOT NULL DEFAULT 0 CHECK (avg_price >= 0)`.
|
|---|
| [9577c79] | 27 | - `reserved_quantity` is `NOT NULL DEFAULT 0 CHECK (reserved_quantity >= 0
|
|---|
| 28 | AND reserved_quantity <= quantity)` — the amount already committed to the
|
|---|
| 29 | user's own open sell orders. `quantity - reserved_quantity` (the amount
|
|---|
| 30 | actually free to sell) is not a stored column; it is computed wherever
|
|---|
| 31 | needed, in `v_portfolio` as `available_quantity` and in the sell path of
|
|---|
| 32 | [UseCase0005](../P3-UseCaseModel/UseCase0005.md). See
|
|---|
| [1549dae] | 33 | [ERModel](../P1-ConceptualModel/ERModel.md#holdings)
|
|---|
| [9577c79] | 34 | for why this mirrors `available_balance`/`invested_balance` on `Users`.
|
|---|
| [1549dae] | 35 | - **Orders**(<u>**id**</u>, *user_id* [`Places`], *market_id* [`PlacedOn`], side, type, status, quantity, filled_quantity, price, placed_at, executed_at)
|
|---|
| 36 | - Entity set `Orders`. `side ∈ {buy, sell}`, `type ∈ {market, limit}`,
|
|---|
| 37 | `status ∈ {open, partially_filled, executed, cancelled}`,
|
|---|
| 38 | `0 ≤ filled_quantity ≤ quantity`.
|
|---|
| 39 | - **Transactions**(<u>**id**</u>, *user_id* [`Records`], type, amount, currency, *related_order* [`Settles`], created_at, description)
|
|---|
| 40 | - Entity set `Transactions`. `type ∈ {deposit, buy, sell, fee}`.
|
|---|
| 41 | `related_order` is nullable (see below).
|
|---|
| 42 | - **MarketTrades**(<u>**id**</u>, *market_id* [`Fills`], executed_at, price, quantity, side, source, *buy_order_id* [`FillsBuy`], *sell_order_id* [`FillsSell`])
|
|---|
| 43 | - Entity set `MarketTrades`. `buy_order_id` and `sell_order_id` are both
|
|---|
| 44 | nullable (see below).
|
|---|
| 45 | - **OrderEvents**(<u>**id**</u>, *order_id* [`Logs`], event_type, quantity, price, status_after, created_at)
|
|---|
| 46 | - Entity set `OrderEvents`. `event_type ∈ {placed, partially_filled, filled, cancelled}`.
|
|---|
| 47 | - **MarketCandles**(<u>**id**</u>, *market_id* [`Aggregates`], timeframe, open, high, low, close, volume, candle_time)
|
|---|
| 48 | - Entity set `MarketCandles`. Candidate keys: `{id}`, `{market_id, timeframe,
|
|---|
| 49 | candle_time}` (the model's rule "one candle per market, timeframe and
|
|---|
| 50 | bucket"), enforced with `UNIQUE(market_id, timeframe, candle_time)`.
|
|---|
| 51 | - **Watchlists**(<u>**id**</u>, *user_id* [`Owns`], name, created_at)
|
|---|
| 52 | - Entity set `Watchlists`.
|
|---|
| 53 | - **WatchlistItems**(<u>**id**</u>, *watchlist_id* [`Contains`], *crypto_id* [`Lists`], added_at)
|
|---|
| 54 | - Entity set `WatchlistItems`. Candidate keys: `{id}` and `{watchlist_id,
|
|---|
| 55 | crypto_id}` (the model's rule "an asset at most once per list"), enforced
|
|---|
| 56 | with `UNIQUE(watchlist_id, crypto_id)`.
|
|---|
| [d8ce4e2] | 57 |
|
|---|
| 58 | ### Transformation method used
|
|---|
| 59 |
|
|---|
| [1549dae] | 60 | **Partial transformation.** The model has 11 entity sets and 15 relationships.
|
|---|
| 61 | Every relationship is binary and 1:N with no attributes of its own (the two M:N
|
|---|
| 62 | relationships of earlier versions, `Holds` and `Contains`, were corrected into
|
|---|
| 63 | the entity sets `Holdings` and `WatchlistItems` in v05). The rules:
|
|---|
| 64 |
|
|---|
| 65 | - **Each entity set becomes one relation**, with its own attributes and its
|
|---|
| 66 | own key `id` as primary key. 11 entity sets → 11 relations.
|
|---|
| 67 | - **Each 1:N relationship becomes one foreign key** on the relation of the "N"
|
|---|
| 68 | side, pointing to the primary key of the "1" side. No relationship gets its
|
|---|
| 69 | own table, because none is M:N and none has attributes. 15 relationships →
|
|---|
| 70 | 15 foreign keys:
|
|---|
| 71 |
|
|---|
| 72 | | ER relationship | 1 side → N side | Foreign key | Participation of the N side | `NULL`? |
|
|---|
| 73 | |---|---|---|---|---|
|
|---|
| 74 | | `QuotedOn` | Cryptos → Markets | `markets.crypto_id` | total | `NOT NULL` |
|
|---|
| 75 | | `PlacedOn` | Markets → Orders | `orders.market_id` | total | `NOT NULL` |
|
|---|
| 76 | | `Places` | Users → Orders | `orders.user_id` | total | `NOT NULL` |
|
|---|
| 77 | | `Records` | Users → Transactions | `transactions.user_id` | total | `NOT NULL` |
|
|---|
| 78 | | `Settles` | Orders → Transactions | `transactions.related_order` | partial | nullable |
|
|---|
| 79 | | `Fills` | Markets → MarketTrades | `market_trades.market_id` | total | `NOT NULL` |
|
|---|
| 80 | | `FillsBuy` | Orders → MarketTrades | `market_trades.buy_order_id` | partial | nullable |
|
|---|
| 81 | | `FillsSell` | Orders → MarketTrades | `market_trades.sell_order_id` | partial | nullable |
|
|---|
| 82 | | `Logs` | Orders → OrderEvents | `order_events.order_id` | total | `NOT NULL` |
|
|---|
| 83 | | `Aggregates` | Markets → MarketCandles | `market_candles.market_id` | total | `NOT NULL` |
|
|---|
| 84 | | `Owns` | Users → Watchlists | `watchlists.user_id` | total | `NOT NULL` |
|
|---|
| 85 | | `Holds` | Users → Holdings | `holdings.user_id` | total | `NOT NULL` |
|
|---|
| 86 | | `PositionIn` | Cryptos → Holdings | `holdings.crypto_id` | total | `NOT NULL` |
|
|---|
| 87 | | `Contains` | Watchlists → WatchlistItems | `watchlist_items.watchlist_id` | total | `NOT NULL` |
|
|---|
| 88 | | `Lists` | Cryptos → WatchlistItems | `watchlist_items.crypto_id` | total | `NOT NULL` |
|
|---|
| 89 |
|
|---|
| 90 | - **Participation decides `NULL`.** Total participation of the N side means
|
|---|
| 91 | every row must reference a parent, so the foreign key is `NOT NULL`. Partial
|
|---|
| 92 | participation leaves it nullable. There are exactly three partial ones:
|
|---|
| 93 | `Settles` (a deposit has no originating order), and `FillsBuy` / `FillsSell`
|
|---|
| 94 | (a trade against the simulated market has no user order on that side).
|
|---|
| 95 | Partial participation of the **1** side (for example, a user with no orders)
|
|---|
| 96 | needs no column at all. It simply means no row points at that parent.
|
|---|
| 97 | - **Uniqueness rules of the model become `UNIQUE` constraints.** The four
|
|---|
| 98 | rules the model states in words ("a crypto quoted once per currency", "one
|
|---|
| 99 | candle per market, timeframe and bucket", "one holding per user and crypto",
|
|---|
| 100 | "an asset once per list") involve a relationship, so Chen notation cannot
|
|---|
| 101 | draw them as keys. After transformation, the relationship is a foreign-key
|
|---|
| 102 | column, and each rule becomes an ordinary composite `UNIQUE` constraint, i.e. a
|
|---|
| 103 | second candidate key.
|
|---|
| 104 |
|
|---|
| 105 | Nothing in the schema comes from anywhere else. Every column is either an ER
|
|---|
| 106 | attribute or the foreign key of one listed relationship.
|
|---|
| [d8ce4e2] | 107 |
|
|---|
| 108 | ### Normalisation
|
|---|
| 109 |
|
|---|
| [1549dae] | 110 | > **Checked in P5.** [Normalization](../P5-Normalization/Normalization.md)
|
|---|
| 111 | > starts from a single de-normalized relation containing only the attributes
|
|---|
| 112 | > of the ER model and the functional dependencies that follow from its rules.
|
|---|
| 113 | > It decomposes that relation step by step to BCNF and arrives at these same 11
|
|---|
| 114 | > relations, with one deliberate difference: `transactions.user_id` (see the last
|
|---|
| 115 | > bullet below). The comparison is in the *Discussion* section at the end of that
|
|---|
| 116 | > page.
|
|---|
| [9577c79] | 117 |
|
|---|
| [1549dae] | 118 | All relations except `transactions` are in **BCNF**, as P5 shows. `transactions`
|
|---|
| 119 | is in 2NF but not in 3NF, because of the deliberately kept `user_id` (last
|
|---|
| 120 | bullet):
|
|---|
| [d8ce4e2] | 121 |
|
|---|
| 122 | - Every attribute is atomic (no repeating groups, no composite fields).
|
|---|
| [1549dae] | 123 | - No partial dependency exists: every candidate key is either the single
|
|---|
| 124 | column `id` or a composite key (`{user_id, crypto_id}`, …) on which no
|
|---|
| 125 | non-key attribute depends only partially.
|
|---|
| 126 | - No transitive dependency exists, except `transactions.user_id` (last bullet):
|
|---|
| 127 | every other non-key attribute depends directly on the row's own entity, never
|
|---|
| 128 | on another entity reached through a foreign key.
|
|---|
| 129 | For example, `holdings.quantity` depends on `holdings.id`, and nothing about
|
|---|
| 130 | the user or the crypto is copied into `holdings`.
|
|---|
| [d8ce4e2] | 131 | - `avg_price` in `Holdings` is a **derived value** cached for performance (it is
|
|---|
| 132 | the weighted-average entry price across all `buy` transactions for that
|
|---|
| 133 | `(user, crypto)` pair) — it is drawn as a derived attribute in the ER diagram.
|
|---|
| 134 | We accept the denormalisation: it is recomputed by the database inside the same
|
|---|
| 135 | transaction as each buy, in the same statement that changes the quantity
|
|---|
| 136 | (`INSERT … ON CONFLICT (user_id, crypto_id) DO UPDATE`), so the stored average
|
|---|
| 137 | and the stored quantity can never disagree.
|
|---|
| 138 | - `avg_price` is declared `NOT NULL DEFAULT 0`. This matters: it is used in the
|
|---|
| 139 | P/L arithmetic of `v_portfolio`, and in SQL any arithmetic involving `NULL`
|
|---|
| 140 | yields `NULL`, so a nullable average would have silently blanked the
|
|---|
| 141 | unrealised-P/L column for an existing position instead of failing loudly.
|
|---|
| [9577c79] | 142 | - `holdings.reserved_quantity`, unlike `avg_price`, is **not** derived — it is
|
|---|
| 143 | written directly by the application (`trade.go`) as orders are placed and
|
|---|
| 144 | settled, the same way `quantity` itself is. `quantity - reserved_quantity`
|
|---|
| 145 | ("available") is the derived value here, and it is never stored, only
|
|---|
| 146 | computed where it is needed.
|
|---|
| [1549dae] | 147 | - `transactions.user_id` is kept **deliberately**, although for an entry that
|
|---|
| 148 | settles an order it repeats that order's user (`related_order → user_id`, a
|
|---|
| 149 | transitive dependency). A deposit has no order (`Settles` is partial), so
|
|---|
| 150 | `user_id` is the only way to record whose deposit it is. For entries with an
|
|---|
| 151 | order, the only code that sets `related_order` (the buy and sell inserts in
|
|---|
| 152 | `advanced_db.sql`) writes both from the same order row. No database
|
|---|
| 153 | constraint enforces this.
|
|---|
| [9577c79] | 154 |
|
|---|
| 155 | ### Reservation and the order lifecycle
|
|---|
| 156 |
|
|---|
| 157 | `holdings.reserved_quantity` exists so that placing a sell order can be
|
|---|
| 158 | checked against what a user actually has *free* to sell
|
|---|
| 159 | (`quantity - reserved_quantity`), not against the raw `quantity`, which also
|
|---|
| 160 | counts crypto already promised to another order that has not settled yet.
|
|---|
| 161 | `CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` makes an
|
|---|
| 162 | inconsistent reservation impossible at the database level, regardless of what
|
|---|
| 163 | application code does. The exact statement sequence — lock the row, check the
|
|---|
| 164 | available amount, reserve, then settle — is in
|
|---|
| 165 | [UseCase0005](../P3-UseCaseModel/UseCase0005.md); the same
|
|---|
| 166 | `SELECT … FOR UPDATE` locking that already protected `users.available_balance`
|
|---|
| 167 | on the buy path is what makes two concurrent sell orders against the same
|
|---|
| [1549dae] | 168 | holding serialize correctly instead of racing. The cash side of a buy order
|
|---|
| 169 | (`users.reserved_balance`), `orders.filled_quantity` and `order_events` were
|
|---|
| 170 | added in P7; see
|
|---|
| 171 | [AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md).
|
|---|
| [d8ce4e2] | 172 |
|
|---|
| 173 | ## DDL script
|
|---|
| 174 |
|
|---|
| [1549dae] | 175 | The script that creates the schema is [`../server/db/schema_creation.sql`](../../server/db/schema_creation.sql). It is idempotent: it drops and recreates the `project` schema every run, so it works on an empty database and on a database that already has the schema.
|
|---|
| [d8ce4e2] | 176 |
|
|---|
| 177 | The script creates:
|
|---|
| [1549dae] | 178 | - 10 of the 11 tables, with check constraints, primary keys, foreign keys and unique constraints.
|
|---|
| 179 | - 8 performance indexes.
|
|---|
| [9577c79] | 180 | - 2 views: `v_latest_prices` (latest trade price per market) and `v_portfolio` (per-user holdings valuation with unrealised P/L, plus `reserved_quantity` and the derived `available_quantity`).
|
|---|
| [d8ce4e2] | 181 |
|
|---|
| [1549dae] | 182 | The 11th table, `order_events`, is created by
|
|---|
| 183 | [`../server/db/advanced_db.sql`](../../server/db/advanced_db.sql) together with
|
|---|
| 184 | the P7 triggers that fill it. `./eduberza -init` runs both scripts in that
|
|---|
| 185 | order, so a freshly initialised database always has all 11 tables and all 15
|
|---|
| 186 | foreign keys.
|
|---|
| 187 |
|
|---|
| [d8ce4e2] | 188 | ## DML script (sample data)
|
|---|
| 189 |
|
|---|
| 190 | The script that loads realistic sample data is [`../server/db/data_load.sql`](../../server/db/data_load.sql). It is idempotent: it truncates all tables with `CASCADE` then re-inserts. Loaded:
|
|---|
| 191 | - 5 crypto assets (BTC, ETH, ADA, SOL, DOGE) and 5 USD-quoted markets.
|
|---|
| 192 | - 3 sample users (`alice`, `bob`, `charlie`) with password `test123` (sha256 hex).
|
|---|
| 193 | - 18 recent market trades across all markets so `v_latest_prices` is populated.
|
|---|
| 194 | - 10 one-hour candles (BTC and ETH).
|
|---|
| 195 | - One fully-executed market-buy order for Alice, the matching holding, and two ledger entries (deposit + buy), with Alice's balances updated accordingly.
|
|---|
| 196 | - Two watchlists with five watchlist items.
|
|---|
| 197 |
|
|---|
| 198 | ## Relational diagram
|
|---|
| 199 |
|
|---|
| [1549dae] | 200 | 
|
|---|
| [d8ce4e2] | 201 |
|
|---|
| [1549dae] | 202 | Generated in **DBeaver** from the **live** `project` schema (after
|
|---|
| 203 | `./eduberza -init`), not drawn by hand, so it shows what the deployed database
|
|---|
| 204 | actually contains. Each box is a table with its columns; the key icon marks the
|
|---|
| 205 | primary key, and the lines are the 15 declared foreign keys. The two foreign keys from
|
|---|
| 206 | `market_trades` to `orders` (`buy_order_id`, `sell_order_id`) connect the same
|
|---|
| 207 | two boxes, so DBeaver draws them on top of each other as one line.
|
|---|
| [d8ce4e2] | 208 |
|
|---|
| [1549dae] | 209 | The tables are arranged in the **same positions** as the entity sets in
|
|---|
| 210 | `ERModel_v05.png`, so the two can be compared directly:
|
|---|
| [d8ce4e2] | 211 |
|
|---|
| [1549dae] | 212 | - every rectangle of the ER diagram is one table in the same place;
|
|---|
| 213 | - every diamond of the ER diagram is one foreign-key line between the same two
|
|---|
| 214 | boxes. The dot is on the referencing ("N") table, next to the foreign-key
|
|---|
| 215 | column;
|
|---|
| 216 | - a double (total) line in the ER diagram is a `NOT NULL` foreign key, drawn by
|
|---|
| 217 | DBeaver as a solid line. The three single lines on the N side (`Settles`,
|
|---|
| 218 | `FillsBuy`, `FillsSell`) are the three nullable foreign keys, which DBeaver
|
|---|
| 219 | draws dashed, with a hollow diamond on the `orders` side. The table under
|
|---|
| 220 | [Transformation method used](#transformation-method-used) lists all 15.
|
|---|
| [d8ce4e2] | 221 |
|
|---|
| [1549dae] | 222 | Earlier images (`relational_schema.jpg`, `relational_schema_v2.png`,
|
|---|
| 223 | `relational_schema_v3.png`) were exported from pgAdmin, with a different layout
|
|---|
| 224 | and from an older schema. They are kept only as history.
|
|---|
| 225 |
|
|---|
| 226 | ### How to regenerate it
|
|---|
| [d8ce4e2] | 227 |
|
|---|
| [1549dae] | 228 | 1. Initialise the database: `./eduberza -init` (runs `schema_creation.sql` and
|
|---|
| 229 | `advanced_db.sql`, so `order_events` is included).
|
|---|
| 230 | 2. In DBeaver, connect to the project database and expand
|
|---|
| 231 | *Schemas → project → Tables*.
|
|---|
| 232 | 3. Select all 11 tables → right-click → **View Diagram** (or create a new ER
|
|---|
| 233 | diagram and drag the tables in).
|
|---|
| 234 | 4. Drag each table to the position of its entity set in `ERModel_v05.png`:
|
|---|
| 235 |
|
|---|
| 236 | ```
|
|---|
| 237 | column 1 column 2 column 3 column 4
|
|---|
| 238 | row 1 watchlist_items crypto markets market_candles
|
|---|
| 239 | row 2 watchlists holdings . market_trades
|
|---|
| 240 | row 3 users . orders .
|
|---|
| 241 | row 4 . transactions order_events .
|
|---|
| 242 | ```
|
|---|
| 243 |
|
|---|
| 244 | Leave the empty cells (`.`) empty. They are where the relationship
|
|---|
| 245 | diamonds are in the ER diagram, so the foreign-key lines will run through
|
|---|
| 246 | the same gaps.
|
|---|
| 247 |
|
|---|
| 248 | 5. Right-click the canvas → **Export diagram** → PNG, saved as
|
|---|
| 249 | `relational_diagram_v4.png` in this folder.
|
|---|