{% extends "base.html" %}
{% block title %}ORM Models{% endblock %}
{% block breadcrumb %}ORM Models{% endblock %}
{% block extra_head %}
{% endblock %}
{% block content %}
{# ── Stats Row ── #}
{{ total_models }}
Models
{{ total_records }}
Records
{{ total_fk + total_m2m }}
Relations
{{ total_indexes }}
Indexes
{{ total_constraints }}
Constraints
{# ── Tabs ── #}
Overview
Relation Graph
Analytics
Schema Inspector
Indexes & Constraints
SQL DDL
Config & Metadata
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── TAB 1: Overview (model cards + search) ── #}
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── Search ── #}
{% for app in app_list %}
{{ app.app_name }}
{% endfor %}
{% if not app_list %}
{% endif %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── TAB 2: Relation Graph (Force-Graph 2D) ── #}
{# ═══════════════════════════════════════════════════════════════════ #}
Model Relations
{# ── Graph Stats Strip ── #}
{{ all_relations|selectattr('type','equalto','FK')|list|length }} FK
{{ all_relations|selectattr('type','equalto','O2O')|list|length }} O2O
{{ all_relations|selectattr('type','equalto','M2M')|list|length }} M2M
{{ model_schema|length }} nodes
{# ── Graph Search ── #}
{# Legend #}
FK (Foreign Key)
O2O (One-to-One)
M2M (Many-to-Many)
{# ── Graph Controls Toolbar ── #}
{# ── Tooltip ── #}
{% if not all_relations %}
No relations found between registered models.
{% endif %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── TAB 3: Analytics (charts & insights) ── #}
{# ═══════════════════════════════════════════════════════════════════ #}
Model Analytics
{# ── Field Type Distribution (donut chart) ── #}
{# ── Model Complexity ── #}
Model Complexity
fields + relations + indexes
{# ── Relation Density ── #}
Relation Density
per model
{# ── Field Coverage Report ── #}
{# ── Nullable Fields Analysis ── #}
{# ── Index Coverage per Model ── #}
Index Coverage
indexed ÷ total fields
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── TAB 4: Schema Inspector ── #}
{# ═══════════════════════════════════════════════════════════════════ #}
Schema Inspector
Click a model to expand its full schema — fields, meta options, methods, reverse relations, DDL, and fingerprint.
{% for model in model_schema %}
{# ── Sub-tab navigation inside each model ── #}
Fields
Meta
{% if model.methods and (model.methods.methods|length + model.methods.class_methods|length + model.methods.static_methods|length + model.methods.properties|length) > 0 %}
Methods
{% endif %}
{% if model.reverse_relations %}
Reverse Relations
{% endif %}
{% if model.m2m_tables %}
M2M Tables
{% endif %}
{% if model.sql and model.sql.create_table %}
SQL
{% endif %}
{# ── SUB-TAB: Fields ── #}
Field
Column
Type
Class
Python
Constraints
Default
Relation
{% for f in model.fields %}
{% if f.primary_key %} {% endif %}
{{ f.name }}
{% if f.verbose_name and f.verbose_name != f.name %}{{ f.verbose_name }} {% endif %}
{{ f.column }}
{{ f.type }}
{{ f.field_class }}
{{ f.python_type }}
{% if f.primary_key %}PK {% endif %}
{% if f.unique and not f.primary_key %}UQ {% endif %}
{% if not f.null and not f.primary_key %}NN {% endif %}
{% if f.db_index and not f.primary_key %}IDX {% endif %}
{% if f.choices %}ENUM {% endif %}
{% if f.blank %}BLANK {% endif %}
{% if f.auto_now %}AUTO_NOW {% endif %}
{% if f.auto_now_add %}AUTO_ADD {% endif %}
{% if f.validators %}{{ f.validators|length }}V {% endif %}
{% if f.default %}
{{ f.default }}
{% else %}
—
{% endif %}
{% if f.get('relation') %}
{{ f.relation.type }} → {{ f.relation.to }}
{% if f.relation.get('on_delete') %}
on_delete={{ f.relation.on_delete }}
{% endif %}
{% if f.relation.get('related_name') %}
related_name={{ f.relation.related_name }}
{% endif %}
{% else %}
—
{% endif %}
{% endfor %}
{# ── Choices detail (collapsed inline) ── #}
{% set choice_fields = model.fields|selectattr('choices')|list %}
{% if choice_fields %}
Choices / Enums:
{% for cf in choice_fields %}
{{ cf.name }}
{% for ch in cf.choices_list %}
{{ ch.value }}={{ ch.label }}
{% endfor %}
{% endfor %}
{% endif %}
{# ── SUB-TAB: Meta ── #}
{# ── Core options ── #}
Core Options
Table Name {{ model.table_name }}
Verbose Name {{ model.meta.verbose_name }}
Plural {{ model.meta.verbose_name_plural }}
App Label {{ model.meta.app_label or '—' }}
Label {{ model.meta.label or '—' }}
PK Field {{ model.pk_field }}
{# ── Flags ── #}
Flags
Managed {% if model.meta.managed %}✓ Yes {% else %}✗ No {% endif %}
Abstract {% if model.meta.abstract %}✓ Yes {% else %}✗ No {% endif %}
Proxy {% if model.meta.proxy %}✓ Yes {% else %}✗ No {% endif %}
Select on Save {% if model.meta.select_on_save %}✓ Yes {% else %}✗ No {% endif %}
{# ── Ordering & Query ── #}
Ordering & Query
Ordering {% if model.meta.ordering %}{{ model.meta.ordering|join(', ') }}{% else %}— {% endif %}
Get Latest By {% if model.meta.get_latest_by %}{{ model.meta.get_latest_by }}{% else %}— {% endif %}
Order W.R.T. {% if model.meta.order_with_respect_to %}{{ model.meta.order_with_respect_to }}{% else %}— {% endif %}
Default Related Name {% if model.meta.default_related_name %}{{ model.meta.default_related_name }}{% else %}— {% endif %}
{# ── Permissions ── #}
Permissions
Default Permissions:
{% for p in model.meta.default_permissions %}
{{ p }}
{% endfor %}
{% if model.meta.permissions %}
Custom Permissions:
{% for p in model.meta.permissions %}
{{ p.codename }}
{% endfor %}
{% endif %}
{# ── Database requirements ── #}
Database
DB Tablespace {% if model.meta.db_tablespace %}{{ model.meta.db_tablespace }}{% else %}default {% endif %}
Required Vendor {% if model.meta.required_db_vendor %}{{ model.meta.required_db_vendor }}{% else %}any {% endif %}
Required Features {% if model.meta.required_db_features %}{% for feat in model.meta.required_db_features %}{{ feat }}{% endfor %}{% else %}none {% endif %}
{% if model.meta.unique_together %}
Unique Together:
{% for ut in model.meta.unique_together %}
{% for f in ut %}{{ f }} {% endfor %}
{% endfor %}
{% endif %}
{# ── Source & Identity ── #}
Source
Module {{ model.source.module }}
File {{ model.source.file }}
{% if model.fingerprint %}
Fingerprint {{ model.fingerprint }}
{% endif %}
{# ── SUB-TAB: Methods ── #}
{% if model.methods and (model.methods.methods|length + model.methods.class_methods|length + model.methods.static_methods|length + model.methods.properties|length) > 0 %}
{% if model.methods.methods %}
Instance Methods {{ model.methods.methods|length }}
{% for m in model.methods.methods %}
def {{ m }}(self)
{% endfor %}
{% endif %}
{% if model.methods.class_methods %}
Class Methods {{ model.methods.class_methods|length }}
{% for m in model.methods.class_methods %}
@classmethod {{ m }}(cls)
{% endfor %}
{% endif %}
{% if model.methods.static_methods %}
Static Methods {{ model.methods.static_methods|length }}
{% for m in model.methods.static_methods %}
@staticmethod {{ m }}()
{% endfor %}
{% endif %}
{% if model.methods.properties %}
Properties {{ model.methods.properties|length }}
{% for m in model.methods.properties %}
@property {{ m }}
{% endfor %}
{% endif %}
{% endif %}
{# ── SUB-TAB: Reverse Relations ── #}
{% if model.reverse_relations %}
Source Model
Field
Type
On Delete
Related Name
{% for rr in model.reverse_relations %}
{{ rr.from_model }}
{{ rr.field }}
{{ rr.type }}
{% if rr.get('on_delete') %}
{{ rr.on_delete }}
{% else %}
—
{% endif %}
{% if rr.get('related_name') %}
{{ rr.related_name }}
{% else %}
auto
{% endif %}
{% endfor %}
{% endif %}
{# ── SUB-TAB: M2M Junction Tables ── #}
{% if model.m2m_tables %}
Field
Junction Table
Source Column
Target Column
Target Model
Through
{% for jt in model.m2m_tables %}
{{ jt.field }}
{{ jt.junction_table }}
{{ jt.source_column }}
{{ jt.target_column }}
{{ jt.target_model }}
{% if jt.through %}
{{ jt.through }}
{% else %}
auto
{% endif %}
{% endfor %}
{% endif %}
{# ── SUB-TAB: SQL DDL ── #}
{% if model.sql and model.sql.create_table %}
CREATE TABLE
{{ model.sql.create_table }}
{% if model.sql.indexes %}
CREATE INDEX ({{ model.sql.indexes|length }})
{% for idx_sql in model.sql.indexes %}
{{ idx_sql }}
{% endfor %}
{% endif %}
{% if model.sql.m2m_tables %}
M2M Junction Tables ({{ model.sql.m2m_tables|length }})
{% for m2m_sql in model.sql.m2m_tables %}
{{ m2m_sql }}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── TAB 5: Indexes & Constraints ── #}
{# ═══════════════════════════════════════════════════════════════════ #}
Indexes & Constraints
{% set ns = namespace(has_any=false) %}
{% for model in model_schema %}
{% if model.indexes or model.constraints %}
{% set ns.has_any = true %}
{% endif %}
{% endfor %}
{% if ns.has_any %}
Model
Index / Constraint
Fields
Unique
Type
Source
Details
{% for model in model_schema %}
{% for idx in model.indexes %}
{{ model.name }}
{{ idx.name or 'auto' }}
{% for f in idx.fields %}
{{ f }}
{% endfor %}
{% if idx.unique %}
{% else %}
—
{% endif %}
{% if idx.get('index_type') %}{{ idx.index_type }}{% else %}INDEX{% endif %}
{% if idx.get('source') %}
{{ idx.source }}
{% else %}
meta
{% endif %}
{% if idx.get('condition') %}
WHERE {{ idx.condition }}
{% else %}
—
{% endif %}
{% endfor %}
{% for c in model.constraints %}
{{ model.name }}
{{ c.name or 'auto' }}
{% if c.get('fields') %}
{% for f in c.fields %}
{{ f }}
{% endfor %}
{% elif c.get('check_expression') %}
{{ c.check_expression }}
{% endif %}
{{ c.type }}
meta
{% if c.get('violation_message') %}
⚠ {{ c.violation_message }}
{% elif c.get('check_expression') %}
CHECK({{ c.check_expression }})
{% else %}
—
{% endif %}
{% endfor %}
{% endfor %}
{% else %}
No explicit indexes or constraints defined.
Primary key and unique field indexes are auto-created.
{% endif %}
{# ── Relation table ── #}
{% if all_relations %}
Foreign Key & Relation Map
Source Model
Field
Type
Target Model
On Delete
Related Name
{% for r in all_relations %}
{{ r['from'] }}
{{ r.field }}
{{ r.type }}
{{ r.to }}
{% if r.get('on_delete') %}
{{ r.on_delete }}
{% else %}
—
{% endif %}
{% if r.related_name %}
{{ r.related_name }}
{% else %}
auto
{% endif %}
{% endfor %}
{% endif %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── TAB 6: SQL DDL (full DDL for all models) ── #}
{# ═══════════════════════════════════════════════════════════════════ #}
SQL DDL Statements
Generated DDL for the current database dialect. Includes CREATE TABLE, indexes, and junction tables.
{% for model in model_schema %}
{% if model.sql and model.sql.create_table %}
{{ model.sql.create_table }}
{% for idx_sql in model.sql.indexes %}
{{ idx_sql }}
{% endfor %}
{% for m2m_sql in model.sql.m2m_tables %}
{{ m2m_sql }}
{% endfor %}
{% endif %}
{% endfor %}
{# ═══════════════════════════════════════════════════════════════════ #}
{# ── TAB 7: Config & Metadata (database, backend, dependency graph)── #}
{# ═══════════════════════════════════════════════════════════════════ #}
ORM Config & Metadata
{# ── Database Info ── #}
Database Connection
{% if orm_metadata.database %}
{% if orm_metadata.database.get('dialect') %}
Dialect {{ orm_metadata.database.dialect }}
{% endif %}
{% if orm_metadata.database.get('driver') %}
Driver {{ orm_metadata.database.driver }}
{% endif %}
{% if orm_metadata.database.get('url') %}
URL {{ orm_metadata.database.url }}
{% endif %}
{% if orm_metadata.database.get('connected') is not none %}
Connected
{% if orm_metadata.database.connected %}
● Yes
{% else %}
○ No
{% endif %}
{% endif %}
{% if orm_metadata.database.get('in_transaction') is not none %}
In Transaction
{% if orm_metadata.database.in_transaction %}
● Yes
{% else %}
○ No
{% endif %}
{% endif %}
{% if orm_metadata.database.get('config_type') %}
Config Type {{ orm_metadata.database.config_type }}
{% endif %}
{% else %}
No database connection information available.
{% endif %}
{# ── Backend Capabilities ── #}
Backend Capabilities
{% if orm_metadata.backend %}
{% for cap_name, cap_val in orm_metadata.backend.items() %}
{% if cap_val %}
✓
{% else %}
✗
{% endif %}
{{ cap_name|replace('_',' ')|title }}
{% endfor %}
{% else %}
No backend capability information available.
{% endif %}
{# ── Stats Summary ── #}
Schema Statistics
{% if orm_metadata.stats %}
{% for stat_key, stat_val in orm_metadata.stats.items() %}
{{ stat_val }}
{{ stat_key|replace('_',' ') }}
{% endfor %}
{% endif %}
{# ── Dependency Graph ── #}
Dependency Graph
model → depends on
{% if orm_metadata.dependency_graph %}
{% for dep_model, dep_targets in orm_metadata.dependency_graph.items() %}
{{ dep_model }}
→
{% for dt in dep_targets %}
{{ dt }}
{% endfor %}
{% if not dep_targets %}
no dependencies
{% endif %}
{% endfor %}
{% else %}
No dependency information available.
{% endif %}
{# ── Model Summary Table ── #}
{% if orm_metadata.models %}
Model Summary
Model
Table
PK
Fields
Relations
Indexes
Managed
{% for mm in orm_metadata.models %}
{{ mm.name }}
{{ mm.table }}
{{ mm.pk }}
{{ mm.field_count }}
{{ mm.get('relation_count', 0) }}
{{ mm.get('index_count', 0) }}
{% if mm.get('managed', true) %}
{% else %}
✗
{% endif %}
{% endfor %}
{% endif %}
{% endblock %}
{% block extra_js %}
// ── Schema Sub-tab switcher ─────────────────────────────────────────
function switchSchemaSubTab(btn, subName) {
var card = btn.closest('.schema-model-card');
if (!card) return;
var modelName = btn.parentElement.getAttribute('data-model');
// Deactivate all sub-tabs and sub-contents within this card
card.querySelectorAll('.schema-sub-tab').forEach(function(t) {
t.classList.remove('active');
t.style.borderBottomColor = 'transparent';
t.style.color = 'var(--text-secondary)';
});
card.querySelectorAll('.schema-sub-content').forEach(function(c) {
c.style.display = 'none';
c.classList.remove('active');
});
// Activate clicked
btn.classList.add('active');
btn.style.borderBottomColor = 'var(--accent)';
btn.style.color = 'var(--text-primary)';
var target = card.querySelector('.schema-sub-content[data-sub="' + subName + '"][data-model="' + modelName + '"]');
if (target) {
target.style.display = 'block';
target.classList.add('active');
}
}
// Initialize first sub-tab styling
document.querySelectorAll('.schema-sub-tab.active').forEach(function(btn) {
btn.style.borderBottomColor = 'var(--accent)';
btn.style.color = 'var(--text-primary)';
});
// ── Model Search (client-side, instant filter) ──────────────────────
(function() {
var input = document.getElementById('modelSearchInput');
var countEl = document.getElementById('modelSearchCount');
if (!input) return;
input.addEventListener('input', function() {
var q = this.value.trim().toLowerCase();
var cards = document.querySelectorAll('.model-card');
var shown = 0;
cards.forEach(function(card) {
var name = (card.getAttribute('data-model-name') || '') + ' ' +
(card.getAttribute('data-model-label') || '');
var match = !q || name.indexOf(q) !== -1;
card.style.display = match ? '' : 'none';
if (match) shown++;
});
countEl.textContent = shown + ' model' + (shown !== 1 ? 's' : '');
document.querySelectorAll('.model-section').forEach(function(sec) {
var grid = sec.nextElementSibling;
if (!grid) return;
var visible = grid.querySelectorAll('.model-card:not([style*="display: none"])');
sec.style.display = visible.length ? '' : 'none';
grid.style.display = visible.length ? '' : 'none';
});
});
document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
input.focus();
input.select();
}
});
})();
// ── Animated stat counters ──
document.querySelectorAll('.stat-number[data-count]').forEach(function(el) {
var target = parseInt(el.getAttribute('data-count'), 10);
if (isNaN(target) || target <= 0) return;
var duration = 600, start = performance.now();
el.textContent = '0';
function tick(now) {
var t = Math.min((now - start) / duration, 1);
var ease = 1 - Math.pow(1 - t, 3);
el.textContent = Math.round(ease * target);
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
// ═══════════════════════════════════════════════════════════════════
// ── Force-Graph 2D Relation Graph (vasturiano/force-graph) ──
// ═══════════════════════════════════════════════════════════════════
var _fgInit = false;
var _fgInstance = null;
function initForceGraph() {
if (_fgInit) return;
_fgInit = true;
var schema = {{ model_schema|tojson }};
var relations = {{ all_relations|tojson }};
var mountEl = document.getElementById('fgMount');
var containerEl = document.getElementById('fgContainer');
var tooltip = document.getElementById('fgTooltip');
if (!mountEl || !schema.length) return;
var accentHex = '#22c55e';
var infoHex = '#3b82f6';
var warningHex = '#f59e0b';
var typeColors = { FK: accentHex, O2O: infoHex, M2M: warningHex };
function isDark() {
return document.documentElement.getAttribute('data-theme') === 'dark';
}
function themeColors() {
var d = isDark();
return {
bg: d ? '#0f1117' : '#f8fafc',
nodeBg: d ? '#1e2130' : '#ffffff',
border: accentHex,
text: d ? '#e2e8f0' : '#1e293b',
textSub: d ? '#94a3b8' : '#64748b'
};
}
// ── Build graph data ──
var nodeDataMap = {};
var nodes = [];
var links = [];
var maxFields = 1;
schema.forEach(function(m) {
nodeDataMap[m.name] = m;
if (m.field_count > maxFields) maxFields = m.field_count;
nodes.push({ id: m.name, fieldCount: m.field_count, relCount: m.relations.length });
});
relations.forEach(function(r) {
if (!nodeDataMap[r['from']] || !nodeDataMap[r.to]) return;
links.push({
source: r['from'],
target: r.to,
relType: r.type,
field: r.field,
onDelete: r.on_delete || '',
relatedName: r.related_name || '',
color: typeColors[r.type] || '#64748b'
});
});
// ── Node size helper ──
function nodeRadius(n) {
return 8 + (n.fieldCount / maxFields) * 18;
}
// ── Hover state ──
var hoveredNode = null;
var highlightNodes = new Set();
var highlightLinks = new Set();
function updateHighlight(node) {
highlightNodes.clear();
highlightLinks.clear();
if (node) {
highlightNodes.add(node.id);
links.forEach(function(l) {
var sid = typeof l.source === 'object' ? l.source.id : l.source;
var tid = typeof l.target === 'object' ? l.target.id : l.target;
if (sid === node.id || tid === node.id) {
highlightNodes.add(sid);
highlightNodes.add(tid);
highlightLinks.add(l);
}
});
}
hoveredNode = node;
}
// ── Tooltip builder ──
function showTooltip(node) {
if (!tooltip || !node) return;
var m = nodeDataMap[node.id];
if (!m) return;
var fieldsHtml = '';
var maxF = 6;
(m.fields || []).slice(0, maxF).forEach(function(f) {
fieldsHtml += '' + f.name + ' ' + f.type + '
';
});
if ((m.fields || []).length > maxF) {
fieldsHtml += '+' + ((m.fields || []).length - maxF) + ' more…
';
}
var relHtml = '';
(m.relations || []).forEach(function(r) {
var c = r.type === 'FK' ? 'var(--accent)' : r.type === 'O2O' ? 'var(--info)' : 'var(--warning)';
relHtml += '' + r.type + ' → ' + r.to + '
';
});
tooltip.innerHTML = '' + node.id + '
'
+ '' + m.field_count + ' fields · ' + m.relations.length + ' relations
'
+ fieldsHtml
+ (relHtml ? '' + relHtml + '
' : '');
tooltip.classList.add('visible');
tooltip.style.right = '16px';
tooltip.style.top = '16px';
tooltip.style.left = 'auto';
}
function hideTooltip() {
if (tooltip) tooltip.classList.remove('visible');
}
// ── Create Force-Graph instance ──
var tc = themeColors();
var graph = new ForceGraph(mountEl)
.graphData({ nodes: nodes, links: links })
.width(mountEl.offsetWidth)
.height(mountEl.offsetHeight)
.backgroundColor(tc.bg)
// ── Nodes ──
.nodeRelSize(1)
.nodeVal(function(n) { return nodeRadius(n); })
.nodeCanvasObject(function(node, ctx, globalScale) {
var tc2 = themeColors();
var r = nodeRadius(node);
var isHl = highlightNodes.has(node.id);
var dimmed = hoveredNode && !isHl;
var alpha = dimmed ? 0.12 : 1;
ctx.globalAlpha = alpha;
// Glow for highlighted
if (isHl && hoveredNode) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 4, 0, 2 * Math.PI);
ctx.fillStyle = accentHex + '33';
ctx.fill();
}
// Node circle
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI);
ctx.fillStyle = (isHl && node.id === (hoveredNode && hoveredNode.id)) ? accentHex : tc2.nodeBg;
ctx.fill();
ctx.strokeStyle = accentHex;
ctx.lineWidth = isHl ? 2.5 : 1.5;
ctx.stroke();
// Label
var fontSize = Math.max(10 / globalScale, 3);
ctx.font = '600 ' + fontSize + 'px Outfit, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = (isHl && node.id === (hoveredNode && hoveredNode.id)) ? '#ffffff' : tc2.text;
ctx.fillText(node.id, node.x, node.y + r + 3 / globalScale);
ctx.globalAlpha = 1;
})
.nodeCanvasObjectMode(function() { return 'replace'; })
// ── Links ──
.linkColor(function(l) { return l.color; })
.linkWidth(function(l) {
return highlightLinks.has(l) ? 3 : 1.5;
})
.linkLineDash(function(l) {
return l.relType === 'M2M' ? [6, 3] : null;
})
.linkDirectionalArrowLength(8)
.linkDirectionalArrowRelPos(0.85)
.linkDirectionalArrowColor(function(l) { return l.color; })
.linkDirectionalParticles(function(l) {
return highlightLinks.has(l) ? 5 : 2;
})
.linkDirectionalParticleWidth(function(l) {
return highlightLinks.has(l) ? 4 : 2;
})
.linkDirectionalParticleColor(function(l) { return l.color; })
.linkDirectionalParticleSpeed(0.005)
.linkCanvasObjectMode(function() { return 'after'; })
.linkCanvasObject(function(link, ctx, globalScale) {
if (!link.source || !link.target) return;
var sx = typeof link.source === 'object' ? link.source.x : 0;
var sy = typeof link.source === 'object' ? link.source.y : 0;
var tx = typeof link.target === 'object' ? link.target.x : 0;
var ty = typeof link.target === 'object' ? link.target.y : 0;
var mx = (sx + tx) / 2;
var my = (sy + ty) / 2;
var dimmed = hoveredNode && !highlightLinks.has(link);
if (dimmed) return;
var fontSize = Math.max(9 / globalScale, 2.5);
ctx.font = '500 ' + fontSize + 'px Outfit, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
var tc2 = themeColors();
// Text background
var txt = link.relType;
var tw = ctx.measureText(txt).width;
ctx.fillStyle = tc2.bg;
ctx.fillRect(mx - tw / 2 - 2, my - fontSize / 2 - 1, tw + 4, fontSize + 2);
ctx.fillStyle = link.color;
ctx.fillText(txt, mx, my);
})
.linkVisibility(function(l) {
if (!hoveredNode) return true;
return highlightLinks.has(l);
})
// ── Interaction ──
.onNodeHover(function(node) {
mountEl.style.cursor = node ? 'pointer' : 'default';
updateHighlight(node);
if (node) showTooltip(node); else hideTooltip();
})
.onNodeClick(function(node) {
graph.centerAt(node.x, node.y, 400);
graph.zoom(4, 400);
})
.onNodeDragEnd(function(node) {
node.fx = node.x;
node.fy = node.y;
})
// ── Force tuning ──
.d3AlphaDecay(0.02)
.d3VelocityDecay(0.3)
.cooldownTicks(200)
.warmupTicks(50);
// Tweak forces after creation
graph.d3Force('charge').strength(-300).distanceMax(400);
graph.d3Force('link').distance(100);
graph.d3Force('center', null);
// Re-add a center force manually for better grouping
var d3 = window.d3 || (graph.d3Force('x') && graph.d3Force('x').constructor && { forceCenter: null });
if (typeof d3 !== 'undefined' && d3 && d3.forceCenter) {
graph.d3Force('center', d3.forceCenter());
}
_fgInstance = graph;
// ── Show all links when not hovering ──
// Force-graph renders all links by default; our linkVisibility above handles dimming.
// Override to show all when no hover:
// (already handled in linkVisibility callback)
// ── Controls ──
document.getElementById('fgZoomIn').addEventListener('click', function() {
graph.zoom(graph.zoom() * 1.5, 300);
});
document.getElementById('fgZoomOut').addEventListener('click', function() {
graph.zoom(graph.zoom() * 0.67, 300);
});
document.getElementById('fgCenter').addEventListener('click', function() {
graph.zoomToFit(400, 40);
});
document.getElementById('fgFullscreen').addEventListener('click', function() {
if (!document.fullscreenElement) {
containerEl.requestFullscreen().catch(function() {});
} else {
document.exitFullscreen();
}
});
// ── Resize handler ──
var resizeTimer;
new ResizeObserver(function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
graph.width(mountEl.offsetWidth).height(mountEl.offsetHeight);
}, 100);
}).observe(mountEl);
// ── Graph node search ──
var searchInput = document.getElementById('graphSearchInput');
if (searchInput) {
searchInput.addEventListener('input', function() {
var q = this.value.trim().toLowerCase();
updateHighlight(null);
if (!q) return;
var found = nodes.filter(function(n) { return n.id.toLowerCase().indexOf(q) !== -1; });
if (found.length) {
var target = found[0];
updateHighlight(target);
showTooltip(target);
graph.centerAt(target.x, target.y, 400);
graph.zoom(3, 400);
}
});
}
// ── Theme change listener ──
new MutationObserver(function() {
var t = themeColors();
graph.backgroundColor(t.bg);
}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
// ── Fit after stabilization ──
setTimeout(function() { graph.zoomToFit(600, 40); }, 1500);
}
// ═══════════════════════════════════════════════════════════════════
// ── Analytics Charts ──
// ═══════════════════════════════════════════════════════════════════
var _analyticsInit = false;
function initAnalytics() {
if (_analyticsInit) return;
_analyticsInit = true;
var schema = {{ model_schema|tojson }};
var relations = {{ all_relations|tojson }};
var modelCounts = {{ model_counts|tojson }};
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
var accentHex = '#22c55e';
var infoHex = '#3b82f6';
var warningHex = '#f59e0b';
// ── Palette for charts ──
var palette = ['#22c55e','#3b82f6','#f59e0b','#ec4899','#8b5cf6','#06b6d4','#f97316','#14b8a6','#6366f1','#a855f7','#e11d48','#eab308'];
// ════════════════════════════════════════
// 1. Field Type Distribution — Donut
// ════════════════════════════════════════
var typeCounts = {};
schema.forEach(function(m) {
(m.fields || []).forEach(function(f) {
var t = f.type || 'Unknown';
typeCounts[t] = (typeCounts[t] || 0) + 1;
});
});
var typeEntries = Object.entries(typeCounts).sort(function(a,b){ return b[1]-a[1]; });
var totalFields = typeEntries.reduce(function(s,e){ return s+e[1]; }, 0);
var canvas = document.getElementById('fieldTypeDonut');
if (canvas && typeEntries.length > 0) {
var ctx = canvas.getContext('2d');
var cx = 80, cy = 80, R = 65, r = 38;
var startAngle = -Math.PI / 2;
// Animate donut
var progress = 0;
function drawDonut() {
progress = Math.min(progress + 0.03, 1);
var ease = 1 - Math.pow(1 - progress, 3);
ctx.clearRect(0, 0, 160, 160);
var angle = startAngle;
typeEntries.forEach(function(entry, i) {
var sliceAngle = (entry[1] / totalFields) * 2 * Math.PI * ease;
ctx.beginPath();
ctx.arc(cx, cy, R, angle, angle + sliceAngle);
ctx.arc(cx, cy, r, angle + sliceAngle, angle, true);
ctx.closePath();
ctx.fillStyle = palette[i % palette.length];
ctx.fill();
angle += sliceAngle;
});
// Center text
ctx.fillStyle = isDark ? '#e2e8f0' : '#1e293b';
ctx.font = '700 18px Outfit, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(totalFields, cx, cy - 6);
ctx.fillStyle = isDark ? '#64748b' : '#94a3b8';
ctx.font = '500 10px Outfit, sans-serif';
ctx.fillText('fields', cx, cy + 10);
if (progress < 1) requestAnimationFrame(drawDonut);
}
drawDonut();
// Legend
var legendEl = document.getElementById('fieldTypeLegend');
if (legendEl) {
var lhtml = '';
typeEntries.slice(0, 10).forEach(function(e, i) {
var pct = ((e[1] / totalFields) * 100).toFixed(0);
lhtml += ''
+ ' '
+ ''+e[0]+' '
+ ''+e[1]+' ('+pct+'%) '
+ '
';
});
if (typeEntries.length > 10) {
var rest = typeEntries.slice(10).reduce(function(s,e){ return s+e[1]; }, 0);
lhtml += '+' + (typeEntries.length - 10) + ' more (' + rest + ')
';
}
legendEl.innerHTML = lhtml;
}
}
// ════════════════════════════════════════
// 2. Model Complexity — Horizontal bars
// ════════════════════════════════════════
var complexEl = document.getElementById('complexityBars');
if (complexEl && schema.length > 0) {
var items = schema.map(function(m) {
return { name: m.name, score: m.field_count + m.relations.length * 3 + (m.indexes || []).length * 2, fields: m.field_count, rels: m.relations.length, idxs: (m.indexes||[]).length };
}).sort(function(a,b){ return b.score - a.score; });
var maxScore = items[0].score || 1;
var bhtml = '';
items.forEach(function(it, i) {
var pct = Math.max(4, (it.score / maxScore) * 100);
var color = palette[i % palette.length];
bhtml += ''
+ '
'+it.name+' '
+ '
'
+ '
'
+ ''+it.score+' '
+ '
'
+ '
'
+ '
'+it.fields+'f '+it.rels+'r '+it.idxs+'i '
+ '
';
});
complexEl.innerHTML = bhtml;
}
// ════════════════════════════════════════
// 3. Relation Density — per model
// ════════════════════════════════════════
var relDensity = document.getElementById('relationDensity');
if (relDensity && schema.length > 0) {
var relItems = schema.map(function(m) {
var fk = 0, o2o = 0, m2m = 0;
m.relations.forEach(function(r) {
if (r.type === 'FK') fk++;
else if (r.type === 'O2O') o2o++;
else if (r.type === 'M2M') m2m++;
});
return { name: m.name, fk: fk, o2o: o2o, m2m: m2m, total: fk+o2o+m2m };
}).filter(function(it){ return it.total > 0; }).sort(function(a,b){ return b.total - a.total; });
var maxRel = relItems.length ? relItems[0].total : 1;
var rhtml = '';
relItems.forEach(function(it) {
var totalW = Math.max(8, (it.total / maxRel) * 100);
var fkW = it.fk / it.total * totalW;
var o2oW = it.o2o / it.total * totalW;
var m2mW = it.m2m / it.total * totalW;
rhtml += ''
+ '
'+it.name+' '
+ '
';
if (it.fk > 0) rhtml += '
';
if (it.o2o > 0) rhtml += '
';
if (it.m2m > 0) rhtml += '
';
rhtml += '
'
+ '
'+it.fk+'FK '+it.o2o+'O2O '+it.m2m+'M2M '
+ '
';
});
if (!relItems.length) rhtml = 'No relations found.
';
relDensity.innerHTML = rhtml;
}
// ════════════════════════════════════════
// 4. Field Coverage Report
// ════════════════════════════════════════
var coverageEl = document.getElementById('coverageReport');
if (coverageEl && schema.length > 0) {
var totalF = 0, nullableF = 0, indexedF = 0, uniqueF = 0, pkF = 0, choicesF = 0, defaultF = 0;
schema.forEach(function(m) {
(m.fields || []).forEach(function(f) {
totalF++;
if (f.null) nullableF++;
if (f.db_index) indexedF++;
if (f.unique) uniqueF++;
if (f.primary_key) pkF++;
if (f.choices) choicesF++;
if (f['default']) defaultF++;
});
});
function pctBar(label, count, color) {
var p = totalF > 0 ? ((count / totalF) * 100).toFixed(1) : 0;
return ''
+ '
'
+ ''+label+' '
+ ''+count+'/'+totalF+' ('+p+'%)
'
+ '
';
}
var chtml = pctBar('Nullable', nullableF, warningHex)
+ pctBar('Indexed', indexedF, infoHex)
+ pctBar('Unique', uniqueF, '#8b5cf6')
+ pctBar('Has Default', defaultF, accentHex)
+ pctBar('Primary Key', pkF, '#f97316')
+ pctBar('Choices/Enum', choicesF, '#ec4899');
coverageEl.innerHTML = chtml;
}
// ════════════════════════════════════════
// 5. Nullable Fields Overview
// ════════════════════════════════════════
var nullableEl = document.getElementById('nullableChart');
if (nullableEl && schema.length > 0) {
var nullItems = schema.map(function(m) {
var total = m.field_count, nullable = 0;
(m.fields || []).forEach(function(f) { if (f.null) nullable++; });
return { name: m.name, nullable: nullable, total: total };
}).filter(function(it){ return it.nullable > 0; }).sort(function(a,b){ return (b.nullable/b.total) - (a.nullable/a.total); });
var nhtml = '';
if (nullItems.length > 0) {
nullItems.forEach(function(it) {
var pct = ((it.nullable / it.total) * 100).toFixed(0);
var barColor = it.nullable / it.total > 0.5 ? '#ef4444' : it.nullable / it.total > 0.25 ? warningHex : accentHex;
nhtml += ''
+ '
'+it.name+' '
+ '
'
+ '
'+it.nullable+'/'+it.total+' ('+pct+'%) '
+ '
';
});
} else {
nhtml = ' No nullable fields found — great schema hygiene!
';
}
nullableEl.innerHTML = nhtml;
}
// ════════════════════════════════════════
// 6. Index Coverage per Model
// ════════════════════════════════════════
var idxCovEl = document.getElementById('indexCoverage');
if (idxCovEl && schema.length > 0) {
var idxItems = schema.map(function(m) {
var total = m.field_count;
var indexedFields = new Set();
(m.indexes || []).forEach(function(idx) {
(idx.fields || []).forEach(function(f) { indexedFields.add(f); });
});
(m.fields || []).forEach(function(f) {
if (f.db_index || f.primary_key) indexedFields.add(f.name);
});
return { name: m.name, indexed: indexedFields.size, total: total };
}).sort(function(a,b){ return (b.indexed/b.total) - (a.indexed/a.total); });
var ihtml = '';
idxItems.forEach(function(it) {
var pct = it.total > 0 ? ((it.indexed / it.total) * 100).toFixed(0) : 0;
var color = pct > 60 ? accentHex : pct > 30 ? infoHex : (isDark ? '#475569' : '#94a3b8');
ihtml += ''
+ '
'+it.name+' '
+ '
'
+ '
'+it.indexed+'/'+it.total+' ('+pct+'%) '
+ '
';
});
idxCovEl.innerHTML = ihtml;
}
}
// ── SQL Syntax Highlighting ─────────────────────────────────────────
(function() {
// Token rules ordered by priority (first match wins)
var SQL_RULES = [
// 1. Block comments /* ... */
{ re: /\/\*[\s\S]*?\*\//g, cls: 'sql-comment' },
// 2. Single-line comments -- ...
{ re: /--[^\n]*/g, cls: 'sql-comment' },
// 3. Strings '...'
{ re: /'(?:[^'\\]|\\.)*'/g, cls: 'sql-string' },
// 4. Quoted identifiers "..."
{ re: /"(?:[^"\\]|\\.)*"/g, cls: 'sql-identifier' },
// 5. Backtick identifiers `...`
{ re: /`[^`]*`/g, cls: 'sql-identifier' },
// 6. Numbers (integer and decimal)
{ re: /\b\d+(?:\.\d+)?\b/g, cls: 'sql-number' },
// 7. SQL keywords
{ re: /\b(?:CREATE|TABLE|INDEX|UNIQUE|IF|NOT|EXISTS|DROP|ALTER|ADD|COLUMN|CONSTRAINT|PRIMARY|KEY|FOREIGN|REFERENCES|ON|DELETE|UPDATE|CASCADE|SET|NULL|DEFAULT|CHECK|INSERT|INTO|VALUES|SELECT|FROM|WHERE|AND|OR|IN|BETWEEN|LIKE|ORDER|BY|ASC|DESC|GROUP|HAVING|LIMIT|OFFSET|JOIN|LEFT|RIGHT|INNER|OUTER|CROSS|NATURAL|UNION|ALL|AS|DISTINCT|COUNT|SUM|AVG|MIN|MAX|CASE|WHEN|THEN|ELSE|END|USING|WITH|RECURSIVE|REPLACE|TEMPORARY|TEMP|AUTOINCREMENT|AUTO_INCREMENT|SERIAL|BIGSERIAL|RETURNING|DEFERRABLE|INITIALLY|DEFERRED|IMMEDIATE|RESTRICT|NO|ACTION|TRIGGER|BEFORE|AFTER|FOR|EACH|ROW|BEGIN|COMMIT|ROLLBACK|TRANSACTION|GRANT|REVOKE|EXPLAIN|ANALYZE|VACUUM|PRAGMA|ATTACH|DETACH|RENAME|TO|TRUNCATE|COMMENT|SEQUENCE|OWNED|NEXTVAL|CURRVAL|TRUE|FALSE|BOOLEAN|NULLS|FIRST|LAST|ONLY|COLLATE|CONFLICT|ABORT|FAIL|IGNORE|CURRENT_TIMESTAMP|CURRENT_DATE|CURRENT_TIME)\b/gi, cls: 'sql-keyword' },
// 8. Data types
{ re: /\b(?:INTEGER|INT|SMALLINT|BIGINT|TINYINT|MEDIUMINT|FLOAT|DOUBLE|REAL|DECIMAL|NUMERIC|CHAR|VARCHAR|NCHAR|NVARCHAR|TEXT|TINYTEXT|MEDIUMTEXT|LONGTEXT|BLOB|TINYBLOB|MEDIUMBLOB|LONGBLOB|CLOB|DATE|TIME|DATETIME|TIMESTAMP|TIMESTAMPTZ|INTERVAL|YEAR|BOOLEAN|BOOL|BIT|BINARY|VARBINARY|BYTEA|UUID|JSON|JSONB|XML|ARRAY|ENUM|MONEY|INET|CIDR|MACADDR|POINT|LINE|POLYGON|CIRCLE|BOX|PATH|SERIAL|BIGSERIAL|SMALLSERIAL)\b/gi, cls: 'sql-type' },
// 9. SQL functions (common)
{ re: /\b(?:COALESCE|NULLIF|CAST|CONVERT|IFNULL|NVL|LENGTH|SUBSTR|SUBSTRING|TRIM|UPPER|LOWER|REPLACE|CONCAT|NOW|STRFTIME|DATE_TRUNC|EXTRACT|TO_CHAR|TO_DATE|ROUND|CEIL|FLOOR|ABS|MOD|RANDOM|ROW_NUMBER|RANK|DENSE_RANK|LEAD|LAG|FIRST_VALUE|LAST_VALUE|NTILE|OVER|PARTITION|RANGE|ROWS|UNBOUNDED|PRECEDING|FOLLOWING|CURRENT)\b/gi, cls: 'sql-function' },
// 10. Punctuation
{ re: /[(),;]/g, cls: 'sql-punctuation' }
];
function highlightSQL(text) {
// Collect all token matches with their positions
var tokens = [];
SQL_RULES.forEach(function(rule) {
var re = new RegExp(rule.re.source, rule.re.flags);
var m;
while ((m = re.exec(text)) !== null) {
tokens.push({ start: m.index, end: m.index + m[0].length, text: m[0], cls: rule.cls });
}
});
// Sort by start position, then by length descending (longer match wins)
tokens.sort(function(a, b) { return a.start - b.start || (b.end - b.start) - (a.end - a.start); });
// Remove overlapping tokens (first match wins)
var filtered = [];
var lastEnd = 0;
tokens.forEach(function(tok) {
if (tok.start >= lastEnd) {
filtered.push(tok);
lastEnd = tok.end;
}
});
// Build highlighted HTML
var result = '';
var pos = 0;
filtered.forEach(function(tok) {
if (tok.start > pos) {
result += escHTML(text.substring(pos, tok.start));
}
result += '' + escHTML(tok.text) + ' ';
pos = tok.end;
});
if (pos < text.length) {
result += escHTML(text.substring(pos));
}
return result;
}
function escHTML(s) {
return s.replace(/&/g,'&').replace(//g,'>');
}
// Apply highlighting to all sql-highlight pre blocks
function applySQLHighlighting() {
document.querySelectorAll('pre.sql-highlight').forEach(function(el) {
if (el.getAttribute('data-highlighted')) return;
el.setAttribute('data-highlighted', '1');
var raw = el.textContent || '';
el.innerHTML = highlightSQL(raw);
});
}
// Run on load and observe for lazy-loaded content (sub-tabs)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applySQLHighlighting);
} else {
applySQLHighlighting();
}
// Re-highlight when sub-tabs are switched (content becomes visible)
var origSwitch = window.switchSchemaSubTab;
window.switchSchemaSubTab = function(btn, subName) {
origSwitch(btn, subName);
applySQLHighlighting();
};
// Also highlight when DDL accordion cards are expanded
document.querySelectorAll('.schema-model-header').forEach(function(hdr) {
hdr.addEventListener('click', function() {
setTimeout(applySQLHighlighting, 50);
});
});
})();
{% endblock %}