chore: add database diagram generator
This commit is contained in:
283
scripts/generate-db-diagram.sh
Executable file
283
scripts/generate-db-diagram.sh
Executable file
@@ -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=" <FONT POINT-SIZE=\"10\">PK</FONT>"
|
||||
[[ "$nullable" == "YES" ]] && suffix="${suffix} <FONT POINT-SIZE=\"10\">nullable</FONT>"
|
||||
|
||||
TABLE_COLUMNS["$table_name"]+=$' <TR><TD ALIGN="LEFT" PORT="'$(html_escape "$column_name")$'"><FONT FACE="monospace">'"${label}"$'</FONT></TD><TD ALIGN="LEFT"><FONT POINT-SIZE="10">'"${type_label}${suffix}"$'</FONT></TD></TR>\n'
|
||||
done < "$TABLES_TSV"
|
||||
|
||||
{
|
||||
cat <<'DOT'
|
||||
digraph database {
|
||||
graph [
|
||||
rankdir=LR,
|
||||
bgcolor="white",
|
||||
pad=0.4,
|
||||
nodesep=0.6,
|
||||
ranksep=1.0,
|
||||
splines=ortho,
|
||||
overlap=false
|
||||
];
|
||||
|
||||
node [
|
||||
shape=plain,
|
||||
fontname="Arial"
|
||||
];
|
||||
|
||||
edge [
|
||||
color="#64748b",
|
||||
arrowsize=0.7,
|
||||
fontname="Arial",
|
||||
fontsize=10
|
||||
];
|
||||
|
||||
DOT
|
||||
|
||||
for table_name in "${TABLE_ORDER[@]}"; do
|
||||
id="$(node_id "$table_name")"
|
||||
title="$(html_escape "${table_name#public.}")"
|
||||
printf ' %s [label=<\n' "$id"
|
||||
printf ' <TABLE BORDER="1" CELLBORDER="1" CELLSPACING="0" CELLPADDING="6" COLOR="#94a3b8">\n'
|
||||
printf ' <TR><TD BGCOLOR="#0f766e" COLSPAN="2"><FONT COLOR="white"><B>%s</B></FONT></TD></TR>\n' "$title"
|
||||
printf '%s' "${TABLE_COLUMNS[$table_name]}"
|
||||
printf ' </TABLE>\n'
|
||||
printf ' >];\n\n'
|
||||
done
|
||||
|
||||
while IFS=$'\t' read -r constraint_name child_table child_column parent_table parent_column nullable; do
|
||||
[[ -n "$constraint_name" ]] || continue
|
||||
child_id="$(node_id "$child_table")"
|
||||
parent_id="$(node_id "$parent_table")"
|
||||
label="$(html_escape "${child_column} -> ${parent_column}")"
|
||||
style="solid"
|
||||
[[ "$nullable" == "YES" ]] && style="dashed"
|
||||
printf ' %s -> %s [label="%s", style="%s"];\n' "$child_id" "$parent_id" "$label" "$style"
|
||||
done < "$RELATIONS_TSV"
|
||||
|
||||
printf '}\n'
|
||||
} > "$DOT_FILE"
|
||||
|
||||
if command -v dot >/dev/null 2>&1; then
|
||||
dot -Tsvg "$DOT_FILE" -o "$SVG_FILE"
|
||||
dot -Tpng "$DOT_FILE" -o "$PNG_FILE"
|
||||
else
|
||||
echo "Graphviz dot was not found; wrote only ${DOT_FILE}." >&2
|
||||
fi
|
||||
|
||||
cat > "$HTML_FILE" <<HTML
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Socialize Database Diagram</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
header a {
|
||||
color: #0f766e;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: max-content;
|
||||
min-width: calc(100vw - 32px);
|
||||
padding: 16px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
object {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
min-height: calc(100vh - 104px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Socialize Database Diagram</h1>
|
||||
<a href="./database-diagram.svg">SVG</a>
|
||||
<a href="./database-diagram.png">PNG</a>
|
||||
<a href="./database-diagram.dot">DOT</a>
|
||||
</header>
|
||||
<main>
|
||||
<div class="canvas">
|
||||
<object data="./database-diagram.svg" type="image/svg+xml">
|
||||
Open database-diagram.svg or database-diagram.png from this folder.
|
||||
</object>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
|
||||
echo "Wrote:"
|
||||
echo " ${HTML_FILE}"
|
||||
[[ -f "$SVG_FILE" ]] && echo " ${SVG_FILE}"
|
||||
[[ -f "$PNG_FILE" ]] && echo " ${PNG_FILE}"
|
||||
echo " ${DOT_FILE}"
|
||||
Reference in New Issue
Block a user