/** * What a schema's *structure* says about the app on top of it. * * Introspection has always been a table mirror: one table in, one collection * out, one nav entry each, every column a form field. A schema of thirty tables * produces thirty sidebar entries, and a panel whose navigation is a list of * table names reads as a database browser however good the fields are — which * is the actual complaint about generated admin panels, and is structural, not * cosmetic. * * Most of what separates the eight nouns a user navigates by from the thirty * tables underneath them is written down in the schema already: which tables * only exist to join two others, which are small referenced code lists, which * rows cannot outlive a parent row. This module reads that. * * ## Structure only * * Nothing here looks at a column or table *name*. Name heuristics — `status`, * `*_url`, `image`, `created_at` — are wrong exactly when a schema is not in * English, or is domain-specific, or spells things differently, and they are * wrong silently. Every rule below is a fact the database enforces: key * composition, foreign-key direction and delete rule, uniqueness, nullability, * declared type and length, generated-ness, row count. * * That constraint has a cost, and it is worth stating: a schema that declares * nothing beyond `NOT NULL` gives this module very little to work with, and it * returns `entity` for everything rather than guessing. Under-classifying is * the intended failure mode. A table wrongly hidden from the navigation is a * table the user cannot find; a table wrongly left in it is merely the status * quo. * * Pure module: no I/O. Row counts come in on {@link SchemaMetadata.rowCounts}, * which the caller fills from {@link ./introspect-db-queries.countRowsUpTo} for * the tables {@link lookupCandidates} names. */ import type { ForeignKeyRow, SchemaMetadata, TableColumn, TableMeta } from "./introspect-db-logic"; import type { CheckFactsByTable } from "./introspect-db-constraints"; /** * The row count above which a referenced table is a real entity rather than a * code list. Deliberately low: `pagila.category` has 16 rows and `language` 6, * while `actor` has 200 and `country` 109 — the gap between "a fixed set * somebody typed once" and "data the app accumulates" is wide, and picking a * number in the middle of it costs nothing. */ export declare const LOOKUP_MAX_ROWS = 50; /** * The most payload columns a code list may carry. A code list is an id, a * label, and perhaps a sort key or a flag; past that it is a table with * attributes, which is an entity. */ export declare const LOOKUP_MAX_PAYLOAD_COLUMNS = 3; /** * The most enum values a board can usefully have as columns. A kanban with * thirty columns is a horizontally scrolling table. */ export declare const KANBAN_MAX_VALUES = 12; /** Below this, a "board" is one or two columns — a filter, not a board. */ export declare const KANBAN_MIN_VALUES = 2; /** * How many columns a generated list view shows before it stops being readable. * Only applied when a table has more properties than this; a six-column table * gets no `listProperties` at all rather than a restatement of its own columns. */ export declare const LIST_PROPERTIES_CAP = 6; /** * What a table *is*, structurally. * * - `entity` — a thing the app is about. Gets a collection and a nav entry. * - `junction` — exists only to relate two other tables. Gets no collection at * all; it becomes a many-to-many relation on both sides. * - `lookup` — a small, referenced, self-contained code list. Gets a collection, * grouped away from the entities rather than listed beside them. * - `owned-child` — rows that belong to exactly one parent row and are reached * through it. Gets a collection (it is a real table with real rows, and the * API still serves it) but no nav entry: it already renders as a tab on its * parent. */ export type TableRole = "entity" | "junction" | "lookup" | "owned-child"; /** * Why a table was called someone's child, weakest last. * * Carried into the generated file as a comment. A reader who disagrees with the * classification needs to see what it was based on to know which line to change. */ export type OwnershipEvidence = /** The only foreign key declared `ON DELETE CASCADE`. */ "cascade-delete" /** The only foreign key that is part of the table's primary key. */ | "identifying-key" /** The only foreign key that is `NOT NULL`. */ | "sole-required-key" /** First column of a composite primary key made entirely of foreign keys. */ | "leading-key-column"; export interface JunctionShape { sourceTable: string; sourceColumn: string; targetTable: string; targetColumn: string; } export interface TableClassification { table: string; role: TableRole; /** One line, in prose, for the generated file. */ reason: string; /** Set when `role === "owned-child"`. */ owner?: { table: string; column: string; evidence: OwnershipEvidence; }; /** Set when `role === "junction"`. */ junction?: JunctionShape; } /** * A timestamp the database maintains: a temporal column defaulting to the * transaction clock. * * This is the structural stand-in for the `created_at`/`updated_at` name check. * It is strictly better than the name: it catches `fecha_creacion` and * `last_update` (pagila's spelling, which the name list misses), and it does not * fire on a user-editable `created_at date` column that has no default and which * the name check would wrongly make read-only. */ export declare function isAutoTimestamp(column: TableColumn): boolean; /** A key the database fills in: identity, serial, or a uuid-generating default. */ export declare function isGeneratedKey(column: TableColumn): boolean; /** A column Postgres computes; writing to it is an error. */ export declare function isGeneratedColumn(column: TableColumn): boolean; /** * Types that exist to be searched or indexed, never to be typed into. * * A `tsvector` column is a derived search index — maintained by a trigger, a * generated expression, or an application job — and its contents are lexeme * positions, not text. Pagila's `film.fulltext` is one, and introspection used * to emit it as an ordinary required string: a mandatory form field whose * correct value no user can produce, on the sixth column of the list view. */ export declare function isDerivedIndexColumn(column: TableColumn): boolean; /** Anything the user cannot meaningfully edit, whatever the reason. */ export declare function isReadOnlyColumn(column: TableColumn): boolean; /** * A string column with a declared maximum length. * * `varchar(50)` and `text` are the same type to an application but not to the * author: choosing a bound is a statement that the value is short and * label-like, which is what makes this usable for picking a display column. */ export declare function isBoundedString(column: TableColumn): boolean; /** * A column carrying data rather than structure: not a key, not a foreign key, * not a database-maintained timestamp, not computed. * * The count of these is what tells a pure join table from an association that * carries its own attributes — `northwind.order_details` has the key shape of a * junction and three payload columns, so it is not one. */ export declare function isPayloadColumn(column: TableColumn, pks: string[], fkColumns: Set): boolean; /** One foreign key, with its columns grouped back together. */ export interface ForeignKeyConstraint { name: string; table: string; columns: string[]; foreignTable: string; foreignColumns: string[]; deleteRule?: string; } /** * Groups per-column foreign key rows back into constraints. * * Rows arrive one per referencing column. A composite key looks exactly like two * separate keys until they are grouped by constraint name, and the difference * matters: two single-column keys to two tables can be a junction, one * two-column key never is. */ export declare function groupForeignKeys(fks: ForeignKeyRow[]): ForeignKeyConstraint[]; /** * Names the tables whose classification depends on a row count. * * The caller counts these — and only these — before calling * {@link classifyTables}. On a schema of any size this is a handful of tables, * and the count itself is capped (see `countRowsUpTo`), so the whole extra cost * is bounded regardless of how much data the database holds. */ export declare function lookupCandidates(metadata: SchemaMetadata, tables: Map): string[]; /** * Classifies every table in the schema. * * Order matters: junction is the most specific and most consequential (the * table disappears), so it is tested first; then lookup, which needs no * ownership reasoning; then ownership. Anything unmatched is an entity, which * is also what every rule falls back to when its evidence is ambiguous. */ export declare function classifyTables(metadata: SchemaMetadata, tables: Map): Map; /** * The columns a property-level derivation needs, resolved once. */ export interface ColumnFacts { column: TableColumn; isPk: boolean; isFk: boolean; /** Covered by a single-column unique constraint or unique index. */ isUniqueAlone: boolean; isAutoTimestamp: boolean; isGenerated: boolean; /** Allowed values, from a Postgres enum type or a readable CHECK. */ enumValues?: string[]; propType: string; } export declare function buildColumnFacts(meta: TableMeta, metadata: SchemaMetadata, enumMap: Map, checkFacts: CheckFactsByTable): Map; /** * The column that identifies a row to a human. * * Structural, in three rungs, strongest first: * * 1. A single-column unique constraint on a required string. This is as close * as a schema comes to declaring "this is what a row is called": it is the * column a person looks a row up by, and the database guarantees it picks * out one row. * 2. The first required string that declares a length, when the table also has * strings that do not. Choosing `varchar(n)` for one column and `text` for * another is the author distinguishing a label from prose. * 3. The first required string in declaration order. Weak, but it is the same * rung the panel's own fallback stands on, and column order carries real * information — the identifying column of a table is written near the top of * it, in every schema, in every language. * * Deliberately not: a column called `name`, or `title`. That works on English * schemas written by someone who read the same tutorial. This picks * `film.title`, `actor.first_name` and `category.name` out of pagila without * knowing what any of those words mean. */ export declare function deriveTitleProperty(facts: Map): string | undefined; /** * The enum column a board should have as its columns. * * A board needs a small, closed, always-present set of states. `NOT NULL` is * required because a null has no column to sit in; the bounds keep out * two-state flags (a filter, not a board) and long code lists (a scrolling * table). The first qualifying column in declaration order wins, so the output * is stable across runs. */ export declare function deriveKanbanProperty(facts: Map): string | undefined; /** * The column a list should be sorted by, newest first. * * Only when the table has exactly one database-maintained timestamp. With two — * a created and an updated stamp — the two orderings differ and the schema does * not say which the user means, so neither is chosen. */ export declare function deriveSort(facts: Map): [string, "desc"] | undefined; /** * The first `LIST_PROPERTIES_CAP` visible properties, or nothing. * * Returning nothing when the table is already narrow matters: `listProperties` * that restates every column is config the reader has to check against the * property list to discover it does nothing, and it silently stops new columns * from appearing in the list view when someone adds one later. * * `hidden` names the properties already marked `hideFromCollection` — spending * one of six columns on a value the list does not render is worse than not * capping at all. */ export declare function deriveListProperties(propertiesOrder: string[], hidden?: ReadonlySet): string[] | undefined;