{% 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 ── #}
{# ═══════════════════════════════════════════════════════════════════ #} {# ── TAB 1: Overview (model cards + search) ── #} {# ═══════════════════════════════════════════════════════════════════ #}
{# ── Search ── #}
{{ total_models }} model{{ 's' if total_models != 1 else '' }}
{% for app in app_list %}
{{ app.app_name }}
{% for model in app.models %}
{{ model.name_plural }}
{{ model.model_name }}
{{ model_counts.get(model.model_name, '?') }}
records
{% endfor %}
{% endfor %} {% if not app_list %}
No models registered yet
{% 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) ── #}
Field Type Distribution
{# ── Model Complexity ── #}
Model Complexity fields + relations + indexes
{# ── Relation Density ── #}
Relation Density per model
{# ── Field Coverage Report ── #}
Field Coverage Report
{# ── Nullable Fields Analysis ── #}
Nullable Fields Overview
{# ── 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 %}
{{ model.name }} {{ model.table_name }} {% if model.fingerprint %} 🔑 {{ model.fingerprint }} {% endif %}
{{ model.field_count }} fields {% if model.relations %} {{ model.relations|length }} rel{{ 's' if model.relations|length != 1 else '' }} {% endif %} {% if model.m2m_tables %} {{ model.m2m_tables|length }} m2m {% endif %} {% if model.reverse_relations %} {{ model.reverse_relations|length }} rev {% endif %} {% if model.indexes %} {{ model.indexes|length }} idx {% endif %} {% if model.constraints %} {{ model.constraints|length }} cst {% endif %} {% if model.methods and (model.methods.methods|length + model.methods.class_methods|length + model.methods.properties|length) > 0 %} {{ model.methods.methods|length + model.methods.class_methods|length + model.methods.static_methods|length + model.methods.properties|length }} methods {% endif %} {% if model_counts.get(model.name) is not none %} {{ model_counts.get(model.name, '?') }} rows {% endif %}
{# ── Sub-tab navigation inside each model ── #}
{% if model.methods and (model.methods.methods|length + model.methods.class_methods|length + model.methods.static_methods|length + model.methods.properties|length) > 0 %} {% endif %} {% if model.reverse_relations %} {% endif %} {% if model.m2m_tables %} {% endif %} {% if model.sql and model.sql.create_table %} {% endif %}
{# ── SUB-TAB: Fields ── #}
{% for f in model.fields %} {% endfor %}
Field Column Type Class Python Constraints Default Relation
{% 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 %}
{# ── 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 ── #} {# ── 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 %} {% endif %} {# ── SUB-TAB: Reverse Relations ── #} {% if model.reverse_relations %} {% endif %} {# ── SUB-TAB: M2M Junction Tables ── #} {% if model.m2m_tables %} {% endif %} {# ── SUB-TAB: SQL DDL ── #} {% if model.sql and model.sql.create_table %} {% 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 %}
{% for model in model_schema %} {% for idx in model.indexes %} {% endfor %} {% for c in model.constraints %} {% endfor %} {% endfor %}
Model Index / Constraint Fields Unique Type Source Details
{{ 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 %}
{{ 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 %}
{% 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
{% for r in all_relations %} {% endfor %}
Source Model Field Type Target Model On Delete Related Name
{{ 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 %}
{% 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.name }} {{ model.table_name }}
CREATE TABLE {% if model.sql.indexes %} {{ model.sql.indexes|length }} indexes {% endif %} {% if model.sql.m2m_tables %} {{ model.sql.m2m_tables|length }} m2m {% endif %}
{{ 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
{% for mm in orm_metadata.models %} {% endfor %}
Model Table PK Fields Relations Indexes Managed
{{ 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 %}
{% 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 %}