source: docs/P2-RelationalDesign/RelationalDesign.md

main
Last change on this file was 1549dae, checked in by Stefan <trsunovstefan@…>, 4 hours ago

Correct P1/P2 consistency (Holdings, WatchlistItems), redo P5 normalization

  • Property mode set to 100644
File size: 15.5 KB
RevLine 
[d8ce4e2]1# Relational Design
2
[1549dae]3This page transforms [ERModel](../P1-ConceptualModel/ERModel.md) **v05** into
4relations. Every relation below corresponds to exactly one entity set of the
5model, and every foreign key corresponds to exactly one relationship, so the
6two 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]11Notation: **bold** = primary key, *italic* = foreign key. After each foreign key
12comes 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.
61Every relationship is binary and 1:N with no attributes of its own (the two M:N
62relationships of earlier versions, `Holds` and `Contains`, were corrected into
63the 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
105Nothing in the schema comes from anywhere else. Every column is either an ER
106attribute 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]118All relations except `transactions` are in **BCNF**, as P5 shows. `transactions`
119is in 2NF but not in 3NF, because of the deliberately kept `user_id` (last
120bullet):
[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
158checked against what a user actually has *free* to sell
159(`quantity - reserved_quantity`), not against the raw `quantity`, which also
160counts crypto already promised to another order that has not settled yet.
161`CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)` makes an
162inconsistent reservation impossible at the database level, regardless of what
163application code does. The exact statement sequence — lock the row, check the
164available 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`
167on the buy path is what makes two concurrent sell orders against the same
[1549dae]168holding serialize correctly instead of racing. The cash side of a buy order
169(`users.reserved_balance`), `orders.filled_quantity` and `order_events` were
170added in P7; see
171[AdvancedDatabaseDevelopment](../P7-AdvancedDatabaseDevelopment/AdvancedDatabaseDevelopment.md).
[d8ce4e2]172
173## DDL script
174
[1549dae]175The 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
177The 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]182The 11th table, `order_events`, is created by
183[`../server/db/advanced_db.sql`](../../server/db/advanced_db.sql) together with
184the P7 triggers that fill it. `./eduberza -init` runs both scripts in that
185order, so a freshly initialised database always has all 11 tables and all 15
186foreign keys.
187
[d8ce4e2]188## DML script (sample data)
189
190The 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![relational_diagram_v4](relational_diagram_v4.png)
[d8ce4e2]201
[1549dae]202Generated in **DBeaver** from the **live** `project` schema (after
203`./eduberza -init`), not drawn by hand, so it shows what the deployed database
204actually contains. Each box is a table with its columns; the key icon marks the
205primary 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
207two boxes, so DBeaver draws them on top of each other as one line.
[d8ce4e2]208
[1549dae]209The 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]222Earlier images (`relational_schema.jpg`, `relational_schema_v2.png`,
223`relational_schema_v3.png`) were exported from pgAdmin, with a different layout
224and from an older schema. They are kept only as history.
225
226### How to regenerate it
[d8ce4e2]227
[1549dae]2281. Initialise the database: `./eduberza -init` (runs `schema_creation.sql` and
229 `advanced_db.sql`, so `order_events` is included).
2302. In DBeaver, connect to the project database and expand
231 *Schemas → project → Tables*.
2323. Select all 11 tables → right-click → **View Diagram** (or create a new ER
233 diagram and drag the tables in).
2344. 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
2485. Right-click the canvas → **Export diagram** → PNG, saved as
249 `relational_diagram_v4.png` in this folder.
Note: See TracBrowser for help on using the repository browser.