source: docs/P5-Normalization/wiki/Normalization.md@ 0cee8ec

main
Last change on this file since 0cee8ec was ef1c1c7, checked in by Stefan <trsunovstefan@…>, 6 days ago

Wiki docs, phase 6 and phase 7 added

  • Property mode set to 100644
File size: 38.1 KB
Line 
1= Normalization =
2
3This phase deliberately ignores the design from [wiki:ERModel]
4(P1) and [wiki:RelationalDesign] (P2) as a starting
5point. Instead it starts over from a single flat relation containing every attribute of the
6model, derives the functional dependencies that hold on it, and decomposes it formally,
7step by step. The
8final section compares what falls out of that process with
9the P2 design.
10
11== De-normalized database form ==
12
13=== Building one relation out of the whole model ===
14
15The ER model has ten entity/relationship sets carrying attributes (see
16[wiki:ERModel]): `Users`, `Cryptos`, `Markets`, `Orders`,
17`Transactions`, `MarketTrades`, `MarketCandles`, `Watchlists`, and the two attributed
18relationships `Holds` and `Contains`. Eight more relationships (`QuotedOn`, `PlacedOn`,
19`Places`, `Records`, `Settles`, `Fills`, `Aggregates`, `Owns`) carry no attributes of their
20own — in Chen notation they need none, because the diagram expresses the link itself as a
21relationship, not a column. A single flat relation has no such device: the only way to keep
22one entity's rows pointed at another's is a plain attribute holding the referenced key,
23which is exactly what P2's ER-to-relational transformation already introduces for each of
24those eight relationships (`markets.crypto_id`, `orders.market_id`, `orders.user_id`,
25`transactions.user_id`, `transactions.related_order`, `market_trades.market_id`,
26`market_candles.market_id`, `watchlists.user_id`). Those linking attributes are included
27below for that reason — not because they were copied from P2's design, but because a "single
28table with everything in it" cannot represent the model at all without them.
29
30Every attribute name is prefixed by a two-or-three-letter code for the entity/relationship it
31came from, because several names repeat across the model (`id`, `created_at`, `quantity`,
32`type`, `name`, `price`, `side` all appear more than once) and the de-normalized relation may
33not contain duplicate names.
34
35||= Prefix =||= Origin (P1 entity / relationship) =||= Attributes =||
36|| `U_` || Users || `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` ||
37|| `C_` || Cryptos || `C_ID, C_SYMBOL, C_NAME, C_CREATED_AT` ||
38|| `M_` || Markets (+ `QuotedOn`) || `M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` ||
39|| `H_` || `Holds` (+ surrogate key) || `H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` ||
40|| `O_` || Orders (+ `PlacedOn`, `Places`) || `O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` ||
41|| `T_` || Transactions (+ `Records`, `Settles`) || `T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` ||
42|| `MT_` || !MarketTrades (+ `Fills`) || `MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` ||
43|| `MC_` || !MarketCandles (+ `Aggregates`) || `MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` ||
44|| `W_` || Watchlists (+ `Owns`) || `W_ID, W_USER_ID, W_NAME, W_CREATED_AT` ||
45|| `WI_` || `Contains` (+ surrogate key) || `WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` ||
46
47`H_ID` and `WI_ID` exist for the same reason they exist in P2: `Holds` and `Contains` are M:N
48relationships with their own attributes, and giving each its own surrogate key (rather than
49relying solely on the `{user,crypto}` / `{watchlist,crypto}` pair) is the same design choice
50already justified in [wiki:RelationalDesign] (section "Descriptive representation of the relational schema").
51
52This gives '''one relation, `R_EDUBERZA`, of 68 attributes:'''
53
54{{{
55R_EDUBERZA(
56 U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE,
57 U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT,
58 C_ID, C_SYMBOL, C_NAME, C_CREATED_AT,
59 M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT,
60 H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE,
61 H_CREATED_AT, H_UPDATED_AT,
62 O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE,
63 O_PLACED_AT, O_EXECUTED_AT,
64 T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT,
65 T_DESCRIPTION,
66 MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE,
67 MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME,
68 MC_CANDLE_TIME,
69 W_ID, W_USER_ID, W_NAME, W_CREATED_AT,
70 WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT
71)
72}}}
73
74Every attribute is single-valued and atomic (a balance, a timestamp, a symbol, an amount —
75nothing here is a list or a nested record), so `R_EDUBERZA` satisfies 1NF as soon as it is
76written down. Whether it satisfies anything beyond that is exactly what the rest of this page
77checks.
78
79== Functional dependencies ==
80
81=== Canonical cover ===
82
83Read directly off the model: each entity's/relationship's own key determines its own
84attributes, nothing more. This is already minimal — no functional dependency below has an
85extraneous attribute on its left side, and no dependent attribute is repeated on the right
86side of more than one dependency, which is what "canonical cover" requires.
87
88||= # =||= Functional dependency =||= Source =||
89|| FD1 || `U_ID → U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` || Users ||
90|| FD2 || `U_USERNAME → U_ID` || Users (`UNIQUE(username)`) ||
91|| FD3 || `U_EMAIL → U_ID` || Users (`UNIQUE(email)`) ||
92|| FD4 || `C_ID → C_SYMBOL, C_NAME, C_CREATED_AT` || Cryptos ||
93|| FD5 || `C_SYMBOL → C_ID` || Cryptos (`UNIQUE(symbol)`) ||
94|| FD6 || `M_ID → M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` || Markets ||
95|| FD7 || `M_CRYPTO_ID, M_QUOTE_CURRENCY → M_ID` || Markets (`UNIQUE(crypto_id, quote_currency)`) ||
96|| FD8 || `H_ID → H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` || Holds ||
97|| FD9 || `H_USER_ID, H_CRYPTO_ID → H_ID` || Holds (`UNIQUE(user_id, crypto_id)`) ||
98|| FD10 || `O_ID → O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` || Orders ||
99|| FD11 || `T_ID → T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` || Transactions ||
100|| FD12 || `MT_ID → MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` || !MarketTrades ||
101|| FD13 || `MC_ID → MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` || !MarketCandles ||
102|| FD14 || `MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID` || !MarketCandles (`UNIQUE(market_id, timeframe, candle_time)`) ||
103|| FD15 || `W_ID → W_USER_ID, W_NAME, W_CREATED_AT` || Watchlists ||
104|| FD16 || `WI_ID → WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` || Contains ||
105|| FD17 || `WI_WATCHLIST_ID, WI_CRYPTO_ID → WI_ID` || Contains (`UNIQUE(watchlist_id, crypto_id)`) ||
106
107'''Minimality, checked by example (Markets):''' could FD7 drop an attribute from its left side?
108`M_CRYPTO_ID` alone does not determine `M_ID` — many markets can reference the same crypto in
109different quote currencies (that is the entire point of the market entity), so two rows can
110share `M_CRYPTO_ID` and disagree on `M_ID`. `M_QUOTE_CURRENCY` alone fails the same way in the
111other direction. Neither attribute is extraneous, so the left side of FD7 cannot shrink. The
112same check applies to FD9, FD14 and FD17, whose composite left sides come directly from the
113`UNIQUE` constraints already justified per-relation in
114[wiki:RelationalDesign]; none of those constraints
115holds on a proper subset of its columns either.
116
117'''No redundant dependency:''' each of FD1–FD17 has a right side that is not implied by any
118other dependency in the set — for instance, nothing outside FD1 mentions `U_AVAILABLE_BALANCE`,
119so FD1 cannot be derived from the rest and cannot be dropped. This set is the canonical cover.
120
121=== Dependencies carried by foreign keys ===
122
123Six attributes above are foreign keys: `M_CRYPTO_ID`, `H_USER_ID`, `H_CRYPTO_ID`,
124`O_USER_ID`, `O_MARKET_ID`, `T_USER_ID`, `T_RELATED_ORDER`, `MT_MARKET_ID`, `MC_MARKET_ID`,
125`W_USER_ID`, `WI_WATCHLIST_ID`, `WI_CRYPTO_ID` — each one draws its values from the same
126domain as some other attribute's key. Because of that, every dependency that holds on the
127referenced key also holds, by substitution, on the referencing attribute:
128
129||= Foreign key =||= References =||= Therefore also determines =||
130|| `M_CRYPTO_ID` || `C_ID` || `C_SYMBOL, C_NAME, C_CREATED_AT` ||
131|| `H_USER_ID` || `U_ID` || all of `U_*` ||
132|| `H_CRYPTO_ID` || `C_ID` || all of `C_*` ||
133|| `O_USER_ID` || `U_ID` || all of `U_*` ||
134|| `O_MARKET_ID` || `M_ID` || all of `M_*`, and transitively all of `C_*` ||
135|| `T_USER_ID` || `U_ID` || all of `U_*` ||
136|| `T_RELATED_ORDER` || `O_ID` || all of `O_*`, and transitively `U_*`, `M_*`, `C_*` (when not null) ||
137|| `MT_MARKET_ID` || `M_ID` || all of `M_*`, transitively `C_*` ||
138|| `MC_MARKET_ID` || `M_ID` || all of `M_*`, transitively `C_*` ||
139|| `W_USER_ID` || `U_ID` || all of `U_*` ||
140|| `WI_WATCHLIST_ID` || `W_ID` || all of `W_*`, transitively `U_*` ||
141|| `WI_CRYPTO_ID` || `C_ID` || all of `C_*` ||
142
143None of these is added to the canonical cover — each is ''derivable'' from FD1–FD17 by
144transitivity plus the foreign-key identity, which is exactly why a canonical cover excludes
145them. They matter anyway: they are precisely the transitive dependencies the 3NF check below
146has to rule out.
147
148== Candidate keys and primary key ==
149
150`Orders`, `Transactions`, `MarketTrades`, `MarketCandles`, `Holds`, `Watchlists` and
151`Contains` are, with respect to each other, independent record types: nothing about an
152order's id says anything about which market-candle row, or which unrelated transaction, or
153which watchlist item is in the same tuple of `R_EDUBERZA` — a user can exist with zero of any
154of them, and having one order says nothing about how many holdings, trades or candles exist
155alongside it. (The one FK that crosses between two of these — `T_RELATED_ORDER` — is
156nullable, so it cannot be relied on to always connect a transaction row back to an order.)
157That means no proper subset of attributes can functionally determine all 68 attributes of
158`R_EDUBERZA`: the only way to pin down a `H_*` value, an `O_*` value, a `T_*` value, an
159`MT_*` value, an `MC_*` value, a `W_*` value ''and'' a `WI_*` value at once is to state one
160identifying attribute from each cluster explicitly.
161
162'''Chosen primary key''' (closure shown below):
163
164{{{
165{ U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID }
166}}}
167
168'''Closure check''', applying FD1–FD17 in turn to this set:
169
170||= Step =||= Attributes added =||= Dependency used =||
171|| start || `U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID` || — ||
172|| 1 || `U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` || FD1 (`U_ID → …`) ||
173|| 2 || `C_SYMBOL, C_NAME, C_CREATED_AT` || FD4 ||
174|| 3 || `M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` || FD6 ||
175|| 4 || `H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` || FD8 ||
176|| 5 || `O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` || FD10 ||
177|| 6 || `T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` || FD11 ||
178|| 7 || `MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` || FD12 ||
179|| 8 || `MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` || FD13 ||
180|| 9 || `W_USER_ID, W_NAME, W_CREATED_AT` || FD15 ||
181|| 10 || `WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` || FD16 ||
182
183The closure now contains all 68 attributes, so the set is a superkey; removing any one of its
184ten attributes drops an entire cluster that nothing else in the set can reach (e.g. drop
185`T_ID` and no remaining attribute determines any `T_*` value), so it is minimal — a candidate
186key.
187
188'''It is not the only one.''' Any attribute that is itself a determinant of a whole cluster can
189stand in for that cluster's id — `U_USERNAME` or `U_EMAIL` for `U_ID` (FD2/FD3), `C_SYMBOL`
190for `C_ID` (FD5), `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` for `M_ID` (FD7), `{H_USER_ID, H_CRYPTO_ID}` for `H_ID` (FD9), `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` for `MC_ID`
191(FD14), `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` for `WI_ID` (FD17) — giving 3 × 2 × 2 × 2 × 1 × 1 ×
1921 × 2 × 1 × 2 = 96 candidate keys in total. The all-surrogate-id combination above is chosen
193as '''primary key''' for the same reason `id` was chosen over `username`/`email`/`symbol`/etc.
194per entity in [wiki:ERModel]: it is opaque, and none of its parts
195are things a user would ever legitimately change.
196
197'''Normal form of `R_EDUBERZA` before decomposition:''' 1NF only, and barely that — see 2NF
198below. It cannot be in 2NF, 3NF or BCNF, since each of those requires 2NF as a precondition.
199
200== 1NF decomposition ==
201
202No decomposition happens at this step. 1NF requires atomic, single-valued attributes and no
203repeating groups; `R_EDUBERZA` was built that way from the start (every column above is a
204single scalar), so the relation already satisfies 1NF as written in
205De-normalized database form. The real work starts at 2NF.
206
207== 2NF decomposition ==
208
209'''Relation analyzed:''' `R_EDUBERZA`, all 68 attributes, primary key
210`{U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID}` (10 attributes), FD1–FD17
211in force.
212
213'''Current normal form:''' 1NF only (previous section).
214
215'''Violations:''' 2NF forbids a non-prime attribute from depending on ''part'' of a candidate
216key. Every single functional dependency in the canonical cover (FD1–FD17) has a left side
217that is a '''proper subset''' of the ten-attribute primary key — `U_ID` alone, `C_ID` alone, …,
218down to the two-attribute `{WI_WATCHLIST_ID, WI_CRYPTO_ID}`. There is no non-prime attribute
219in `R_EDUBERZA` that depends on the whole ten-attribute key and nothing smaller. In other
220words, ''every'' non-prime attribute violates 2NF at once — the violation is not a handful of
221stray columns to peel off, it is the entire relation, because gluing ten independent record
222types together under one artificial composite key was never going to satisfy 2NF to begin
223with.
224
225'''Decomposition.''' This uses 3NF/BCNF '''synthesis''' (Bernstein's algorithm) rather than the
226binary decomposition algorithm: since the canonical cover is already in hand (as the phase
227instructions recommend building first), synthesis creates one relation per left-hand side in
228the cover directly, instead of hunting for one offending dependency at a time and splitting
229in two repeatedly. Grouping FD1–FD17 by determinant produces ten relations:
230
231||= New relation =||= Attributes =||= Key(s) =||= Source FDs =||
232|| `R_USERS` || `U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT` || `U_ID`, `U_USERNAME`, `U_EMAIL` || FD1, FD2, FD3 ||
233|| `R_CRYPTO` || `C_ID, C_SYMBOL, C_NAME, C_CREATED_AT` || `C_ID`, `C_SYMBOL` || FD4, FD5 ||
234|| `R_MARKETS` || `M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT` || `M_ID`, `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` || FD6, FD7 ||
235|| `R_HOLDINGS` || `H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT` || `H_ID`, `{H_USER_ID, H_CRYPTO_ID}` || FD8, FD9 ||
236|| `R_ORDERS` || `O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT` || `O_ID` || FD10 ||
237|| `R_TRANSACTIONS` || `T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION` || `T_ID` || FD11 ||
238|| `R_MARKET_TRADES` || `MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE` || `MT_ID` || FD12 ||
239|| `R_MARKET_CANDLES` || `MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME` || `MC_ID`, `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` || FD13, FD14 ||
240|| `R_WATCHLISTS` || `W_ID, W_USER_ID, W_NAME, W_CREATED_AT` || `W_ID` || FD15 ||
241|| `R_WATCHLIST_ITEMS` || `WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT` || `WI_ID`, `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` || FD16, FD17 ||
242
243Every one of these ten relations now has '''all''' of its non-prime attributes depending on its
244'''whole''' key (in every case there is only one non-composite or one designated key doing the
245determining, so 2NF holds trivially in each).
246
247'''Dependency preservation.''' FD1–FD17 is the canonical cover of `R_EDUBERZA`. Each FD's
248determinant and every one of its dependent attributes land inside exactly one of the ten new
249relations (see the "Source FDs" column above — no FD is split across two relations). The
250union of the FDs that hold on `R_USERS, …, R_WATCHLIST_ITEMS` is therefore exactly FD1–FD17
251again: nothing was lost.
252
253'''Lossless join — chase test.'''
254
255> ''Note: the chase algorithm is not part of the course material. I was curious about a stricter way to test lossless join than the usual "the common attributes are a key of one side" argument, so I applied it here.''
256
257The chase decides whether a decomposition `R = R1 ∪ … ∪ Rn` is lossless under a set of
258functional dependencies. Build a tableau with one column per attribute of `R` and one row per
259relation `Ri`. In row `i`, put a distinguished symbol `a` in every column of `Ri` and a unique
260symbol `b_i` in every other column. Then repeat, until nothing changes: for each FD `X → Y`,
261whenever two rows agree on all of `X`, make them agree on `Y`. If they disagree, an `a` wins,
262otherwise one `b` replaces the other. '''The decomposition is lossless exactly when some row ends up with `a` in every column.'''
263
264All attributes of one cluster (`U_*`, `C_*`, `M_*`, …) always appear together, and FD1–FD17 never mix clusters. So each cluster is one column group below: `a` means every column of the group holds a distinguished symbol, and `b` means none of them does. The foreign-key attributes (`H_USER_ID`, `O_MARKET_ID`, …) belong to their own cluster (`H_*`, `O_*`, …), not to the cluster they reference.
265
266'''Step 1 — the ten relations from the table above.'''
267
268{{{
269 U* C* M* H* O* T* MT* MC* W* WI*
270R_USERS a b b b b b b b b b
271R_CRYPTO b a b b b b b b b b
272R_MARKETS b b a b b b b b b b
273R_HOLDINGS b b b a b b b b b b
274R_ORDERS b b b b a b b b b b
275R_TRANSACTIONS b b b b b a b b b b
276R_MARKET_TR. b b b b b b a b b b
277R_MARKET_CA. b b b b b b b a b b
278R_WATCHLISTS b b b b b b b b a b
279R_WATCHLIST_I. b b b b b b b b b a
280}}}
281
282Every FD has its left side inside one cluster, for example `U_ID → U_*` or `H_USER_ID, H_CRYPTO_ID → H_ID`. For such an FD to fire, two rows would have to agree on that left side. But only one row has `a`s in that cluster, and the `b`s of different rows are all different, so no two rows ever agree on any left side. ''*The chase changes nothing, and no row becomes all `a`.'''' Under FD1–FD17 alone, the ten relations are ''not'' guaranteed to join back to `R_EDUBERZA`. This is not an accident of this model. It is exactly why Bernstein's synthesis algorithm has a final step: *if no synthesised relation contains a candidate key of `R`, add one that does.'' None of the ten contains the ten-attribute key.
283
284'''Step 2 — add the key relation''' `R_KEY(U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID,
285W_ID, WI_ID)`. Its row has `a` only in the ten ID columns, written `a·` for "`a` in the ID,
286`b` in the rest of the group":
287
288{{{
289 U* C* M* H* O* T* MT* MC* W* WI*
290R_KEY a· a· a· a· a· a· a· a· a· a·
291(the ten rows of step 1 unchanged)
292}}}
293
294Now FD1 `U_ID → U_*` fires: row `R_KEY` and row `R_USERS` both have `a` in `U_ID`, so they must agree on the rest of `U_*`, and `R_USERS` has `a` there. `R_KEY` becomes `a` in the whole
295`U*` group. The same happens with FD4 (`C*`), FD6 (`M*`), FD8 (`H*`), FD10 (`O*`), FD11 (`T*`),
296FD12 (`MT*`), FD13 (`MC*`), FD15 (`W*`) and FD16 (`WI*`):
297
298{{{
299 U* C* M* H* O* T* MT* MC* W* WI*
300R_KEY a a a a a a a a a a <- all distinguished
301}}}
302
303'''Row `R_KEY` is all `a`, so the decomposition into the ten relations plus `R_KEY` is lossless.'''
304
305'''Why `R_KEY` is not kept in the final schema.''' An instance of `R_KEY` would only record
306which ID of one cluster appears together with which ID of every other cluster. As shown under
307''Candidate keys and primary key'', the ten clusters are independent record types, and
308`R_EDUBERZA` pairs every row of one with every row of the others. So `R_KEY` would be just the
309cross product of the ten ID sets and would carry no information. The same independence means
310the join dependency `⋈[R_USERS, …, R_WATCHLIST_ITEMS]` holds on `R_EDUBERZA` by construction.
311Under that dependency the ten relations alone already reconstruct it: their natural join, with
312no common attributes, is exactly that cross product. The chase makes this reasoning explicit.
313FDs by themselves cannot prove the join lossless; you need either the key relation or the
314independence of the clusters. That was hidden in the earlier "foreign key equals primary key"
315argument, which described the equi-joins the application runs, not the natural join the
316lossless-join property is about.
317
318== 3NF decomposition ==
319
320'''Relations analyzed:''' each of the ten relations produced above, individually.
321
322For each relation, 3NF asks whether any non-prime attribute is ''transitively'' dependent on a
323key — i.e. determined by another non-prime attribute rather than directly by the key. This is
324exactly where the foreign-key-carried dependencies from
325Dependencies carried by foreign keys have to be
326checked, because that table is precisely the list of "dependency that would cause a problem at
327the next higher normal form" the phase template asks for.
328
329'''Worked example — `R_MARKETS`.''' Its key `M_ID` determines `M_CRYPTO_ID`, and
330`M_CRYPTO_ID → C_SYMBOL, C_NAME, C_CREATED_AT` also holds (`M_CRYPTO_ID` draws its values from
331`C_ID`'s domain). If `C_SYMBOL`, `C_NAME` and `C_CREATED_AT` were still columns of
332`R_MARKETS`, this would be exactly the transitive dependency `M_ID → M_CRYPTO_ID → C_SYMBOL`
333that violates 3NF. They are not: the 2NF step above already put them in `R_CRYPTO`, keyed
334directly by `C_ID` (FD4), because FD4 — not the derived `M_CRYPTO_ID → C_SYMBOL` — is what the
335canonical cover actually contains. `R_MARKETS` itself has no attribute that determines another
336non-prime attribute of `R_MARKETS`; the transitive dependency is real, but it points ''out'' of
337the relation, not within it.
338
339The same reasoning applies to every other foreign key in the list: `H_USER_ID`/`H_CRYPTO_ID`,
340`O_USER_ID`/`O_MARKET_ID`, `T_USER_ID`/`T_RELATED_ORDER`, `MT_MARKET_ID`, `MC_MARKET_ID`,
341`W_USER_ID`, `WI_WATCHLIST_ID`/`WI_CRYPTO_ID` are all foreign keys sitting ''alongside'' a
342non-key attribute set that depends only on their own relation's key, never on the foreign key
343itself. None of `R_USERS`, `R_CRYPTO`, `R_HOLDINGS`, `R_ORDERS`, `R_TRANSACTIONS`,
344`R_MARKET_TRADES`, `R_MARKET_CANDLES`, `R_WATCHLISTS`, `R_WATCHLIST_ITEMS` has a non-prime
345attribute that another non-prime attribute of the ''same'' relation determines.
346
347'''Conclusion:''' synthesising directly from the canonical cover in the 2NF step already
348avoided every transitive dependency — there is nothing left to decompose for 3NF. All ten
349relations from the previous section satisfy 3NF unchanged.
350
351== BCNF if possible ==
352
353'''Relations analyzed:''' the same ten relations, checked against the stricter BCNF rule: every
354determinant of every functional dependency that holds on the relation must be a candidate key
355of that relation (3NF allows an exception when the dependent side is prime; BCNF does not).
356
357||= Relation =||= Functional dependencies in force =||= Determinant =||= Is it a candidate key? =||
358|| `R_USERS` || FD1, FD2, FD3 || `U_ID`, `U_USERNAME`, `U_EMAIL` || Yes — all three are candidate keys ||
359|| `R_CRYPTO` || FD4, FD5 || `C_ID`, `C_SYMBOL` || Yes — both candidate keys ||
360|| `R_MARKETS` || FD6, FD7 || `M_ID`, `{M_CRYPTO_ID, M_QUOTE_CURRENCY}` || Yes — both candidate keys ||
361|| `R_HOLDINGS` || FD8, FD9 || `H_ID`, `{H_USER_ID, H_CRYPTO_ID}` || Yes — both candidate keys ||
362|| `R_ORDERS` || FD10 || `O_ID` || Yes — the only candidate key ||
363|| `R_TRANSACTIONS` || FD11 || `T_ID` || Yes — the only candidate key ||
364|| `R_MARKET_TRADES` || FD12 || `MT_ID` || Yes — the only candidate key ||
365|| `R_MARKET_CANDLES` || FD13, FD14 || `MC_ID`, `{MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME}` || Yes — both candidate keys ||
366|| `R_WATCHLISTS` || FD15 || `W_ID` || Yes — the only candidate key ||
367|| `R_WATCHLIST_ITEMS` || FD16, FD17 || `WI_ID`, `{WI_WATCHLIST_ID, WI_CRYPTO_ID}` || Yes — both candidate keys ||
368
369Every determinant in every relation is one of that relation's own candidate keys. '''All ten relations are already in BCNF''' — the highest of the four normal forms this phase asks for,
370reached in the same step that fixed 2NF. This is not a coincidence: it happens because the
371canonical cover already grouped each relation's own key directly against its own attributes
372with no attribute appearing on the right side of two different relations' dependencies, which
373is exactly what synthesis from a canonical cover guarantees when, as here, none of the
374per-cluster functional dependencies overlap.
375
376No further decomposition is possible or necessary; splitting any of the ten relations further
377would only separate attributes that already depend on the ''whole'' key of a BCNF relation,
378which cannot fix anything and only costs a join.
379
380== Final result and discussion ==
381
382=== Normalized relational model ===
383
384{{{
385R_USERS (U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH,
386 U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT)
387R_CRYPTO (C_ID, C_SYMBOL, C_NAME, C_CREATED_AT)
388R_MARKETS (M_ID, M_CRYPTO_ID → R_CRYPTO, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT)
389R_HOLDINGS (H_ID, H_USER_ID → R_USERS, H_CRYPTO_ID → R_CRYPTO, H_QUANTITY,
390 H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT)
391R_ORDERS (O_ID, O_USER_ID → R_USERS, O_MARKET_ID → R_MARKETS, O_SIDE, O_TYPE,
392 O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT)
393R_TRANSACTIONS (T_ID, T_USER_ID → R_USERS, T_TYPE, T_AMOUNT, T_CURRENCY,
394 T_RELATED_ORDER → R_ORDERS, T_CREATED_AT, T_DESCRIPTION)
395R_MARKET_TRADES (MT_ID, MT_MARKET_ID → R_MARKETS, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY,
396 MT_SIDE, MT_SOURCE)
397R_MARKET_CANDLES (MC_ID, MC_MARKET_ID → R_MARKETS, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW,
398 MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME)
399R_WATCHLISTS (W_ID, W_USER_ID → R_USERS, W_NAME, W_CREATED_AT)
400R_WATCHLIST_ITEMS(WI_ID, WI_WATCHLIST_ID → R_WATCHLISTS, WI_CRYPTO_ID → R_CRYPTO, WI_ADDED_AT)
401}}}
402
403Ten relations, every one in BCNF, connected by the eleven foreign keys spelled out above.
404
405=== Discussion ===
406
407'''This is the P2 design.''' Strip the `U_`/`C_`/`M_`/… prefixes back to plain column names and
408`R_USERS, R_CRYPTO, R_MARKETS, R_HOLDINGS, R_ORDERS, R_TRANSACTIONS, R_MARKET_TRADES, R_MARKET_CANDLES, R_WATCHLISTS, R_WATCHLIST_ITEMS` are, attribute for attribute and key for
409key, `users, crypto, markets, holdings, orders, transactions, market_trades, market_candles, watchlists, watchlist_items` from
410[wiki:RelationalDesign]. Every foreign key matches,
411every candidate key matches (including the less obvious composite ones — `{user_id, crypto_id}` on `holdings`, `{crypto_id, quote_currency}` on `markets`, `{market_id, timeframe, candle_time}` on `market_candles`), and the normal form matches (P2 already claimed 3NF; this
412phase shows the stronger result that the design is actually in BCNF).
413
414That is not a coincidence of two people happening to agree — it is what should happen when a
415design is derived correctly twice by two different methods from the same underlying model:
416P2 got here by applying the standard ER-to-relational transformation rules (each entity
417becomes a table on its own key, each attributed M:N relationship becomes a table on the
418combined key, each attributeless 1:N relationship becomes a foreign key on the "many" side).
419This phase got here by ignoring that transformation entirely, writing down only the
420attributes and the functional dependencies they obey, and mechanically applying 2NF/3NF/BCNF
421synthesis. Landing on the same ten relations either means the P2 transformation rules are
422sound for this particular model (which they are, for exactly the reason [wiki:RelationalDesign] (Normalisation section)
423already argued: single-column UUID primary keys everywhere rule out partial dependencies by
424construction, and no non-key attribute references another non-key attribute anywhere in the
425model, which rules out transitive dependencies too), or it is a coincidence spanning ten
426independently-checked relations and dozens of functional dependencies — the first explanation
427is the only credible one.
428
429'''The one substantive difference''' is `holdings.avg_price`, which P2 documents as a
430''derived'' attribute — the running weighted-average buy price, recomputable from the `buy` rows
431in `transactions` — kept as a stored column anyway for read performance
432([wiki:RelationalDesign] (Normalisation section) calls this out
433explicitly as an accepted denormalisation). Nothing in this phase's functional-dependency
434analysis can see that `H_AVG_PRICE` is derivable from `T_*` rows rather than stored
435independently — FD8 (`H_ID → H_AVG_PRICE`) is a perfectly ordinary functional dependency
436either way, because ''derivability from a different relation's rows'' is a property of the data
437and the application logic that maintains it (see
438[wiki:UseCase0004]'s `ON CONFLICT … DO UPDATE`), not something
439that shows up as a violation of any single-relation normal form. Formal normalization and "no
440column is a cached computation of other columns" are related but different concerns; this
441phase only checked the first one.
442
443'''Which design is used going forward:''' P2's, unchanged. Since the two designs coincide
444exactly, "restructuring the database objects" means confirming there is nothing to change
445rather than writing new DDL. `server/db/schema_creation.sql`
446already matches `R_USERS`…`R_WATCHLIST_ITEMS` column-for-column (including
447`holdings.reserved_quantity`, added between P2 and this phase — see
448[wiki:RelationalDesignAIUsage] (section "Session 3 — 2026-09-16")
449— which is `H_RESERVED_QUANTITY` above, correctly grouped under `R_HOLDINGS`'s key alongside
450`H_QUANTITY` and not treated as needing a relation of its own). P4's prototype
451(`server/trade.go`, `server/portfolio.go`) keeps working against the same schema without
452change. [wiki:RelationalDesign] has been updated with a
453short note pointing here as the formal validation of its normal-form claim.
454
455The table definitions in `server/db/schema_creation.sql`:
456
457{{{
458CREATE TABLE project.users (
459 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
460 username varchar(50) NOT NULL UNIQUE,
461 email varchar(255) NOT NULL UNIQUE,
462 full_name varchar(200),
463 password_hash varchar(255) NOT NULL,
464 available_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (available_balance >= 0),
465 invested_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (invested_balance >= 0),
466 created_at timestamptz NOT NULL DEFAULT now(),
467 updated_at timestamptz
468);
469
470-- ============================================================================
471-- CRYPTO
472-- Catalog of crypto assets available on the platform.
473-- ============================================================================
474CREATE TABLE project.crypto (
475 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
476 symbol varchar(20) NOT NULL UNIQUE,
477 name varchar(255) NOT NULL,
478 created_at timestamptz NOT NULL DEFAULT now()
479);
480
481-- ============================================================================
482-- MARKETS
483-- A market is a (crypto, quote_currency) pair, e.g. BTC/USD.
484-- ============================================================================
485CREATE TABLE project.markets (
486 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
487 crypto_id uuid NOT NULL REFERENCES project.crypto(id),
488 quote_currency char(3) NOT NULL DEFAULT 'USD',
489 is_active boolean NOT NULL DEFAULT true,
490 created_at timestamptz NOT NULL DEFAULT now(),
491 CONSTRAINT uq_markets UNIQUE (crypto_id, quote_currency)
492);
493
494-- ============================================================================
495-- HOLDINGS
496-- Per-user crypto position with running weighted average entry price.
497-- ============================================================================
498CREATE TABLE project.holdings (
499 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
500 user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
501 crypto_id uuid NOT NULL REFERENCES project.crypto(id),
502 quantity numeric(20,4) NOT NULL CHECK (quantity >= 0),
503 -- Committed to the user's own open sell orders, not yet removed from the
504 -- position. quantity - reserved_quantity is what is actually free to
505 -- sell — the crypto-side equivalent of users.available_balance.
506 reserved_quantity numeric(20,4) NOT NULL DEFAULT 0
507 CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity),
508 -- Weighted-average entry price. NOT NULL so that the P/L arithmetic in
509 -- v_portfolio can never silently produce NULL for an existing position.
510 avg_price numeric(18,6) NOT NULL DEFAULT 0 CHECK (avg_price >= 0),
511 created_at timestamptz NOT NULL DEFAULT now(),
512 updated_at timestamptz,
513 CONSTRAINT uq_holdings_user_crypto UNIQUE (user_id, crypto_id)
514);
515
516-- ============================================================================
517-- ORDERS
518-- Orders placed by users on a market.
519-- ============================================================================
520CREATE TABLE project.orders (
521 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
522 user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
523 market_id uuid NOT NULL REFERENCES project.markets(id),
524 side varchar(4) NOT NULL CHECK (side IN ('buy', 'sell')),
525 type varchar(20) NOT NULL CHECK (type IN ('market', 'limit')),
526 status varchar(20) NOT NULL CHECK (status IN ('open', 'executed', 'cancelled')),
527 quantity numeric(20,4) NOT NULL CHECK (quantity > 0),
528 price numeric(18,6),
529 placed_at timestamptz NOT NULL DEFAULT now(),
530 executed_at timestamptz
531);
532
533CREATE INDEX idx_orders_user ON project.orders(user_id);
534CREATE INDEX idx_orders_market ON project.orders(market_id);
535CREATE INDEX idx_orders_status ON project.orders(status);
536
537-- ============================================================================
538-- TRANSACTIONS
539-- Financial ledger: deposits, buys, sells, fees.
540-- ============================================================================
541CREATE TABLE project.transactions (
542 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
543 user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
544 type varchar(50) NOT NULL CHECK (type IN ('deposit', 'buy', 'sell', 'fee')),
545 amount numeric(18,4) NOT NULL,
546 currency char(3) NOT NULL DEFAULT 'USD',
547 related_order uuid REFERENCES project.orders(id),
548 created_at timestamptz NOT NULL DEFAULT now(),
549 description text
550);
551
552CREATE INDEX idx_transactions_user ON project.transactions(user_id, created_at DESC);
553
554-- ============================================================================
555-- MARKET TRADES
556-- Raw executed trades on a market. Source of truth for current price.
557-- ============================================================================
558CREATE TABLE project.market_trades (
559 id bigserial PRIMARY KEY,
560 market_id uuid NOT NULL REFERENCES project.markets(id),
561 executed_at timestamptz NOT NULL,
562 price numeric(18,6) NOT NULL CHECK (price > 0),
563 quantity numeric(20,6) NOT NULL CHECK (quantity > 0),
564 side varchar(4) CHECK (side IN ('buy', 'sell')),
565 source varchar(50) NOT NULL DEFAULT 'simulation'
566);
567
568CREATE INDEX idx_market_trades_market_time ON project.market_trades(market_id, executed_at DESC);
569
570-- ============================================================================
571-- MARKET CANDLES
572-- OHLCV aggregates over standard timeframes.
573-- ============================================================================
574CREATE TABLE project.market_candles (
575 id bigserial PRIMARY KEY,
576 market_id uuid NOT NULL REFERENCES project.markets(id),
577 timeframe varchar(5) NOT NULL CHECK (timeframe IN ('1m', '5m', '1h', '1d')),
578 open numeric(18,6) NOT NULL,
579 high numeric(18,6) NOT NULL,
580 low numeric(18,6) NOT NULL,
581 close numeric(18,6) NOT NULL,
582 volume numeric(20,6) NOT NULL,
583 candle_time timestamptz NOT NULL,
584 CONSTRAINT uq_candle UNIQUE (market_id, timeframe, candle_time)
585);
586
587CREATE INDEX idx_market_candles_market_tf_time ON project.market_candles(market_id, timeframe, candle_time DESC);
588
589-- ============================================================================
590-- WATCHLISTS
591-- ============================================================================
592CREATE TABLE project.watchlists (
593 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
594 user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
595 name varchar(100) NOT NULL,
596 created_at timestamptz NOT NULL DEFAULT now()
597);
598
599CREATE TABLE project.watchlist_items (
600 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
601 watchlist_id uuid NOT NULL REFERENCES project.watchlists(id) ON DELETE CASCADE,
602 crypto_id uuid NOT NULL REFERENCES project.crypto(id),
603 added_at timestamptz NOT NULL DEFAULT now(),
604 CONSTRAINT uq_watchlist_crypto UNIQUE (watchlist_id, crypto_id)
605);
606}}}
Note: See TracBrowser for help on using the repository browser.