Relational Databases: Tables & Keys
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
relational-store.ts
import type { Relation, Scalar, Tuple } from "../relational/relation";import { RelationQuery } from "../relational/relation-query";import type { Tracer } from "../trace";export const MAX_TABLES = 3;/** Rows a table may hold: the RelationTable primitive draws every one. */export const MAX_ROWS = 12;export const MAX_OPS = 40;/** What happens to the rows that point at a row being deleted. */export const DELETE_RULES = ["restrict", "cascade"] as const;export type DeleteRule = (typeof DELETE_RULES)[number];export interface ForeignKey { /** The pointing attributes, in the order of the target's primary key. */ attributes: readonly string[]; /** The table whose primary key the attributes name. */ references: string; onDelete: DeleteRule;}export interface TableSchema { name: string; attributes: readonly string[]; primaryKey: readonly string[]; foreignKeys: readonly ForeignKey[];}export type StoreOp = | { kind: "insert"; table: string; tuple: Tuple } | { kind: "delete"; table: string; key: Tuple };export type RefusalReason = "duplicate-key" | "dangling-reference" | "referenced" | "missing-row";export interface OpOutcome { ok: boolean; reason: RefusalReason | null; /** Dependent rows removed along with a deleted row. */ cascaded: number;}export interface StoreReport { inserted: number; rejected: number; deleted: number; cascaded: number;}/** A row that points at another: its table, its index there, and the rule of the key it points with. */interface Dependent { table: string; index: number; onDelete: DeleteRule;}/** * A relational database is tables of rows whose keys point at each * other, and an engine that refuses every row that would break a * pointer. Each table declares a primary key, attributes whose values * identify a row, and foreign keys, attributes whose values must equal * the primary key of some row in another table. An insert is checked * twice: the key must not already be taken, and every foreign key must * find its row. A delete finds the row, then the rows that point at it: * under restrict they block the delete, under cascade they go with it, * and a row they are pointed at by goes in turn. The checks are what * "referential integrity" means, and they are the reason an order can * never name a customer who does not exist. * * Time: O(rows) per check, O(tables * rows) per delete. Space: O(rows). */export class RelationalStore { private readonly schemas: ReadonlyMap<string, TableSchema>; private readonly rows = new Map<string, Tuple[]>(); private ops = 0; private inserted = 0; private rejected = 0; private deleted = 0; private cascaded = 0; constructor( schemas: readonly TableSchema[], private readonly tracer?: Tracer, ) { if (schemas.length < 1 || schemas.length > MAX_TABLES) throw new RangeError(`a store holds 1 to ${MAX_TABLES} tables`); const byName = new Map<string, TableSchema>(); for (const schema of schemas) { if (byName.has(schema.name)) throw new RangeError(`table "${schema.name}" is declared twice`); if (schema.attributes.length === 0 || new Set(schema.attributes).size !== schema.attributes.length) throw new RangeError(`table "${schema.name}" needs distinct attributes`); if (schema.primaryKey.length === 0 || !schema.primaryKey.every((a) => schema.attributes.includes(a))) throw new RangeError(`table "${schema.name}" needs a primary key among its attributes`); byName.set(schema.name, schema); this.rows.set(schema.name, []); } for (const schema of schemas) { for (const fk of schema.foreignKeys) { for (const foreignKey of schema.foreignKeys) { const target = byName.get(fk.references); const target = byName.get(foreignKey.references); if (!target) throw new RangeError(`table "${schema.name}" references unknown table "${fk.references}"`); if (fk.attributes.length !== target.primaryKey.length || !fk.attributes.every((a) => schema.attributes.includes(a))) throw new RangeError(`a foreign key of "${schema.name}" must match the primary key of "${fk.references}"`); if (foreignKey.attributes.length !== target.primaryKey.length || !foreignKey.attributes.every((a) => schema.attributes.includes(a))) throw new RangeError(`a foreign key of "${schema.name}" must match the primary key of "${fk.references}"`); if (!DELETE_RULES.includes(fk.onDelete)) throw new RangeError(`unknown delete rule ${String(fk.onDelete)}`); if (!DELETE_RULES.includes(foreignKey.onDelete)) throw new RangeError(`unknown delete rule ${String(fk.onDelete)}`); } } this.schemas = byName; } /** The table's rows as a relation; a copy. */ relation(table: string): Relation { const schema = this.schema(table); return { name: schema.name, attributes: [...schema.attributes], tuples: this.rows.get(table)!.map((row) => ({ ...row })) }; } /** Every table's schema, in declaration order. */ tables(): readonly TableSchema[] { return [...this.schemas.values()]; } /** One row in: the key must be free and every foreign key must find its row. */ insert(table: string, tuple: Tuple): OpOutcome { const schema = this.schema(table); this.checkShape(schema, tuple, schema.attributes); this.countOp(); const rows = this.rows.get(table)!; const key = this.valuesOf(tuple, schema.primaryKey); // ▸ a row arrives for a table: before it is stored, its key and its pointers are checked this.tracer?.emit("insert", { table, tuple: { ...tuple }, rows: rows.length }); const clash = rows.find((row) => this.sameKey(row, tuple, schema.primaryKey)); // ▸ look for a row already holding this primary key: two rows may never share one this.tracer?.emit("key", { table, key, duplicate: clash !== undefined, clash: clash ? this.valuesOf(clash, schema.primaryKey) : null }); if (clash) return this.refuse(table, "insert", "duplicate-key"); for (const fk of schema.foreignKeys) { for (const foreignKey of schema.foreignKeys) { const target = this.schemas.get(fk.references)!; const target = this.schemas.get(foreignKey.references)!; const value = this.valuesOf(tuple, fk.attributes); const value = this.valuesOf(tuple, foreignKey.attributes); const exists = this.rows.get(fk.references)!.some((row) => JSON.stringify(this.valuesOf(row, target.primaryKey)) === JSON.stringify(value)); const exists = this.rows.get(foreignKey.references)!.some((row) => JSON.stringify(this.valuesOf(row, target.primaryKey)) === JSON.stringify(value)); // ▸ for each foreign key, look for the row it points at in the other table this.tracer?.emit("reference", { table, attributes: [...fk.attributes], target: fk.references, value, exists }); this.tracer?.emit("reference", { table, attributes: [...foreignKey.attributes], target: foreignKey.references, value, exists }); if (!exists) return this.refuse(table, "insert", "dangling-reference"); } if (rows.length >= MAX_ROWS) throw new RangeError(`table "${table}" is full at ${MAX_ROWS} rows`); rows.push({ ...tuple }); this.inserted++; // ▸ the key was free and every pointer found its row, so the row goes in this.tracer?.emit("store", { table, key, rows: rows.length }); return { ok: true, reason: null, cascaded: 0 }; } /** One row out: it must exist, and the rows pointing at it decide whether it may go, and what goes with it. */ delete(table: string, key: Tuple): OpOutcome { const schema = this.schema(table); this.checkShape(schema, key, schema.primaryKey); this.countOp(); const rows = this.rows.get(table)!; const index = rows.findIndex((row) => this.sameKey(row, key, schema.primaryKey)); // ▸ a delete names a row by its key: find it, then find the rows that point at it this.tracer?.emit("delete", { table, key: this.valuesOf(key, schema.primaryKey), found: index !== -1 }); if (index === -1) return this.refuse(table, "delete", "missing-row"); if (this.blocked(table, rows[index]!)) return this.refuse(table, "delete", "referenced"); const before = this.cascaded; this.remove(table, index, null); this.deleted++; return { ok: true, reason: null, cascaded: this.cascaded - before }; } /** Every operation in order; a refused one counts and the rest go on. */ apply(ops: readonly StoreOp[]): StoreReport { if (ops.length > MAX_OPS) throw new RangeError(`${ops.length} operations exceeds ${MAX_OPS}`); for (const op of ops) { if (op.kind === "insert") this.insert(op.table, op.tuple); else this.delete(op.table, op.key); } return this.report(); } report(): StoreReport { return { inserted: this.inserted, rejected: this.rejected, deleted: this.deleted, cascaded: this.cascaded }; } /** Whether some row pointing at this one, directly or through a chain, points with a restricting key. */ private blocked(table: string, row: Tuple): boolean { return this.dependents(table, row).some((dep) => dep.onDelete === "restrict" || this.blocked(dep.table, this.rows.get(dep.table)![dep.index]!)); return this.dependents(table, row).some((pointingRow) => pointingRow.onDelete === "restrict" || this.blocked(pointingRow.table, this.rows.get(pointingRow.table)![pointingRow.index]!)); } /** Removes a row and, through one cascade site, every row that pointed at it. */ private remove(table: string, index: number, parent: { table: string; key: Scalar[] } | null): void { const schema = this.schemas.get(table)!; const rows = this.rows.get(table)!; const row = rows[index]!; const key = this.valuesOf(row, schema.primaryKey); if (parent) { this.cascaded++; // ▸ under cascade, a row that pointed at the deleted row goes with it, and so on down this.tracer?.emit("cascade", { table, key, parent: parent.table, parentKey: parent.key, cascaded: this.cascaded }); } rows.splice(index, 1); // Dependents are found after the row is gone, and removed highest index first so the others keep their places. const deps = this.dependents(table, row).sort((a, b) => (a.table === b.table ? b.index - a.index : 0)); const pointingRows = this.dependents(table, row).sort((a, b) => (a.table === b.table ? b.index - a.index : 0)); for (const dep of deps) this.remove(dep.table, dep.index, { table, key }); for (const pointingRow of pointingRows) this.remove(pointingRow.table, pointingRow.index, { table, key }); } /** The rows of every table whose foreign key points at this row. */ private dependents(table: string, row: Tuple): Dependent[] { const target = this.schemas.get(table)!; const pointed = JSON.stringify(this.valuesOf(row, target.primaryKey)); const deps: Dependent[] = []; const pointingRows: Dependent[] = []; for (const schema of this.schemas.values()) { for (const fk of schema.foreignKeys) { for (const foreignKey of schema.foreignKeys) { if (fk.references !== table) continue; if (foreignKey.references !== table) continue; this.rows.get(schema.name)!.forEach((candidate, index) => { if (JSON.stringify(this.valuesOf(candidate, fk.attributes)) === pointed) deps.push({ table: schema.name, index, onDelete: fk.onDelete }); if (JSON.stringify(this.valuesOf(candidate, foreignKey.attributes)) === pointed) pointingRows.push({ table: schema.name, index, onDelete: foreignKey.onDelete }); }); } } return deps; return pointingRows; } /** Every refusal, whatever the reason, through one site. */ private refuse(table: string, kind: StoreOp["kind"], reason: RefusalReason): OpOutcome { this.rejected++; // ▸ a check failed: the row or the delete is refused and the store stays as it was this.tracer?.emit("reject", { table, kind, reason, rejected: this.rejected }); return { ok: false, reason, cascaded: 0 }; } private schema(table: string): TableSchema { const schema = this.schemas.get(table); if (!schema) throw new RangeError(`no table "${table}"`); return schema; } private countOp(): void { if (this.ops >= MAX_OPS) throw new RangeError(`more than ${MAX_OPS} operations`); this.ops++; } /** The tuple must carry exactly these attributes, each a scalar. */ private checkShape(schema: TableSchema, tuple: Tuple, attributes: readonly string[]): void { for (const attribute of attributes) { const cell = tuple[attribute]; if (cell === undefined || !RelationQuery.isAtomic(cell)) throw new RangeError(`"${schema.name}" needs a scalar ${attribute}`); } for (const attribute of Object.keys(tuple)) { if (!attributes.includes(attribute)) throw new RangeError(`"${schema.name}" has no attribute ${attribute}`); } } private valuesOf(tuple: Tuple, attributes: readonly string[]): Scalar[] { return attributes.map((attribute) => RelationQuery.cell(tuple, attribute) as Scalar); } private sameKey(a: Tuple, b: Tuple, attributes: readonly string[]): boolean { return RelationQuery.keyOf(a, attributes) === RelationQuery.keyOf(b, attributes); }}The script "build the shop" is done: 19 rows stored, 2 operations refused, 0 rows deleted, and 0 dependent rows cascaded. Every pointer left in the store finds its row.