#!/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//\"/"}" 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 ' \n' printf ' \n' "$title" printf '%s' "${TABLE_COLUMNS[$table_name]}" printf '
%s
\n' printf ' >];\n\n' } declare -A TABLE_COLUMNS declare -A TABLE_SEEN declare -A MODULE_SEEN declare -A MODULE_TABLES declare -a TABLE_ORDER declare -a MODULE_ORDER while IFS=$'\t' read -r table_name column_name column_type nullable column_markers; do [[ -n "$table_name" ]] || continue if [[ -z "${TABLE_SEEN[$table_name]:-}" ]]; then TABLE_SEEN["$table_name"]=1 TABLE_ORDER+=("$table_name") module_name="$(table_module "$table_name")" if [[ -z "${MODULE_SEEN[$module_name]:-}" ]]; then MODULE_SEEN["$module_name"]=1 MODULE_ORDER+=("$module_name") fi MODULE_TABLES["$module_name"]+="${table_name}"$'\n' fi label="$(html_escape "$column_name")" type_label="$(html_escape "$column_type")" marker_label="$(html_escape "$column_markers")" [[ -n "$marker_label" ]] || marker_label=" " nullable_label="" [[ "$nullable" == "YES" ]] && nullable_label=' nullable' TABLE_COLUMNS["$table_name"]+=$' '"${marker_label}"$''"${label}"$''"${type_label}${nullable_label}"$'\n' done < "$TABLES_TSV" { cat <<'DOT' digraph database { graph [ rankdir=LR, bgcolor="white", pad=0.4, nodesep=0.6, ranksep=1.0, splines=spline, overlap=false ]; node [ shape=plain, fontname="Arial" ]; edge [ color="#64748b", arrowsize=0.7, fontname="Arial", fontsize=10, labeldistance=1.8, labelangle=22 ]; legend [label=<
Legend
PKprimary key column
FKforeign key column
IXindexed column
solid linerequired FK
dashed linenullable FK
1exactly one referenced row
0..1zero or one referencing row
0..*zero or many referencing rows
Edges point from referenced table to referencing table.
>]; DOT for module_name in "${MODULE_ORDER[@]}"; do id="$(cluster_id "$module_name")" label="$(html_escape "$module_name")" printf ' subgraph %s {\n' "$id" printf ' label=< %s >;\n' "$label" printf ' color="#cbd5e1";\n' printf ' penwidth=1.5;\n' printf ' style="rounded";\n' printf ' margin=18;\n' printf ' fontname="Arial";\n' printf ' fontsize=16;\n' printf ' fontcolor="#334155";\n\n' while IFS= read -r table_name; do [[ -n "$table_name" ]] || continue write_table_node "$table_name" done <<< "${MODULE_TABLES[$module_name]}" printf ' }\n\n' done while IFS=$'\t' read -r constraint_name child_table child_column parent_table parent_column nullable child_column_is_unique; do [[ -n "$constraint_name" ]] || continue child_id="$(node_id "$child_table")" parent_id="$(node_id "$parent_table")" child_cardinality="0..*" [[ "$child_column_is_unique" == "YES" ]] && child_cardinality="0..1" label="$(dot_label_escape "${parent_column} -> ${child_column}")" style="solid" [[ "$nullable" == "YES" ]] && style="dashed" printf ' %s -> %s [label="%s", taillabel="1", headlabel="%s", style="%s"];\n' "$parent_id" "$child_id" "$label" "$child_cardinality" "$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" < Socialize Database Diagram

Socialize Database Diagram

SVG PNG DOT
Open database-diagram.svg or database-diagram.png from this folder.
HTML echo "Wrote:" echo " ${HTML_FILE}" [[ -f "$SVG_FILE" ]] && echo " ${SVG_FILE}" [[ -f "$PNG_FILE" ]] && echo " ${PNG_FILE}" echo " ${DOT_FILE}"