#!/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"
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
if [[ "${PSQL[0]}" == "psql" ]] && ! command -v psql >/dev/null 2>&1; then
echo "psql is required to generate the database diagram when not using the local Docker container." >&2
exit 1
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,
CASE
WHEN c.data_type = 'character varying'
THEN 'varchar(' || c.character_maximum_length || ')'
WHEN c.data_type = 'character'
THEN 'char(' || c.character_maximum_length || ')'
WHEN c.data_type = 'text'
THEN 'text'
WHEN c.data_type = 'numeric' AND c.numeric_precision IS NOT NULL AND c.numeric_scale IS NOT NULL
THEN 'numeric(' || c.numeric_precision || ',' || c.numeric_scale || ')'
WHEN c.data_type = 'numeric' AND c.numeric_precision IS NOT NULL
THEN 'numeric(' || c.numeric_precision || ')'
WHEN c.data_type = 'timestamp with time zone'
THEN 'timestamptz'
WHEN c.data_type = 'timestamp without time zone'
THEN 'timestamp'
WHEN c.data_type = 'USER-DEFINED'
THEN c.udt_name
ELSE c.data_type
END AS formatted_type,
c.is_nullable,
concat_ws(' ',
CASE WHEN pk.column_name IS NULL THEN NULL ELSE 'PK' END,
CASE WHEN fk.column_name IS NULL THEN NULL ELSE 'FK' END,
CASE WHEN ix.column_name IS NULL THEN NULL ELSE 'IX' END
) AS column_markers
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
LEFT JOIN (
SELECT DISTINCT
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 = 'FOREIGN KEY'
) fk
ON fk.table_schema = c.table_schema
AND fk.table_name = c.table_name
AND fk.column_name = c.column_name
LEFT JOIN (
SELECT DISTINCT
indexed_schema.nspname AS table_schema,
indexed_table.relname AS table_name,
indexed_attribute.attname AS column_name
FROM pg_catalog.pg_index index_definition
JOIN pg_catalog.pg_class indexed_table
ON indexed_table.oid = index_definition.indrelid
JOIN pg_catalog.pg_namespace indexed_schema
ON indexed_schema.oid = indexed_table.relnamespace
JOIN pg_catalog.pg_attribute indexed_attribute
ON indexed_attribute.attrelid = indexed_table.oid
AND indexed_attribute.attnum = ANY(index_definition.indkey)
WHERE indexed_schema.nspname = 'public'
) ix
ON ix.table_schema = c.table_schema
AND ix.table_name = c.table_name
AND ix.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,
CASE
WHEN EXISTS (
SELECT 1
FROM pg_catalog.pg_index unique_index
JOIN pg_catalog.pg_class unique_table
ON unique_table.oid = unique_index.indrelid
JOIN pg_catalog.pg_namespace unique_schema
ON unique_schema.oid = unique_table.relnamespace
JOIN pg_catalog.pg_attribute unique_attribute
ON unique_attribute.attrelid = unique_table.oid
AND unique_attribute.attnum = ANY(unique_index.indkey)
WHERE unique_schema.nspname = kcu.table_schema
AND unique_table.relname = kcu.table_name
AND unique_attribute.attname = kcu.column_name
AND unique_index.indisunique
AND unique_index.indnatts = 1
)
THEN 'YES'
ELSE 'NO'
END AS child_column_is_unique
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"
}
dot_label_escape() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
printf '%s' "$value"
}
table_module() {
local table_name="${1#public.}"
case "$table_name" in
AspNet*) printf 'Identity' ;;
OrganizationMemberships | Organizations) printf 'Organizations' ;;
Workspaces | WorkspaceInvites) printf 'Workspaces' ;;
Clients) printf 'Clients' ;;
Campaigns) printf 'Campaigns' ;;
Channels) printf 'Channels' ;;
ContentItems | ContentItemRevisions | ContentItemActivityEntries) printf 'ContentItems' ;;
Assets | AssetRevisions) printf 'Assets' ;;
Comments) printf 'Comments' ;;
ApprovalDecisions | ApprovalRequests | ApprovalWorkflowInstances | WorkspaceApprovalStepConfigurations) printf 'Approvals' ;;
NotificationEvents) printf 'Notifications' ;;
Feedback*) printf 'Feedback' ;;
CalendarCatalogEntries | CalendarEvents | CalendarSources | UserCalendarExportFeeds) printf 'CalendarIntegrations' ;;
*) printf 'Other' ;;
esac
}
cluster_id() {
local value="$1"
value="${value//[^[:alnum:]]/_}"
printf 'cluster_%s' "$value"
}
write_table_node() {
local table_name="$1"
local id title
id="$(node_id "$table_name")"
title="$(html_escape "${table_name#public.}")"
printf ' %s [label=<\n' "$id"
printf '