diff --git a/.gitignore b/.gitignore index de2be007..a3ee3c27 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ App_Data/ .agents/ .codex .codex/ + +# Generated local artifacts +.artifacts/ diff --git a/README.md b/README.md index 7db281ef..35a5ad95 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,24 @@ cd frontend npm run build ``` +## Database Diagram + +Start PostgreSQL, then generate a local schema diagram: + +```bash +./scripts/generate-db-diagram.sh +``` + +The script writes an HTML viewer, SVG, PNG, and Graphviz source under: + +```txt +.artifacts/db-diagrams/ +``` + +Use `DATABASE_URL`, `PGPASSWORD`, or `~/.pgpass` to provide local database credentials. +When using the repository infrastructure script, the diagram script can read from the +running `socialize-postgres` container directly. + ## Agentic Workflow Start here: diff --git a/docs/TASKS/development/002-generate-database-diagram.md b/docs/TASKS/development/002-generate-database-diagram.md new file mode 100644 index 00000000..2c79de5e --- /dev/null +++ b/docs/TASKS/development/002-generate-database-diagram.md @@ -0,0 +1,25 @@ +# Task: Add database diagram generation script + +## Goal + +Provide a repeatable local command that renders the current PostgreSQL schema as a visual database diagram for human review. + +The output should be local generated artifacts, not checked-in schema snapshots, so the diagram can be refreshed whenever EF migrations or model configuration change. + +## Relevant Files + +- `scripts/generate-db-diagram.sh` +- `.gitignore` +- `README.md` + +## Validation + +```bash +./scripts/generate-db-diagram.sh +``` + +Open: + +```txt +.artifacts/db-diagrams/database-diagram.html +``` diff --git a/scripts/generate-db-diagram.sh b/scripts/generate-db-diagram.sh new file mode 100755 index 00000000..f8d9fe67 --- /dev/null +++ b/scripts/generate-db-diagram.sh @@ -0,0 +1,283 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +OUT_DIR="${DB_DIAGRAM_OUT_DIR:-${REPO_ROOT}/.artifacts/db-diagrams}" + +mkdir -p "$OUT_DIR" + +TABLES_TSV="${OUT_DIR}/tables.tsv" +RELATIONS_TSV="${OUT_DIR}/relations.tsv" +DOT_FILE="${OUT_DIR}/database-diagram.dot" +SVG_FILE="${OUT_DIR}/database-diagram.svg" +PNG_FILE="${OUT_DIR}/database-diagram.png" +HTML_FILE="${OUT_DIR}/database-diagram.html" + +if ! command -v psql >/dev/null 2>&1; then + echo "psql is required to generate the database diagram." >&2 + exit 1 +fi + +export PGCONNECT_TIMEOUT="${PGCONNECT_TIMEOUT:-5}" +export PGHOST="${PGHOST:-localhost}" +export PGPORT="${PGPORT:-5433}" +export PGDATABASE="${PGDATABASE:-socialize}" +export PGUSER="${PGUSER:-sa}" + +PSQL_ARGS=(-v ON_ERROR_STOP=1 -X -A -t -F $'\t') +CONNECTION_LABEL="${PGHOST}:${PGPORT}/${PGDATABASE} as ${PGUSER}" + +if [[ -n "${DATABASE_URL:-}" ]]; then + PSQL=(psql "${DATABASE_URL}" "${PSQL_ARGS[@]}") + CONNECTION_LABEL="DATABASE_URL" +elif docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "socialize-postgres"; then + PSQL=(docker exec -i socialize-postgres psql -U "$PGUSER" -d "$PGDATABASE" "${PSQL_ARGS[@]}") + CONNECTION_LABEL="Docker container socialize-postgres/${PGDATABASE} as ${PGUSER}" +else + PSQL=(psql "${PSQL_ARGS[@]}") +fi + +echo "Reading schema from ${CONNECTION_LABEL}..." + +if ! "${PSQL[@]}" -c "select 1" >/dev/null 2>&1; then + echo "Could not connect to PostgreSQL." >&2 + echo "Start infrastructure first, then provide credentials with DATABASE_URL, PGPASSWORD, or ~/.pgpass." >&2 + exit 1 +fi + +"${PSQL[@]}" > "$TABLES_TSV" <<'SQL' +SELECT + c.table_schema || '.' || c.table_name AS table_id, + c.column_name, + c.udt_name, + c.is_nullable, + CASE WHEN pk.column_name IS NULL THEN '' ELSE 'PK' END AS key_type +FROM information_schema.columns c +LEFT JOIN ( + SELECT + kcu.table_schema, + kcu.table_name, + kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_catalog = tc.constraint_catalog + AND kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name + WHERE tc.constraint_type = 'PRIMARY KEY' +) pk + ON pk.table_schema = c.table_schema + AND pk.table_name = c.table_name + AND pk.column_name = c.column_name +WHERE c.table_schema = 'public' + AND c.table_name <> '__EFMigrationsHistory' +ORDER BY c.table_name, c.ordinal_position; +SQL + +"${PSQL[@]}" > "$RELATIONS_TSV" <<'SQL' +SELECT + tc.constraint_name, + kcu.table_schema || '.' || kcu.table_name AS child_table, + kcu.column_name AS child_column, + ccu.table_schema || '.' || ccu.table_name AS parent_table, + ccu.column_name AS parent_column, + col.is_nullable +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON kcu.constraint_catalog = tc.constraint_catalog + AND kcu.constraint_schema = tc.constraint_schema + AND kcu.constraint_name = tc.constraint_name +JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_catalog = tc.constraint_catalog + AND ccu.constraint_schema = tc.constraint_schema + AND ccu.constraint_name = tc.constraint_name +JOIN information_schema.columns col + ON col.table_schema = kcu.table_schema + AND col.table_name = kcu.table_name + AND col.column_name = kcu.column_name +WHERE tc.constraint_type = 'FOREIGN KEY' + AND kcu.table_schema = 'public' + AND kcu.table_name <> '__EFMigrationsHistory' + AND ccu.table_schema = 'public' + AND ccu.table_name <> '__EFMigrationsHistory' +ORDER BY child_table, child_column, parent_table, parent_column; +SQL + +html_escape() { + local value="$1" + value="${value//&/&}" + value="${value//<}" + value="${value//>/>}" + value="${value//\"/"}" + printf '%s' "$value" +} + +node_id() { + local value="$1" + value="${value//./__}" + value="${value//-/__}" + printf '%s' "$value" +} + +declare -A TABLE_COLUMNS +declare -A TABLE_SEEN +declare -a TABLE_ORDER + +while IFS=$'\t' read -r table_name column_name column_type nullable key_type; do + [[ -n "$table_name" ]] || continue + + if [[ -z "${TABLE_SEEN[$table_name]:-}" ]]; then + TABLE_SEEN["$table_name"]=1 + TABLE_ORDER+=("$table_name") + fi + + label="$(html_escape "$column_name")" + type_label="$(html_escape "$column_type")" + suffix="" + [[ "$key_type" == "PK" ]] && suffix=" PK" + [[ "$nullable" == "YES" ]] && suffix="${suffix} nullable" + + TABLE_COLUMNS["$table_name"]+=$'
| %s |