diff --git a/media/materialized-view-designer.css b/media/materialized-view-designer.css
new file mode 100644
index 0000000..de24b93
--- /dev/null
+++ b/media/materialized-view-designer.css
@@ -0,0 +1,701 @@
+/* Reset, common :root design tokens, body font defaults and scrollbar rules
+ * live in shared.css. This file declares materialized-view-designer-specific variables,
+ * body overrides and components. */
+
+:root {
+ --btn-fg: var(--vscode-button-foreground, #fff);
+ --bg: var(--vscode-editor-background, #1e1e2e);
+}
+
+body {
+ font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
+ font-size: 13px;
+ color: var(--text);
+ background: var(--bg);
+ line-height: 1.4;
+ overflow: hidden;
+ height: 100vh;
+ -webkit-font-smoothing: antialiased;
+}
+
+.designer-panel {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ background: var(--surface);
+ backdrop-filter: var(--glass-blur);
+ -webkit-backdrop-filter: var(--glass-blur);
+ border: 1px solid var(--glass-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--glass-shadow), var(--glass-inset);
+ margin: 4px;
+ overflow: hidden;
+}
+
+.header-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 12px;
+ background: rgba(255,255,255,0.02);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ height: 34px;
+ gap: 12px;
+}
+
+.header-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+
+.header-icon {
+ flex-shrink: 0;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ justify-content: flex-end;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ transition: all var(--transition);
+ white-space: nowrap;
+ font-family: inherit;
+}
+
+.btn-primary {
+ background: var(--btn-bg);
+ color: var(--btn-fg);
+ box-shadow: 0 1px 4px rgba(74,158,255,0.2);
+}
+
+.btn-primary:hover {
+ box-shadow: 0 2px 8px rgba(74,158,255,0.3);
+}
+
+.btn-primary:active {
+ transform: scale(0.96);
+}
+
+.btn-secondary {
+ background: var(--btn-secondary-bg);
+ color: var(--text);
+ border: 1px solid var(--input-border);
+}
+
+.btn-secondary:hover {
+ background: var(--btn-secondary-hover);
+}
+
+.btn:active {
+ transform: scale(0.96);
+}
+
+.info-bar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 4px 12px;
+ background: rgba(255,255,255,0.02);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ height: 30px;
+}
+
+.info-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.info-label {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.info-input {
+ width: 160px;
+ padding: 2px 6px;
+ background: var(--input-bg);
+ border: 1px solid var(--input-border);
+ border-radius: var(--radius-sm);
+ color: var(--input-fg);
+ font-size: 11px;
+ font-family: inherit;
+ transition: border-color var(--transition);
+}
+
+.info-input:focus {
+ outline: none;
+ border-color: var(--focus-border);
+ box-shadow: 0 0 0 2px var(--accent-dim);
+}
+
+.info-select {
+ padding: 2px 6px;
+ background: var(--input-bg);
+ border: 1px solid var(--input-border);
+ border-radius: var(--radius-sm);
+ color: var(--input-fg);
+ font-size: 11px;
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color var(--transition);
+}
+
+.info-select:focus {
+ outline: none;
+ border-color: var(--focus-border);
+}
+
+.info-value {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text);
+ padding: 2px 6px;
+ background: var(--input-bg);
+ border: 1px solid var(--input-border);
+ border-radius: var(--radius-sm);
+}
+
+.panel-split {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.editor-section {
+ flex: 1 1 60%;
+ min-height: 100px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.editor-container {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+}
+
+.monaco-editor-container {
+ width: 100%;
+ height: 100%;
+}
+
+.monaco-editor-container .monaco-editor .margin,
+.monaco-editor-container .monaco-editor .margin-view-overlays,
+.monaco-editor-container .monaco-editor .glyph-margin,
+.monaco-editor-container .monaco-editor .line-numbers {
+ background-color: var(--vscode-editor-background) !important;
+}
+
+.monaco-editor-container .monaco-editor .current-line,
+.monaco-editor-container .monaco-editor .margin-view-overlays .current-line-margin {
+ background-color: transparent !important;
+}
+
+.monaco-editor-container .monaco-editor .scroll-decoration {
+ display: none !important;
+}
+
+.splitter {
+ position: relative;
+ height: 6px;
+ cursor: row-resize;
+ background: transparent;
+ flex-shrink: 0;
+ z-index: 10;
+}
+
+.splitter::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ height: 1px;
+ background: var(--splitter-color);
+}
+
+.splitter:hover {
+ background: var(--splitter-hover);
+}
+
+.splitter:active {
+ background: var(--splitter-active);
+}
+
+.splitter-handle {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 32px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--splitter-color);
+}
+
+.splitter:hover .splitter-handle {
+ background: var(--splitter-active);
+}
+
+.bottom-section {
+ flex: 0 0 180px;
+ min-height: 80px;
+ max-height: calc(100% - 100px);
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--border);
+ overflow: hidden;
+}
+
+.tab-content {
+ flex: 1;
+ overflow: hidden;
+ min-height: 0;
+}
+
+.tab-page {
+ display: none;
+ height: 100%;
+ overflow: auto;
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--text);
+}
+
+.tab-page.active {
+ display: block;
+}
+
+.sql-preview-content {
+ padding: 8px 12px;
+ margin: 0;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ color: inherit;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ background: transparent;
+}
+
+.messages-container {
+ padding: 6px 12px;
+}
+
+.msg-item {
+ padding: 3px 0;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.msg-item:last-child {
+ border-bottom: none;
+}
+
+.msg-time {
+ color: var(--text-secondary);
+ font-size: 11px;
+ flex-shrink: 0;
+ opacity: 0.7;
+}
+
+.msg-item.msg-error {
+ color: var(--error-color);
+}
+
+.msg-item.msg-warning {
+ color: var(--warning-color);
+}
+
+.msg-item.msg-info {
+ color: var(--text-secondary);
+}
+
+.msg-item.msg-success {
+ color: var(--success-color);
+}
+
+.bottom-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 8px;
+ background: rgba(255,255,255,0.02);
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+ height: 26px;
+}
+
+.tab-bar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.tab-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: all var(--transition);
+}
+
+.tab-btn:hover {
+ background: var(--btn-secondary-hover);
+ color: var(--text);
+}
+
+.tab-btn.active {
+ background: var(--accent-dim);
+ color: var(--accent);
+}
+
+.status-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border: none;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 11px;
+ padding: 0;
+ transition: all var(--transition);
+}
+
+.btn-icon:hover {
+ background: var(--btn-secondary-hover);
+ color: var(--text);
+}
+
+.sql-keyword {
+ color: #569cd6;
+ font-weight: 600;
+}
+
+.sql-type {
+ color: #4ec9b0;
+}
+
+.sql-string {
+ color: #ce9178;
+}
+
+.sql-identifier {
+ color: #9cdcfe;
+}
+
+.sql-comment {
+ color: #6a9955;
+ font-style: italic;
+}
+
+.sql-number {
+ color: #b5cea8;
+}
+
+.diff-overlay {
+ position: fixed;
+ inset: 0;
+ background: var(--bg);
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.diff-container {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--glass-shadow);
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ margin: 8px;
+}
+
+.diff-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ font-size: 13px;
+ font-weight: 600;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ background: rgba(255,255,255,0.02);
+}
+
+.diff-header-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.btn-close-diff {
+ padding: 4px 8px;
+ font-size: 14px;
+}
+
+.diff-body {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+}
+
+.diff-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.diff-panel + .diff-panel {
+ border-left: 1px solid var(--border);
+}
+
+.diff-panel-header {
+ padding: 6px 12px;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-secondary);
+ background: rgba(255,255,255,0.02);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.diff-panel-content {
+ flex: 1;
+ overflow: auto;
+ padding: 8px 0;
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.diff-line {
+ display: flex;
+ padding: 0 12px;
+ min-height: 20px;
+}
+
+.diff-line-number {
+ width: 40px;
+ flex-shrink: 0;
+ text-align: right;
+ padding-right: 8px;
+ color: var(--text-secondary);
+ user-select: none;
+ opacity: 0.6;
+}
+
+.diff-line-content {
+ flex: 1;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ padding-left: 8px;
+}
+
+.diff-line-added {
+ background: rgba(72,199,142,0.12);
+}
+
+.diff-line-added .diff-line-number {
+ color: var(--success-color);
+ opacity: 1;
+}
+
+.diff-line-removed {
+ background: rgba(255,100,100,0.12);
+}
+
+.diff-line-removed .diff-line-number {
+ color: var(--error-color);
+ opacity: 1;
+}
+
+.diff-line-context {
+ color: var(--text);
+}
+
+.diff-line-empty {
+ background: rgba(255,255,255,0.02);
+}
+
+.diff-line-empty .diff-line-content {
+ color: var(--text-secondary);
+ opacity: 0.3;
+}
+
+.dialog-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1001;
+ backdrop-filter: blur(4px);
+}
+
+.dialog {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--glass-shadow);
+ width: 90%;
+ max-width: 600px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.dialog-header {
+ padding: 12px 16px;
+ font-size: 14px;
+ font-weight: 600;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.dialog-body {
+ padding: 16px;
+ overflow: auto;
+ flex: 1;
+}
+
+.dialog-label {
+ font-size: 13px;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+}
+
+.dialog-sql {
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 12px;
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: 12px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow: auto;
+ max-height: 300px;
+}
+
+.dialog-footer {
+ padding: 12px 16px;
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.notification {
+ position: fixed;
+ top: 12px;
+ right: 12px;
+ padding: 8px 16px;
+ border-radius: var(--radius-sm);
+ font-size: 12px;
+ font-weight: 500;
+ z-index: 1002;
+ animation: slideIn 0.3s ease-out;
+ box-shadow: var(--shadow-md);
+}
+
+.notification-success {
+ background: var(--success-color);
+ color: #fff;
+}
+
+.notification-error {
+ background: var(--error-color);
+ color: #fff;
+}
+
+.notification-warning {
+ background: var(--warning-color);
+ color: #000;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+@keyframes slideOut {
+ from {
+ transform: translateX(0);
+ opacity: 1;
+ }
+ to {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+}
diff --git a/media/materialized-view-designer.html b/media/materialized-view-designer.html
new file mode 100644
index 0000000..e02b9da
--- /dev/null
+++ b/media/materialized-view-designer.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+ Materialized View Designer
+
+
+ {{CONFIG_INJECT}}
+
+
+
+
+
+
+
+
+ View Name
+
+
+
+ Active
+
+
+
+ Refresh Type
+ ASYNC
+
+
+ Refresh Schedule
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The following SQL will be executed:
+
+
+
+
+
+
+
+
+
+
diff --git a/media/materialized-view-designer.js b/media/materialized-view-designer.js
new file mode 100644
index 0000000..a5bc304
--- /dev/null
+++ b/media/materialized-view-designer.js
@@ -0,0 +1,879 @@
+const vscode = window.vscode || acquireVsCodeApi();
+
+var i18nData = {
+ zh: {
+ materializedViewDesigner: '物化视图设计器',
+ newMaterializedView: '新建物化视图',
+ editMaterializedView: '编辑物化视图',
+ save: '保存',
+ refresh: '刷新',
+ exportSql: '导出 SQL',
+ viewName: '视图名称',
+ refreshType: '刷新类型',
+ activeStatus: '生效状态',
+ refreshSchedule: '刷新计划',
+ showDiff: '查看差异',
+ diffTitle: 'DDL 差异对比',
+ originalDDL: '原始 DDL',
+ currentDDL: '当前 DDL',
+ sqlPreview: 'SQL 预览',
+ messages: '消息',
+ confirmSave: '确认保存',
+ sqlWillBeExecuted: '将执行以下 SQL:',
+ execute: '执行',
+ exportSqlOnly: '仅导出 SQL',
+ cancel: '取消',
+ viewNameRequired: '视图名称不能为空',
+ ddlRequired: 'DDL 不能为空',
+ saveSuccess: '保存成功',
+ saveFailed: '保存失败',
+ refreshSuccess: '刷新成功',
+ refreshFailed: '刷新失败',
+ noChanges: '无变更',
+ unknownError: '未知错误',
+ queryStarted: '查询开始执行',
+ queryCompleted: '查询执行成功',
+ queryFailed: '查询执行失败',
+ startPrefix: '开始: ',
+ intervalPrefix: '间隔: '
+ },
+ en: {
+ materializedViewDesigner: 'Materialized View Designer',
+ newMaterializedView: 'New Materialized View',
+ editMaterializedView: 'Edit Materialized View',
+ save: 'Save',
+ refresh: 'Refresh',
+ exportSql: 'Export SQL',
+ viewName: 'View Name',
+ refreshType: 'Refresh Type',
+ activeStatus: 'Active',
+ refreshSchedule: 'Refresh Schedule',
+ showDiff: 'Diff',
+ diffTitle: 'DDL Differences',
+ originalDDL: 'Original DDL',
+ currentDDL: 'Current DDL',
+ sqlPreview: 'SQL Preview',
+ messages: 'Messages',
+ confirmSave: 'Confirm Save',
+ sqlWillBeExecuted: 'The following SQL will be executed:',
+ execute: 'Execute',
+ exportSqlOnly: 'Export SQL Only',
+ cancel: 'Cancel',
+ viewNameRequired: 'View name is required',
+ ddlRequired: 'DDL is required',
+ saveSuccess: 'Save successful',
+ saveFailed: 'Save failed',
+ refreshSuccess: 'Refresh successful',
+ refreshFailed: 'Refresh failed',
+ noChanges: 'No changes',
+ unknownError: 'Unknown error',
+ queryStarted: 'Query started',
+ queryCompleted: 'Query completed',
+ queryFailed: 'Query failed',
+ startPrefix: 'Start: ',
+ intervalPrefix: 'Interval: '
+ }
+};
+
+function getLang() {
+ try {
+ var cfg = window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__ || {};
+ return cfg.lang || 'en';
+ } catch (e) { return 'en'; }
+}
+
+var lang = getLang();
+
+function t(key) {
+ return (i18nData[lang] && i18nData[lang][key]) || (i18nData.en[key]) || key;
+}
+
+function applyI18n() {
+ document.querySelectorAll('[data-i18n]').forEach(function(el) {
+ var key = el.getAttribute('data-i18n');
+ var text = t(key);
+ if (el.tagName === 'INPUT' && el.type !== 'radio') {
+ el.placeholder = text;
+ } else if (el.tagName === 'BUTTON') {
+ el.textContent = text;
+ } else {
+ el.textContent = text;
+ }
+ });
+}
+
+var state = {
+ mode: 'create',
+ database: '',
+ viewName: '',
+ ddl: '',
+ originalDDL: '',
+ refreshType: 'ASYNC',
+ activeStatus: 'ACTIVE',
+ originalActiveStatus: 'ACTIVE',
+ refreshSchedule: '-',
+ messages: []
+};
+
+var editor = null;
+var monacoLoaded = false;
+
+function parseDdlProperties(ddl) {
+ if (!ddl || !ddl.trim()) return;
+
+ var nameMatch = ddl.match(/CREATE\s+MATERIALIZED\s+VIEW\s+`([^`]+)`/i);
+ if (nameMatch) {
+ var newName = nameMatch[1];
+ state.viewName = newName;
+ var nameInput = document.getElementById('viewNameInput');
+ if (nameInput) nameInput.value = newName;
+ var headerName = document.getElementById('headerViewName');
+ if (headerName) {
+ if (state.mode === 'create') {
+ headerName.textContent = t('newMaterializedView');
+ } else {
+ headerName.textContent = newName + ' - ' + t('materializedViewDesigner');
+ }
+ }
+ }
+
+ var refreshMatch = ddl.match(/REFRESH\s+(ASYNC|SYNC|MANUAL)/i);
+ if (refreshMatch) {
+ state.refreshType = refreshMatch[1].toUpperCase();
+ var refreshEl = document.getElementById('refreshTypeValue');
+ if (refreshEl) refreshEl.textContent = state.refreshType;
+ }
+
+ var schedule = '-';
+ var startMatch = ddl.match(/START\s*\(\s*"([^"]+)"\s*\)/i);
+ var everyMatch = ddl.match(/EVERY\s*\(\s*INTERVAL\s+(\d+)\s+(\w+)\s*\)/i);
+ if (startMatch || everyMatch) {
+ var parts = [];
+ if (startMatch) {
+ parts.push(t('startPrefix') + startMatch[1]);
+ }
+ if (everyMatch) {
+ parts.push(t('intervalPrefix') + everyMatch[1] + ' ' + everyMatch[2]);
+ }
+ schedule = parts.join(', ');
+ }
+ state.refreshSchedule = schedule;
+ var scheduleEl = document.getElementById('refreshScheduleValue');
+ if (scheduleEl) scheduleEl.textContent = schedule;
+}
+
+function escapeHtml(str) {
+ return str.replace(/&/g, '&').replace(//g, '>');
+}
+
+function highlightSql(sql) {
+ var keywords = ['CREATE', 'MATERIALIZED', 'VIEW', 'ALTER', 'DROP', 'REFRESH', 'PARTITION', 'BY', 'DISTRIBUTED', 'HASH', 'ORDER', 'PROPERTIES', 'AS', 'SELECT', 'FROM', 'WHERE', 'GROUP', 'HAVING', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'AND', 'OR', 'NOT', 'IN', 'EXISTS', 'BETWEEN', 'LIKE', 'IS', 'NULL', 'TRUE', 'FALSE', 'IF', 'REPLACE', 'DUPLICATE', 'KEY', 'INDEX', 'COMMENT', 'SET', 'TABLE', 'INSERT', 'UPDATE', 'DELETE', 'INTO', 'VALUES', 'UNION', 'ALL', 'DISTINCT', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'LIMIT', 'OFFSET', 'ASC', 'DESC', 'SWAP', 'WITH', 'TEMPORARY'];
+ var types = ['INT', 'INTEGER', 'TINYINT', 'SMALLINT', 'MEDIUMINT', 'BIGINT', 'FLOAT', 'DOUBLE', 'DECIMAL', 'NUMERIC', 'VARCHAR', 'CHAR', 'TEXT', 'TINYTEXT', 'MEDIUMTEXT', 'LONGTEXT', 'BLOB', 'TINYBLOB', 'MEDIUMBLOB', 'LONGBLOB', 'DATE', 'DATETIME', 'TIMESTAMP', 'TIME', 'YEAR', 'ENUM', 'SET', 'BOOLEAN', 'BOOL', 'BINARY', 'VARBINARY', 'JSON'];
+
+ var style = getComputedStyle(document.body);
+ var isDark = document.body.classList.contains('vscode-dark') ||
+ document.querySelector('[data-vscode-theme-kind="vscode-dark"]') ||
+ (window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__ && window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__.themeKind === 2);
+ var tc = (window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__ && window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__.tokenColors) || {};
+
+ function getColor(varName, fallback) {
+ var val = style.getPropertyValue(varName).trim();
+ if (!val) return fallback;
+ if (val.length === 9 && val.charAt(0) === '#') {
+ val = val.substring(0, 7);
+ }
+ return val;
+ }
+
+ var keywordColor = tc.keyword || getColor('--vscode-editorKeyword-foreground', isDark ? '#569cd6' : '#0000ff');
+ var stringColor = tc.string || getColor('--vscode-string-foreground', isDark ? '#ce9178' : '#a31515');
+ var commentColor = tc.comment || getColor('--vscode-editorComments-foreground', isDark ? '#6a9955' : '#008000');
+ var numberColor = tc.number || getColor('--vscode-editorNumbers-foreground', isDark ? '#b5cea8' : '#098658');
+ var typeColor = tc.type || getColor('--vscode-editorType-foreground', isDark ? '#4ec9b0' : '#267f99');
+ var identifierColor = tc.variable || getColor('--vscode-editorVariable-foreground', isDark ? '#9cdcfe' : '#001080');
+
+ var keywordSet = {};
+ for (var i = 0; i < keywords.length; i++) keywordSet[keywords[i].toUpperCase()] = true;
+ var typeSet = {};
+ for (var i = 0; i < types.length; i++) typeSet[types[i].toUpperCase()] = true;
+
+ var tokens = [];
+ var pos = 0;
+ while (pos < sql.length) {
+ if (sql[pos] === "'" || sql[pos] === '"') {
+ var quote = sql[pos];
+ var end = sql.indexOf(quote, pos + 1);
+ if (end === -1) end = sql.length - 1;
+ tokens.push({ type: 'string', value: sql.substring(pos, end + 1) });
+ pos = end + 1;
+ } else if (sql[pos] === '`') {
+ var end = sql.indexOf('`', pos + 1);
+ if (end === -1) end = sql.length - 1;
+ tokens.push({ type: 'identifier', value: sql.substring(pos, end + 1) });
+ pos = end + 1;
+ } else if (sql[pos] === '-' && sql[pos + 1] === '-') {
+ var end = sql.indexOf('\n', pos);
+ if (end === -1) end = sql.length;
+ tokens.push({ type: 'comment', value: sql.substring(pos, end) });
+ pos = end;
+ } else if (sql[pos] === '/' && sql[pos + 1] === '*') {
+ var end = sql.indexOf('*/', pos + 2);
+ if (end === -1) end = sql.length;
+ tokens.push({ type: 'comment', value: sql.substring(pos, end + 2) });
+ pos = end + 2;
+ } else if (/[a-zA-Z_]/.test(sql[pos])) {
+ var start = pos;
+ while (pos < sql.length && /[a-zA-Z0-9_]/.test(sql[pos])) pos++;
+ var word = sql.substring(start, pos);
+ var upper = word.toUpperCase();
+ if (keywordSet[upper]) {
+ tokens.push({ type: 'keyword', value: word });
+ } else if (typeSet[upper]) {
+ tokens.push({ type: 'type', value: word });
+ } else {
+ tokens.push({ type: 'text', value: word });
+ }
+ } else if (/[0-9]/.test(sql[pos])) {
+ var start = pos;
+ while (pos < sql.length && /[0-9.]/.test(sql[pos])) pos++;
+ tokens.push({ type: 'number', value: sql.substring(start, pos) });
+ } else {
+ tokens.push({ type: 'text', value: sql[pos] });
+ pos++;
+ }
+ }
+
+ var result = '';
+ for (var j = 0; j < tokens.length; j++) {
+ var tok = tokens[j];
+ var escaped = escapeHtml(tok.value);
+ if (tok.type === 'keyword') {
+ result += '' + escaped + '';
+ } else if (tok.type === 'type') {
+ result += '' + escaped + '';
+ } else if (tok.type === 'string') {
+ result += '' + escaped + '';
+ } else if (tok.type === 'identifier') {
+ result += '' + escaped + '';
+ } else if (tok.type === 'comment') {
+ result += '' + escaped + '';
+ } else if (tok.type === 'number') {
+ result += '' + escaped + '';
+ } else {
+ result += escaped;
+ }
+ }
+
+ return result;
+}
+
+function updateSqlPreview() {
+ var sqlToExecute = getSqlToExecute();
+ var previewContent = document.getElementById('sqlPreviewContent');
+ if (previewContent) {
+ previewContent.innerHTML = highlightSql(sqlToExecute);
+ }
+}
+
+function getSqlToExecute() {
+ if (state.mode === 'create') {
+ return state.ddl;
+ }
+
+ var statements = [];
+
+ if (state.ddl !== state.originalDDL) {
+ var tempViewName = state.viewName + '_tmp_' + Date.now();
+ var tempDDL = state.ddl.replace(
+ new RegExp('CREATE\\s+MATERIALIZED\\s+VIEW\\s+`?' + state.viewName + '`?', 'i'),
+ 'CREATE MATERIALIZED VIEW `' + tempViewName + '`'
+ );
+ statements.push(tempDDL);
+ statements.push('ALTER MATERIALIZED VIEW `' + state.viewName + '` SWAP WITH `' + tempViewName + '`');
+ statements.push('DROP MATERIALIZED VIEW IF EXISTS `' + tempViewName + '`');
+ }
+
+ if (state.activeStatus !== state.originalActiveStatus) {
+ var statusCmd = state.activeStatus === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE';
+ statements.push('ALTER MATERIALIZED VIEW `' + state.viewName + '` ' + statusCmd);
+ }
+
+ return statements.join(';\n');
+}
+
+function buildVscodeTheme() {
+ var style = getComputedStyle(document.body);
+ function getColor(varName, fallback) {
+ var val = style.getPropertyValue(varName).trim();
+ if (!val) return fallback;
+ if (val.length === 9 && val.charAt(0) === '#') {
+ val = val.substring(0, 7);
+ }
+ return val;
+ }
+ var cfg = window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__ || {};
+ var isDark = document.body.classList.contains('vscode-dark') ||
+ document.querySelector('[data-vscode-theme-kind="vscode-dark"]') ||
+ cfg.themeKind === 2;
+ var base = isDark ? 'vs-dark' : 'vs';
+ var editorBg = getColor('--vscode-editor-background', isDark ? '#1e1e1e' : '#ffffff');
+ var gutterBg = getColor('--vscode-editorGutter-background', editorBg);
+ var overviewBg = getColor('--vscode-editorOverviewRuler-background', isDark ? '#252526' : '#ffffff');
+ var tc = cfg.tokenColors || {};
+ var lineNumColor = getColor('--vscode-editorLineNumber-foreground', isDark ? '#858585' : '#237893');
+ var activeLineNumColor = getColor('--vscode-editorLineNumber-activeForeground', isDark ? '#c6c6c6' : '#0b216f');
+ return {
+ base: base,
+ inherit: true,
+ rules: [
+ { token: 'keyword', foreground: tc.keyword || getColor('--vscode-editorKeyword-foreground', isDark ? '#569cd6' : '#0000ff') },
+ { token: 'string', foreground: tc.string || getColor('--vscode-string-foreground', isDark ? '#ce9178' : '#a31515') },
+ { token: 'string.sql', foreground: tc.string || getColor('--vscode-string-foreground', isDark ? '#ce9178' : '#a31515') },
+ { token: 'comment', foreground: tc.comment || getColor('--vscode-editorComments-foreground', isDark ? '#6a9955' : '#008000') },
+ { token: 'number', foreground: tc.number || getColor('--vscode-editorNumbers-foreground', isDark ? '#b5cea8' : '#098658') },
+ { token: 'type', foreground: tc.type || getColor('--vscode-editorType-foreground', isDark ? '#4ec9b0' : '#267f99') },
+ { token: 'type.identifier', foreground: tc.type || getColor('--vscode-editorType-foreground', isDark ? '#4ec9b0' : '#267f99') },
+ { token: 'function', foreground: tc.function || getColor('--vscode-editorFunction-foreground', isDark ? '#dcdcaa' : '#795e26') },
+ { token: 'operator', foreground: tc.operator || getColor('--vscode-editorOperator-foreground', isDark ? '#d4d4d4' : '#000000') },
+ { token: 'delimiter', foreground: tc.delimiter || getColor('--vscode-editorBracketMatch-background', isDark ? '#d4d4d4' : '#000000') },
+ { token: 'variable', foreground: tc.variable || getColor('--vscode-editorVariable-foreground', isDark ? '#9cdcfe' : '#001080') },
+ { token: '', foreground: getColor('--vscode-editor-foreground', isDark ? '#d4d4d4' : '#000000') },
+ ],
+ colors: {
+ 'editor.background': editorBg,
+ 'editor.foreground': getColor('--vscode-editor-foreground', isDark ? '#d4d4d4' : '#000000'),
+ 'editor.lineHighlightBackground': editorBg,
+ 'editor.selectionBackground': getColor('--vscode-editor-selectionBackground', isDark ? '#264f78' : '#add6ff'),
+ 'editorCursor.foreground': getColor('--vscode-editorCursor-foreground', isDark ? '#aeafad' : '#000000'),
+ 'editor.inactiveSelectionBackground': getColor('--vscode-editor-inactiveSelectionBackground', isDark ? '#3a3d41' : '#e5ebf1'),
+ 'editorLineNumber.foreground': lineNumColor,
+ 'editorLineNumber.activeForeground': activeLineNumColor,
+ 'editorIndentGuide.background1': getColor('--vscode-editorIndentGuide-background1', isDark ? '#404040' : '#e4e4e4'),
+ 'editorIndentGuide.activeBackground1': getColor('--vscode-editorIndentGuide-activeBackground1', isDark ? '#707070' : '#e4e4e4'),
+ 'editorGutter.background': gutterBg,
+ 'editorOverviewRuler.background': overviewBg,
+ 'editor.selectionHighlightBackground': getColor('--vscode-editor-selectionHighlightBackground', isDark ? '#add6ff26' : '#add6ff52'),
+ 'editorGutter.modifiedBackground': getColor('--vscode-editorGutter-modifiedBackground', '#0078d466'),
+ 'editorGutter.addedBackground': getColor('--vscode-editorGutter-addedBackground', '#587c0c66'),
+ 'editorGutter.deletedBackground': getColor('--vscode-editorGutter-deletedBackground', '#94151b66'),
+ }
+ };
+}
+
+function initMonacoEditor(sql) {
+ var container = document.getElementById('monacoEditor');
+ if (!container) return;
+
+ if (typeof require === 'function' && !monacoLoaded) {
+ var cfg = window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__ || {};
+ var monacoBasePath = cfg.monacoBasePath || '';
+ require.config({ paths: { 'vs': monacoBasePath } });
+ require(['vs/editor/editor.main'], function() {
+ monacoLoaded = true;
+ createMonacoInstance(container, sql);
+ });
+ } else if (monacoLoaded && typeof monaco !== 'undefined') {
+ createMonacoInstance(container, sql);
+ }
+}
+
+function createMonacoInstance(container, sql) {
+ if (editor) {
+ editor.setValue(sql || '');
+ return;
+ }
+
+ var isDark = document.body.classList.contains('vscode-dark') ||
+ document.querySelector('[data-vscode-theme-kind="vscode-dark"]') ||
+ (window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__ && window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__.themeKind === 2);
+ var customThemeName = 'vscode-sync-' + (isDark ? 'dark' : 'light');
+ monaco.editor.defineTheme(customThemeName, buildVscodeTheme());
+
+ editor = monaco.editor.create(container, {
+ value: sql || '',
+ language: 'sql',
+ theme: customThemeName,
+ automaticLayout: true,
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ fontSize: 13,
+ lineNumbers: 'on',
+ renderLineHighlight: 'gutter',
+ scrollbar: {
+ vertical: 'auto',
+ horizontal: 'auto',
+ verticalScrollbarSize: 8,
+ horizontalScrollbarSize: 8
+ },
+ overviewRulerLanes: 0,
+ folding: true,
+ contextmenu: true,
+ suggestOnTriggerCharacters: true,
+ lineDecorationsWidth: 8,
+ lineNumbersMinChars: 4,
+ padding: { top: 4, bottom: 4 },
+ domReadOnly: false,
+ readOnly: false,
+ wordWrap: 'on'
+ });
+
+ editor.onDidChangeModelContent(function() {
+ state.ddl = editor.getValue();
+ parseDdlProperties(state.ddl);
+ updateSqlPreview();
+ });
+
+ editor.focus();
+}
+
+function setEditorSql(sql) {
+ if (editor) {
+ editor.setValue(sql || '');
+ }
+}
+
+function handleThemeChange(data) {
+ if (!editor || typeof monaco === 'undefined') return;
+ if (data.tokenColors && window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__) {
+ window.__MATERIALIZED_VIEW_DESIGNER_CONFIG__.tokenColors = data.tokenColors;
+ }
+ var isDark = data.kind === 2 || data.kind === 3;
+ var customThemeName = 'vscode-sync-' + (isDark ? 'dark' : 'light');
+ monaco.editor.defineTheme(customThemeName, buildVscodeTheme());
+ monaco.editor.setTheme(customThemeName);
+ updateSqlPreview();
+}
+
+function updateEditorContent() {
+ if (editor) {
+ editor.setValue(state.ddl || '');
+ } else {
+ initMonacoEditor(state.ddl);
+ }
+}
+
+function showDiff() {
+ if (state.mode === 'create') {
+ showNotification(t('noChanges'), 'info');
+ return;
+ }
+
+ if (state.ddl === state.originalDDL) {
+ showNotification(t('noChanges'), 'info');
+ return;
+ }
+
+ var oldLines = state.originalDDL.split('\n');
+ var newLines = state.ddl.split('\n');
+
+ var diffLeft = document.getElementById('diffLeft');
+ var diffRight = document.getElementById('diffRight');
+
+ if (!diffLeft || !diffRight) return;
+
+ var leftHtml = '';
+ var rightHtml = '';
+
+ var maxLen = Math.max(oldLines.length, newLines.length);
+ for (var i = 0; i < maxLen; i++) {
+ var oldLine = i < oldLines.length ? oldLines[i] : null;
+ var newLine = i < newLines.length ? newLines[i] : null;
+ var lineNum = i + 1;
+
+ if (oldLine === newLine) {
+ leftHtml += '' + lineNum + '' + escapeHtml(oldLine || '') + '
';
+ rightHtml += '' + lineNum + '' + escapeHtml(newLine || '') + '
';
+ } else {
+ if (oldLine !== null) {
+ leftHtml += '' + lineNum + '' + escapeHtml(oldLine) + '
';
+ } else {
+ leftHtml += '
';
+ }
+ if (newLine !== null) {
+ rightHtml += '' + lineNum + '' + escapeHtml(newLine) + '
';
+ } else {
+ rightHtml += '
';
+ }
+ }
+ }
+
+ diffLeft.innerHTML = leftHtml;
+ diffRight.innerHTML = rightHtml;
+ document.getElementById('diffOverlay').style.display = 'flex';
+}
+
+function closeDiff() {
+ document.getElementById('diffOverlay').style.display = 'none';
+}
+
+function handleSave() {
+ var validationError = validateDesign();
+ if (validationError) {
+ showNotification(validationError, 'error');
+ return;
+ }
+
+ var sqlToExecute = getSqlToExecute();
+ var saveSqlPreview = document.getElementById('saveSqlPreview');
+ if (saveSqlPreview) saveSqlPreview.textContent = sqlToExecute;
+
+ var dialog = document.getElementById('saveDialog');
+ if (dialog) dialog.style.display = 'flex';
+}
+
+function confirmSave() {
+ var dialog = document.getElementById('saveDialog');
+ if (dialog) dialog.style.display = 'none';
+
+ vscode.postMessage({
+ command: 'save',
+ data: {
+ viewName: state.viewName,
+ database: state.database,
+ mode: state.mode,
+ ddl: state.ddl,
+ originalDDL: state.originalDDL,
+ refreshType: state.refreshType,
+ activeStatus: state.activeStatus
+ }
+ });
+}
+
+function cancelSave() {
+ var dialog = document.getElementById('saveDialog');
+ if (dialog) dialog.style.display = 'none';
+}
+
+function exportSqlOnly() {
+ cancelSave();
+ handleExport();
+}
+
+function handleRefresh() {
+ if (!state.viewName) {
+ showNotification(t('viewNameRequired'), 'error');
+ return;
+ }
+
+ vscode.postMessage({
+ command: 'refresh',
+ viewName: state.viewName
+ });
+}
+
+function handleExport() {
+ vscode.postMessage({
+ command: 'exportSql',
+ sql: state.ddl
+ });
+}
+
+function handleClose() {
+ vscode.postMessage({ command: 'close' });
+}
+
+function validateDesign() {
+ if (!state.viewName || state.viewName.trim() === '') {
+ return t('viewNameRequired');
+ }
+ if (!state.ddl || state.ddl.trim() === '') {
+ return t('ddlRequired');
+ }
+ return null;
+}
+
+function showNotification(message, type) {
+ var existing = document.querySelector('.notification');
+ if (existing) existing.remove();
+
+ var notification = document.createElement('div');
+ notification.className = 'notification notification-' + (type || 'info');
+ notification.textContent = message;
+ document.body.appendChild(notification);
+
+ setTimeout(function() {
+ notification.style.animation = 'slideOut 0.3s ease-in forwards';
+ setTimeout(function() {
+ notification.remove();
+ }, 300);
+ }, 3000);
+}
+
+function handleMaterializedViewStructure(data) {
+ state.mode = data.mode;
+ state.database = data.database;
+ state.viewName = data.viewName || '';
+ state.ddl = data.ddl || '';
+ state.originalDDL = data.originalDDL || '';
+ state.refreshType = data.refreshType || 'ASYNC';
+ state.activeStatus = data.activeStatus || 'ACTIVE';
+ state.originalActiveStatus = data.activeStatus || 'ACTIVE';
+
+ var viewNameInput = document.getElementById('viewNameInput');
+ if (viewNameInput) viewNameInput.value = state.viewName;
+
+ var refreshEl = document.getElementById('refreshTypeValue');
+ if (refreshEl) refreshEl.textContent = state.refreshType;
+
+ var activeSelect = document.getElementById('activeStatusValue');
+ if (activeSelect) activeSelect.value = state.activeStatus;
+
+ var btnDiff = document.getElementById('btnDiff');
+ var btnRefresh = document.getElementById('btnRefresh');
+ if (btnDiff) btnDiff.style.display = state.mode === 'alter' ? '' : 'none';
+ if (btnRefresh) btnRefresh.style.display = state.mode === 'alter' ? '' : 'none';
+
+ updateEditorContent();
+
+ state.originalDDL = state.ddl;
+
+ updateSqlPreview();
+
+ var headerViewName = document.getElementById('headerViewName');
+ if (headerViewName) {
+ if (state.mode === 'create') {
+ headerViewName.textContent = t('newMaterializedView');
+ } else {
+ headerViewName.textContent = state.viewName + ' - ' + t('materializedViewDesigner');
+ }
+ }
+}
+
+function addMessage(level, text) {
+ var now = new Date();
+ var timeStr = now.getHours().toString().padStart(2, '0') + ':' +
+ now.getMinutes().toString().padStart(2, '0') + ':' +
+ now.getSeconds().toString().padStart(2, '0');
+ state.messages.push({ level: level, text: text, time: timeStr });
+ renderMessages();
+ switchTab('pageMessages');
+}
+
+function renderMessages() {
+ var container = document.getElementById('messagesContainer');
+ if (!container) return;
+ container.innerHTML = '';
+ state.messages.forEach(function(msg) {
+ var div = document.createElement('div');
+ div.className = 'msg-item msg-' + msg.level;
+ var timeSpan = document.createElement('span');
+ timeSpan.className = 'msg-time';
+ timeSpan.textContent = '[' + msg.time + ']';
+ div.appendChild(timeSpan);
+ div.appendChild(document.createTextNode(msg.text));
+ container.appendChild(div);
+ });
+ container.scrollTop = container.scrollHeight;
+}
+
+function clearMessages() {
+ state.messages = [];
+ var container = document.getElementById('messagesContainer');
+ if (container) container.innerHTML = '';
+ var messagesBar = document.getElementById('messagesBar');
+ if (messagesBar) messagesBar.style.display = 'none';
+}
+
+function handleQueryStart(data) {
+ var sql = data.sql || '';
+ var displaySql = sql.length > 200 ? sql.substring(0, 200) + '...' : sql;
+ addMessage('info', t('queryStarted') + ': ' + displaySql);
+}
+
+function handleQuerySuccess(data) {
+ addMessage('success', t('queryCompleted'));
+ showNotification(t('saveSuccess'), 'success');
+}
+
+function handleQueryError(data) {
+ var errorMsg = data.message || t('unknownError');
+ addMessage('error', t('queryFailed') + ': ' + errorMsg);
+ showNotification(t('saveFailed') + ': ' + errorMsg, 'error');
+}
+
+function handleUpdateOriginalDDL(data) {
+ state.originalDDL = data.originalDDL || '';
+ if (data.originalActiveStatus !== undefined) {
+ state.originalActiveStatus = data.originalActiveStatus;
+ }
+ updateSqlPreview();
+}
+
+function switchTab(tabId) {
+ var tabBtns = document.querySelectorAll('.tab-btn');
+ tabBtns.forEach(function(btn) {
+ btn.classList.toggle('active', btn.getAttribute('data-tab') === tabId);
+ });
+ var tabPages = document.querySelectorAll('.tab-page');
+ tabPages.forEach(function(page) {
+ page.classList.toggle('active', page.id === tabId);
+ });
+}
+
+function initSplitter() {
+ var splitter = document.getElementById('splitter');
+ var editorSection = document.getElementById('editorSection');
+ var bottomSection = document.getElementById('bottomSection');
+ var panelSplit = document.getElementById('panelSplit');
+
+ if (!splitter || !editorSection || !bottomSection || !panelSplit) return;
+
+ var startY = 0;
+ var startEditorHeight = 0;
+ var isDragging = false;
+
+ function onMouseDown(e) {
+ e.preventDefault();
+ isDragging = true;
+ startY = e.clientY;
+ startEditorHeight = editorSection.offsetHeight;
+ document.addEventListener('mousemove', onMouseMove);
+ document.addEventListener('mouseup', onMouseUp);
+ document.body.style.cursor = 'row-resize';
+ document.body.style.userSelect = 'none';
+ }
+
+ function onMouseMove(e) {
+ if (!isDragging) return;
+ var deltaY = e.clientY - startY;
+ var newEditorHeight = startEditorHeight + deltaY;
+ var totalHeight = panelSplit.offsetHeight;
+ var minEditorHeight = 100;
+ var minBottomHeight = 80;
+ var maxEditorHeight = totalHeight - minBottomHeight - splitter.offsetHeight;
+
+ newEditorHeight = Math.max(minEditorHeight, Math.min(maxEditorHeight, newEditorHeight));
+ var newBottomHeight = totalHeight - newEditorHeight - splitter.offsetHeight;
+
+ editorSection.style.flex = 'none';
+ editorSection.style.height = newEditorHeight + 'px';
+ bottomSection.style.flex = 'none';
+ bottomSection.style.height = newBottomHeight + 'px';
+
+ if (typeof editor !== 'undefined' && editor) {
+ editor.layout();
+ }
+ }
+
+ function onMouseUp() {
+ isDragging = false;
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ }
+
+ splitter.addEventListener('mousedown', onMouseDown);
+}
+
+function bindEvents() {
+ document.addEventListener('click', function(e) {
+ var target = e.target.closest('[data-action]');
+ if (!target) return;
+
+ var action = target.getAttribute('data-action');
+ switch (action) {
+ case 'handleSave':
+ handleSave();
+ break;
+ case 'handleRefresh':
+ handleRefresh();
+ break;
+ case 'handleExport':
+ handleExport();
+ break;
+ case 'handleClose':
+ handleClose();
+ break;
+ case 'showDiff':
+ showDiff();
+ break;
+ case 'closeDiff':
+ closeDiff();
+ break;
+ case 'confirmSave':
+ confirmSave();
+ break;
+ case 'cancelSave':
+ cancelSave();
+ break;
+ case 'exportSqlOnly':
+ exportSqlOnly();
+ break;
+ case 'clearMessages':
+ clearMessages();
+ break;
+ case 'switchTab':
+ var tabId = target.getAttribute('data-tab');
+ if (tabId) switchTab(tabId);
+ break;
+ }
+ });
+
+ document.addEventListener('input', function(e) {
+ var target = e.target;
+ var action = target.getAttribute('data-action');
+ if (!action) return;
+
+ switch (action) {
+ case 'updateViewName':
+ state.viewName = target.value;
+ updateSqlPreview();
+ break;
+ case 'updateActiveStatus':
+ state.activeStatus = target.value;
+ updateSqlPreview();
+ break;
+ }
+ });
+
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ handleSave();
+ }
+ if (e.key === 'Escape') {
+ cancelSave();
+ closeDiff();
+ }
+ });
+
+ initSplitter();
+}
+
+function init() {
+ applyI18n();
+ bindEvents();
+ updateSqlPreview();
+
+ window.addEventListener('message', function(event) {
+ var message = event.data;
+ switch (message.type) {
+ case 'materializedViewStructure':
+ handleMaterializedViewStructure(message.data);
+ break;
+ case 'updateOriginalDDL':
+ handleUpdateOriginalDDL(message.data);
+ break;
+ case 'themeChange':
+ handleThemeChange(message.data);
+ break;
+ case 'queryStart':
+ handleQueryStart(message.data);
+ break;
+ case 'querySuccess':
+ handleQuerySuccess(message.data);
+ break;
+ case 'queryError':
+ handleQueryError(message.data);
+ break;
+ case 'error':
+ showNotification(message.message, 'error');
+ break;
+ }
+ });
+
+ vscode.postMessage({ command: 'ready' });
+}
+
+document.addEventListener('DOMContentLoaded', init);
diff --git a/package.json b/package.json
index c955785..5a0984e 100644
--- a/package.json
+++ b/package.json
@@ -72,6 +72,7 @@
"onCommand:hive-formatter.viewTableData",
"onCommand:hive-formatter.viewTableDDL",
"onCommand:hive-formatter.viewViewDDL",
+ "onCommand:hive-formatter.viewMaterializedViewDDL",
"onCommand:hive-formatter.viewFunctionDDL",
"onCommand:hive-formatter.viewProcedureDDL",
"onCommand:hive-formatter.viewTriggerDDL",
@@ -426,6 +427,10 @@
"command": "hive-formatter.viewViewDDL",
"title": "%hive-formatter.command.viewViewDDL.title%"
},
+ {
+ "command": "hive-formatter.viewMaterializedViewDDL",
+ "title": "%hive-formatter.command.viewMaterializedViewDDL.title%"
+ },
{
"command": "hive-formatter.viewFunctionDDL",
"title": "%hive-formatter.command.viewFunctionDDL.title%"
@@ -506,6 +511,26 @@
"command": "hive-formatter.editTable",
"title": "%hive-formatter.command.editTable.title%"
},
+ {
+ "command": "sqlAllInOne.createMaterializedView",
+ "title": "%sqlAllInOne.command.createMaterializedView.title%"
+ },
+ {
+ "command": "sqlAllInOne.editMaterializedView",
+ "title": "%sqlAllInOne.command.editMaterializedView.title%"
+ },
+ {
+ "command": "sqlAllInOne.dropMaterializedView",
+ "title": "%sqlAllInOne.command.dropMaterializedView.title%"
+ },
+ {
+ "command": "sqlAllInOne.refreshMaterializedView",
+ "title": "%sqlAllInOne.command.refreshMaterializedView.title%"
+ },
+ {
+ "command": "sqlAllInOne.viewMaterializedViewDDL",
+ "title": "%sqlAllInOne.command.viewMaterializedViewDDL.title%"
+ },
{
"command": "hive-formatter.explainQuery",
"title": "%hive-formatter.command.explainQuery.title%"
@@ -1971,6 +1996,31 @@
"when": "view == hive-formatter.databaseExplorer && viewItem == view",
"group": "3_table@2"
},
+ {
+ "command": "hive-formatter.viewTableData",
+ "when": "view == hive-formatter.databaseExplorer && viewItem == materializedView",
+ "group": "3_table@1"
+ },
+ {
+ "command": "hive-formatter.viewMaterializedViewDDL",
+ "when": "view == hive-formatter.databaseExplorer && viewItem == materializedView",
+ "group": "3_table@2"
+ },
+ {
+ "command": "sqlAllInOne.editMaterializedView",
+ "when": "view == hive-formatter.databaseExplorer && viewItem == materializedView",
+ "group": "3_table@3"
+ },
+ {
+ "command": "sqlAllInOne.refreshMaterializedView",
+ "when": "view == hive-formatter.databaseExplorer && viewItem == materializedView",
+ "group": "3_table@4"
+ },
+ {
+ "command": "sqlAllInOne.dropMaterializedView",
+ "when": "view == hive-formatter.databaseExplorer && viewItem == materializedView",
+ "group": "4_danger@1"
+ },
{
"command": "hive-formatter.viewFunctionDDL",
"when": "view == hive-formatter.databaseExplorer && viewItem == function",
@@ -2016,6 +2066,11 @@
"when": "view == hive-formatter.databaseExplorer && viewItem == database",
"group": "7_database@1"
},
+ {
+ "command": "sqlAllInOne.createMaterializedView",
+ "when": "view == hive-formatter.databaseExplorer && viewItem == database",
+ "group": "7_database@2"
+ },
{
"command": "hive-formatter.refreshSchema",
"when": "view == hive-formatter.databaseExplorer && viewItem =~ /database|objectGroup|connectionConnected/",
diff --git a/package.nls.json b/package.nls.json
index 2293438..40371e9 100644
--- a/package.nls.json
+++ b/package.nls.json
@@ -189,6 +189,12 @@
"hive-formatter.command.viewTableData.title": "Query Data",
"hive-formatter.command.viewTableDDL.title": "View DDL",
"hive-formatter.command.viewViewDDL.title": "View View Definition",
+ "hive-formatter.command.viewMaterializedViewDDL.title": "View Materialized View Definition",
+ "sqlAllInOne.command.createMaterializedView.title": "Create Materialized View",
+ "sqlAllInOne.command.editMaterializedView.title": "Edit Materialized View",
+ "sqlAllInOne.command.dropMaterializedView.title": "Drop Materialized View",
+ "sqlAllInOne.command.refreshMaterializedView.title": "Refresh Materialized View",
+ "sqlAllInOne.command.viewMaterializedViewDDL.title": "View Materialized View DDL",
"hive-formatter.command.viewFunctionDDL.title": "View Function Definition",
"hive-formatter.command.viewProcedureDDL.title": "View Procedure Definition",
"hive-formatter.command.viewTriggerDDL.title": "View Trigger Definition",
diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json
index 8dc513b..14f6f0d 100644
--- a/package.nls.zh-cn.json
+++ b/package.nls.zh-cn.json
@@ -189,6 +189,12 @@
"hive-formatter.command.viewTableData.title": "查询数据",
"hive-formatter.command.viewTableDDL.title": "查看 DDL",
"hive-formatter.command.viewViewDDL.title": "查看视图定义",
+ "hive-formatter.command.viewMaterializedViewDDL.title": "查看物化视图定义",
+ "sqlAllInOne.command.createMaterializedView.title": "创建物化视图",
+ "sqlAllInOne.command.editMaterializedView.title": "编辑物化视图",
+ "sqlAllInOne.command.dropMaterializedView.title": "删除物化视图",
+ "sqlAllInOne.command.refreshMaterializedView.title": "刷新物化视图",
+ "sqlAllInOne.command.viewMaterializedViewDDL.title": "查看物化视图 DDL",
"hive-formatter.command.viewFunctionDDL.title": "查看函数定义",
"hive-formatter.command.viewProcedureDDL.title": "查看存储过程定义",
"hive-formatter.command.viewTriggerDDL.title": "查看触发器定义",
diff --git a/src/application/portImplementations.ts b/src/application/portImplementations.ts
index 9563fd8..8e13496 100644
--- a/src/application/portImplementations.ts
+++ b/src/application/portImplementations.ts
@@ -52,6 +52,7 @@ import type {
DatabaseInfo,
TableInfo,
ViewInfo,
+ MaterializedViewInfo,
FunctionInfo,
ProcedureInfo,
ColumnInfo,
@@ -290,6 +291,19 @@ export class SchemaServiceImpl implements ISchemaService {
}));
}
+ async getMaterializedViews(
+ connectionId: string,
+ database: string,
+ ): Promise<{ name: string; definition?: string; comment?: string }[]> {
+ const mvs: MaterializedViewInfo[] = await getSchemaCache().getMaterializedViews(connectionId, database);
+ return mvs.map((mv) => ({
+ name: mv.name,
+ definition: mv.definition,
+ comment: mv.comment,
+ status: mv.status,
+ }));
+ }
+
async getFunctions(
connectionId: string,
database: string,
diff --git a/src/application/ports.ts b/src/application/ports.ts
index 16ddcfb..fc917a6 100644
--- a/src/application/ports.ts
+++ b/src/application/ports.ts
@@ -147,6 +147,7 @@ export interface ISchemaService {
getDatabases(connectionId: string): Promise<{ name: string; charset?: string; collation?: string }[]>;
getTables(connectionId: string, database: string): Promise<{ name: string; type?: string; rowCount?: number; comment?: string }[]>;
getViews(connectionId: string, database: string): Promise<{ name: string; definition?: string; comment?: string }[]>;
+ getMaterializedViews(connectionId: string, database: string): Promise<{ name: string; definition?: string; comment?: string; status?: string }[]>;
getFunctions(connectionId: string, database: string): Promise<{ name: string; returns?: string; definition?: string }[]>;
getProcedures(connectionId: string, database: string): Promise<{ name: string; definition?: string }[]>;
getColumns(connectionId: string, database: string, table: string): Promise;
diff --git a/src/completion/generated/snippetData.ts b/src/completion/generated/snippetData.ts
index 255c38e..c393642 100644
--- a/src/completion/generated/snippetData.ts
+++ b/src/completion/generated/snippetData.ts
@@ -802,6 +802,181 @@ export const snippetData: Record> = {
"SELECT $2 FROM $3 WHERE $4;"
],
"description": "Create Spark global temporary view"
+ },
+ "Spark Create Table (Parquet)": {
+ "prefix": "sparkcrtparquet",
+ "body": [
+ "CREATE TABLE IF NOT EXISTS $1 (",
+ " $2",
+ ")",
+ "USING PARQUET",
+ "PARTITIONED BY ($3)",
+ "LOCATION '$4';"
+ ],
+ "description": "Create Spark table stored as Parquet"
+ },
+ "Spark Create Table (JDBC)": {
+ "prefix": "sparkcrtjdbc",
+ "body": [
+ "CREATE TABLE $1 (",
+ " $2",
+ ") USING JDBC",
+ "OPTIONS (",
+ " 'url' = '$3',",
+ " 'dbtable' = '$4',",
+ " 'user' = '$5',",
+ " 'password' = '$6'",
+ ");"
+ ],
+ "description": "Create Spark table backed by JDBC"
+ },
+ "Spark Create Table (Delta)": {
+ "prefix": "sparkcrtdelta",
+ "body": [
+ "CREATE TABLE IF NOT EXISTS $1 (",
+ " $2",
+ ") USING DELTA",
+ "LOCATION '$3';"
+ ],
+ "description": "Create Spark table stored as Delta Lake"
+ },
+ "Spark Insert Overwrite Table": {
+ "prefix": "sparkins",
+ "body": [
+ "INSERT OVERWRITE TABLE $1",
+ "SELECT $2",
+ "FROM $3",
+ "WHERE $4;"
+ ],
+ "description": "Insert overwrite into Spark table"
+ },
+ "Spark Insert Into Table": {
+ "prefix": "sparkinsinto",
+ "body": [
+ "INSERT INTO TABLE $1",
+ "SELECT $2",
+ "FROM $3",
+ "WHERE $4;"
+ ],
+ "description": "Insert into Spark table"
+ },
+ "Spark Lateral View Explode": {
+ "prefix": "sparklv",
+ "body": [
+ "SELECT $1,",
+ " ${2:item} AS ${3:item_alias}",
+ "FROM $4",
+ "LATERAL VIEW EXPLODE($5) ${6:t} AS ${3:item_alias};"
+ ],
+ "description": "Spark LATERAL VIEW EXPLODE"
+ },
+ "Spark Lateral View Posexplode": {
+ "prefix": "sparklvp",
+ "body": [
+ "SELECT $1,",
+ " ${6:pos}, ${2:item}",
+ "FROM $3",
+ "LATERAL VIEW POSEXPLODE($4) ${5:t} AS ${6:pos}, ${2:item};"
+ ],
+ "description": "Spark LATERAL VIEW POSEXPLODE (with position)"
+ },
+ "Spark Merge Into (Delta)": {
+ "prefix": "sparkmerge",
+ "body": [
+ "MERGE INTO $1 AS t",
+ "USING $2 AS s",
+ "ON t.$3 = s.$3",
+ "WHEN MATCHED THEN UPDATE SET *",
+ "WHEN NOT MATCHED THEN INSERT *;"
+ ],
+ "description": "Spark Delta MERGE INTO upsert"
+ },
+ "Spark Window Function": {
+ "prefix": "sparkwin",
+ "body": [
+ "SELECT $1,",
+ " ROW_NUMBER() OVER (",
+ " PARTITION BY $2",
+ " ORDER BY $3",
+ " ) AS rn",
+ "FROM $4;"
+ ],
+ "description": "Spark ROW_NUMBER window function"
+ },
+ "Spark Cache Table": {
+ "prefix": "sparkcache",
+ "body": [
+ "CACHE TABLE $1",
+ "OPTIONS ('storageLevel' = 'MEMORY_AND_DISK')",
+ "AS",
+ "SELECT $2 FROM $3 WHERE $4;"
+ ],
+ "description": "Cache Spark table"
+ },
+ "Spark Pivot": {
+ "prefix": "sparkpivot",
+ "body": [
+ "SELECT *",
+ "FROM (",
+ " SELECT $1, $2, $3",
+ " FROM $4",
+ ")",
+ "PIVOT (",
+ " ${5:SUM}($3)",
+ " FOR $2 IN ($6)",
+ ");"
+ ],
+ "description": "Spark PIVOT query"
+ },
+ "Spark Create Function": {
+ "prefix": "sparkfn",
+ "body": [
+ "CREATE TEMPORARY FUNCTION $1",
+ "AS '${2:com.example.YourUdf}'",
+ "USING JAR '$3';"
+ ],
+ "description": "Create Spark temporary function (Java/Scala UDF)"
+ },
+ "Spark Analyze Table": {
+ "prefix": "sparkstats",
+ "body": [
+ "ANALYZE TABLE $1",
+ "COMPUTE STATISTICS FOR COLUMNS $2;"
+ ],
+ "description": "Compute column statistics for Spark table"
+ },
+ "Spark Convert To Delta": {
+ "prefix": "sparkconvdelta",
+ "body": [
+ "CONVERT TO DELTA $1",
+ "PARTITIONED BY ($2);"
+ ],
+ "description": "Convert Parquet table to Delta Lake format"
+ },
+ "Spark Optimize ZOrder (Delta)": {
+ "prefix": "sparkoptimize",
+ "body": [
+ "OPTIMIZE $1",
+ "WHERE $2",
+ "ZORDER BY ($3);"
+ ],
+ "description": "Delta OPTIMIZE with ZORDER"
+ },
+ "Spark Vacuum (Delta)": {
+ "prefix": "sparkvacuum",
+ "body": [
+ "VACUUM $1 RETAIN $2 HOURS;",
+ "VACUUM $1 RETAIN $2 HOURS DRY RUN;"
+ ],
+ "description": "Delta VACUUM retention"
+ },
+ "Spark Show Table Details": {
+ "prefix": "sparkdesc",
+ "body": [
+ "DESCRIBE TABLE EXTENDED $1;",
+ "DESCRIBE DETAIL $1;"
+ ],
+ "description": "Show Spark table details"
}
},
"sql": {
diff --git a/src/core/dialectRegistry.ts b/src/core/dialectRegistry.ts
index eacaf0e..cad6119 100644
--- a/src/core/dialectRegistry.ts
+++ b/src/core/dialectRegistry.ts
@@ -39,6 +39,21 @@ export function findDialectByLangId(langId: string): DialectEntry | undefined {
return langIdMap.get(langId)
}
+/**
+ * Maps a SQL dialect (as stored in ConnectionConfig.dialect) to the
+ * corresponding VS Code language ID. Returns `'sql'` when no match is found.
+ * Prefers the first registered vscodeLangId for each dialect (e.g.
+ * 'postgresql' over 'postgres', 'plsql' over 'oracle').
+ */
+export function findVscodeLangIdByDialect(dialect: string): string {
+ for (const entry of dialectEntries) {
+ if (entry.sqlDialect === dialect) {
+ return entry.vscodeLangId;
+ }
+ }
+ return 'sql';
+}
+
let _cachedLanguageIds: readonly string[] | null = null
export function getSqlLanguageIds(): readonly string[] {
diff --git a/src/core/sqlDialects.ts b/src/core/sqlDialects.ts
index d8f2f14..2897a6d 100644
--- a/src/core/sqlDialects.ts
+++ b/src/core/sqlDialects.ts
@@ -1,7 +1,7 @@
import type { SqlLanguage } from "../formatter/sqlFormatter"
-import { getDialectEntries, isSqlDocument, toSqlDialect, getSqlLanguageIds } from "./dialectRegistry"
+import { getDialectEntries, isSqlDocument, toSqlDialect, getSqlLanguageIds, findVscodeLangIdByDialect } from "./dialectRegistry"
-export { isSqlDocument, toSqlDialect, getSqlLanguageIds }
+export { isSqlDocument, toSqlDialect, getSqlLanguageIds, findVscodeLangIdByDialect }
export const sqlDialects: Record = Object.fromEntries(
getDialectEntries().map(e => [e.vscodeLangId, e.sqlLanguage])
diff --git a/src/database/adapters/BaseMetadataAdapter.ts b/src/database/adapters/BaseMetadataAdapter.ts
index b0bbeb6..a87568e 100644
--- a/src/database/adapters/BaseMetadataAdapter.ts
+++ b/src/database/adapters/BaseMetadataAdapter.ts
@@ -3,6 +3,7 @@ import type {
DatabaseInfo,
TableInfo,
ViewInfo,
+ MaterializedViewInfo,
FunctionInfo,
ProcedureInfo,
TriggerInfo,
@@ -93,6 +94,14 @@ export abstract class BaseMetadataAdapter implements IMetadat
*/
protected abstract listDatabaseRows(): Promise;
+ /**
+ * Default no-op: returns an empty array. Dialects that don't support
+ * materialized views inherit this default.
+ */
+ async listMaterializedViews(_database?: string, _schema?: string): Promise {
+ return [];
+ }
+
/**
* Default no-op: returns an empty array. Dialects that don't support
* user-defined functions (SQLite, StarRocks) inherit this default
diff --git a/src/database/adapters/BaseSchemaAdapter.ts b/src/database/adapters/BaseSchemaAdapter.ts
index 9056155..d470853 100644
--- a/src/database/adapters/BaseSchemaAdapter.ts
+++ b/src/database/adapters/BaseSchemaAdapter.ts
@@ -227,6 +227,7 @@ export abstract class BaseSchemaAdapter implements ISchemaAda
abstract describeTable(database: string, table: string, schema?: string): Promise;
abstract getTableDDL(database: string, table: string, schema?: string): Promise;
abstract getViewDDL(database: string, view: string, schema?: string): Promise;
+ abstract getMaterializedViewDDL(database: string, mvName: string, schema?: string): Promise;
abstract getFunctionDDL(database: string, functionName: string, schema?: string): Promise;
abstract getProcedureDDL(database: string, procedureName: string, schema?: string): Promise;
abstract getTriggerDDL(database: string, triggerName: string, schema?: string): Promise;
diff --git a/src/database/adapters/IDatabaseAdapter.ts b/src/database/adapters/IDatabaseAdapter.ts
index 77a1114..5adc3c0 100644
--- a/src/database/adapters/IDatabaseAdapter.ts
+++ b/src/database/adapters/IDatabaseAdapter.ts
@@ -152,6 +152,7 @@ export interface IMetadataAdapter {
listSchemas(database?: string): Promise;
listTables(database?: string, schema?: string, filter?: string): Promise;
listViews(database?: string, schema?: string): Promise;
+ listMaterializedViews(database?: string, schema?: string): Promise;
listFunctions(database?: string, schema?: string): Promise;
listProcedures(database?: string, schema?: string): Promise;
listTriggers(database?: string, schema?: string): Promise;
@@ -161,6 +162,7 @@ export interface ISchemaAdapter {
describeTable(database: string, table: string, schema?: string): Promise;
getTableDDL(database: string, table: string, schema?: string): Promise;
getViewDDL(database: string, view: string, schema?: string): Promise;
+ getMaterializedViewDDL(database: string, mvName: string, schema?: string): Promise;
getFunctionDDL(database: string, functionName: string, schema?: string): Promise;
getProcedureDDL(database: string, procedureName: string, schema?: string): Promise;
getTriggerDDL(database: string, triggerName: string, schema?: string): Promise;
@@ -238,6 +240,34 @@ export interface ViewInfo {
comment?: string;
}
+export interface MaterializedViewInfo {
+ name: string;
+ definition?: string;
+ comment?: string;
+ status?: string;
+}
+
+export interface MaterializedViewDesign {
+ viewName: string;
+ database: string;
+ mode: 'create' | 'alter';
+ definition: string;
+ refreshType: 'ASYNC' | 'SYNC' | 'MANUAL';
+ partitionBy?: string;
+ distributedBy?: string;
+ orderBy?: string;
+ properties?: Record;
+ originalView?: MaterializedViewInfo;
+}
+
+export interface MaterializedViewRefreshInfo {
+ viewName: string;
+ lastRefreshTime?: string;
+ nextRefreshTime?: string;
+ refreshState: 'SUCCESS' | 'FAILED' | 'RUNNING' | 'PENDING';
+ errorMessage?: string;
+}
+
export interface FunctionInfo {
name: string;
returns?: string;
diff --git a/src/database/adapters/MysqlAdapter.ts b/src/database/adapters/MysqlAdapter.ts
index 64412bf..747dca5 100644
--- a/src/database/adapters/MysqlAdapter.ts
+++ b/src/database/adapters/MysqlAdapter.ts
@@ -1038,6 +1038,10 @@ export class MysqlSchemaAdapter {
+ return '';
+ }
+
async getFunctionDDL(database: string, functionName: string, _schema?: string): Promise {
this.validateIdentifier(database);
this.validateIdentifier(functionName);
diff --git a/src/database/adapters/OracleAdapter.ts b/src/database/adapters/OracleAdapter.ts
index dd8abed..17de193 100644
--- a/src/database/adapters/OracleAdapter.ts
+++ b/src/database/adapters/OracleAdapter.ts
@@ -765,6 +765,10 @@ export class OracleSchemaAdapter {
+ return '';
+ }
+
async getFunctionDDL(_database: string, functionName: string, schema?: string): Promise {
const owner = this.resolveOwner(schema);
this.validateIdentifier(functionName);
diff --git a/src/database/adapters/PostgresAdapter.ts b/src/database/adapters/PostgresAdapter.ts
index 4614154..424f44a 100644
--- a/src/database/adapters/PostgresAdapter.ts
+++ b/src/database/adapters/PostgresAdapter.ts
@@ -663,6 +663,10 @@ class PostgresSchemaAdapter extends BaseSchemaAdapter {
return (result.rows[0].definition as string) ?? '';
}
+ async getMaterializedViewDDL(_database: string, _mvName: string, _schema?: string): Promise {
+ return '';
+ }
+
async getFunctionDDL(_database: string, functionName: string, schema?: string): Promise {
const targetSchema = schema ?? 'public';
const sql = `SELECT pg_get_functiondef($1::regprocedure) AS definition`;
diff --git a/src/database/adapters/SqlServerAdapter.ts b/src/database/adapters/SqlServerAdapter.ts
index 868bd48..9555de7 100644
--- a/src/database/adapters/SqlServerAdapter.ts
+++ b/src/database/adapters/SqlServerAdapter.ts
@@ -589,6 +589,10 @@ class SqlServerSchemaAdapter extends BaseSchemaAdapter {
return (result.rows[0].definition as string) ?? '';
}
+ async getMaterializedViewDDL(_database: string, _mvName: string, _schema?: string): Promise {
+ return '';
+ }
+
async getFunctionDDL(database: string, functionName: string, _schema?: string): Promise {
this.validateIdentifier(database);
this.validateIdentifier(functionName);
diff --git a/src/database/adapters/SqliteAdapter.ts b/src/database/adapters/SqliteAdapter.ts
index c3c07ce..b639327 100644
--- a/src/database/adapters/SqliteAdapter.ts
+++ b/src/database/adapters/SqliteAdapter.ts
@@ -364,6 +364,10 @@ class SqliteSchemaAdapter extends BaseSchemaAdapter {
return (result.rows[0].sql as string) ?? '';
}
+ async getMaterializedViewDDL(_database: string, _mvName: string, _schema?: string): Promise {
+ return '';
+ }
+
async getFunctionDDL(_database: string, _functionName: string, _schema?: string): Promise {
return '';
}
diff --git a/src/database/adapters/StarrocksAdapter.ts b/src/database/adapters/StarrocksAdapter.ts
index b3b68d1..873adea 100644
--- a/src/database/adapters/StarrocksAdapter.ts
+++ b/src/database/adapters/StarrocksAdapter.ts
@@ -214,6 +214,44 @@ class StarrocksMetadataAdapter extends MysqlMetadataAdapter {
+ const db = database ?? this.shared.config?.database;
+ if (!db) {
+ return [];
+ }
+
+ const sql = `SELECT TABLE_NAME, TABLE_COMMENT FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'VIEW' ORDER BY TABLE_NAME`;
+ const views = await this.runListQuery(sql, [{ value: db }], (row: import('./IDatabaseAdapter').QueryRow) => ({
+ name: row.TABLE_NAME as string,
+ comment: row.TABLE_COMMENT as string,
+ }));
+
+ const mvs = await this.listMaterializedViews(database);
+ const mvNames = new Set(mvs.map(mv => mv.name));
+ return views.filter(v => !mvNames.has(v.name));
+ }
+
+ override async listMaterializedViews(database?: string, _schema?: string): Promise {
+ const db = database ?? this.shared.config?.database;
+ if (!db) {
+ return [];
+ }
+
+ const sql = `SHOW MATERIALIZED VIEWS FROM \`${db.replace(/`/g, '``')}\``;
+ return this.runListQuery(sql, undefined, (row: import('./IDatabaseAdapter').QueryRow) => {
+ const isActiveRaw = (row.is_active ?? row.state ?? row.State ?? row.STATE);
+ let status: string | undefined;
+ if (isActiveRaw !== undefined && isActiveRaw !== null) {
+ const val = String(isActiveRaw).toLowerCase();
+ status = (val === 'false' || val === '0' || val === 'inactive') ? 'inactive' : 'active';
+ }
+ return {
+ name: (row.name ?? row.Name ?? row.NAME) as string,
+ status,
+ };
+ });
+ }
}
/**
@@ -260,6 +298,32 @@ class StarrocksSchemaAdapter extends MysqlSchemaAdapter
return (result.rows[0]['Create Table'] ?? result.rows[0]['Create View'] ?? '') as string;
}
+ override async getMaterializedViewDDL(database: string, mvName: string, _schema?: string): Promise {
+ this.validateIdentifier(database);
+ this.validateIdentifier(mvName);
+ const sql = `SHOW CREATE MATERIALIZED VIEW ${this.quoteIdentifier(database)}.${this.quoteIdentifier(mvName)}`;
+ const result = await this.executeQuery(sql);
+ if (result.status !== 'success' || result.rows.length === 0) {
+ return '';
+ }
+
+ return (result.rows[0]['Create Materialized View'] ?? result.rows[0]['create materialized view'] ?? '') as string;
+ }
+
+ async refreshMaterializedView(database: string, mvName: string): Promise {
+ this.validateIdentifier(database);
+ this.validateIdentifier(mvName);
+ const sql = `REFRESH MATERIALIZED VIEW ${this.quoteIdentifier(database)}.${this.quoteIdentifier(mvName)}`;
+ await this.executeQuery(sql);
+ }
+
+ async dropMaterializedView(database: string, mvName: string): Promise {
+ this.validateIdentifier(database);
+ this.validateIdentifier(mvName);
+ const sql = `DROP MATERIALIZED VIEW ${this.quoteIdentifier(database)}.${this.quoteIdentifier(mvName)}`;
+ await this.executeQuery(sql);
+ }
+
override async getFunctionDDL(_database: string, _functionName: string, _schema?: string): Promise {
// StarRocks does not support user-defined functions.
return '';
@@ -330,7 +394,7 @@ class StarrocksSchemaAdapter extends MysqlSchemaAdapter
supportsCancel: true,
supportsSshTunnel: true,
// StarRocks does not support procedures/triggers/foreign keys
- supportedObjectTypes: ['table', 'view', 'index'],
+ supportedObjectTypes: ['table', 'view', 'materializedView', 'index'],
};
}
diff --git a/src/database/commands/SchemaCommands.ts b/src/database/commands/SchemaCommands.ts
index 294c2f0..36764d7 100644
--- a/src/database/commands/SchemaCommands.ts
+++ b/src/database/commands/SchemaCommands.ts
@@ -37,6 +37,334 @@ function getNodeField(node: ITreeNode, field: string): string {
return (node as unknown as Record)[field] as string;
}
+function formatColumnDefs(ddl: string): string {
+ const viewMatch = ddl.match(/^CREATE\s+(?:MATERIALIZED\s+)?VIEW\s+`[^`]+`\s*\(/i);
+ if (!viewMatch) return ddl;
+ const startIdx = viewMatch.index! + viewMatch[0].length - 1;
+ let depth = 1;
+ let i = startIdx + 1;
+ let inQuote = false;
+ let quoteChar = '';
+ while (i < ddl.length && depth > 0) {
+ const ch = ddl[i];
+ if (inQuote) {
+ if (ch === quoteChar && ddl[i - 1] !== '\\') { inQuote = false; }
+ i++;
+ continue;
+ }
+ if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; i++; continue; }
+ if (ch === '(') { depth++; i++; continue; }
+ if (ch === ')') { depth--; if (depth === 0) break; i++; continue; }
+ i++;
+ }
+ if (depth !== 0) return ddl;
+ const endIdx = i;
+ const inner = ddl.substring(startIdx + 1, endIdx);
+ const cols = splitColumnDefs(inner);
+ if (cols.length <= 1) return ddl;
+ return ddl.substring(0, startIdx + 1) + '\n ' + cols.join(',\n ') + '\n' + ddl.substring(endIdx);
+}
+
+function splitColumnDefs(content: string): string[] {
+ const items: string[] = [];
+ let current = '';
+ let depth = 0;
+ let inQuote = false;
+ let quoteChar = '';
+ for (let i = 0; i < content.length; i++) {
+ const ch = content[i];
+ if (inQuote) {
+ current += ch;
+ if (ch === quoteChar && content[i - 1] !== '\\') { inQuote = false; }
+ continue;
+ }
+ if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; current += ch; continue; }
+ if (ch === '(') { depth++; current += ch; continue; }
+ if (ch === ')') { depth--; current += ch; continue; }
+ if (ch === ',' && depth === 0) {
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+ current = '';
+ continue;
+ }
+ current += ch;
+ }
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+ return items;
+}
+
+function formatDdlOutput(ddl: string): string {
+ let s = ddl.replace(/\s+/g, ' ').trim();
+
+ s = formatColumnDefs(s);
+
+ s = s.replace(/\)\s+(COMMENT\s)/i, ')\n$1');
+ s = s.replace(/\)\s+(DISTRIBUTED\b)/i, ')\n$1');
+ s = s.replace(/\)\s+(REFRESH\b)/i, ')\n$1');
+ s = s.replace(/\)\s+(PROPERTIES\s*\()/i, ')\n$1');
+ s = s.replace(/\)\s+(AS\s+SELECT\b)/i, ')\n$1');
+
+ s = s.replace(/"\s+(DISTRIBUTED\b)/g, '"\n$1');
+ s = s.replace(/"\s+(REFRESH\b)/g, '"\n$1');
+ s = s.replace(/"\s+(PROPERTIES\s*\()/g, '"\n$1');
+ s = s.replace(/"\s+(AS\s+SELECT\b)/g, '"\n$1');
+
+ s = s.replace(
+ /PROPERTIES\s*\(([^)]+)\)/i,
+ (_m, inner: string) => {
+ const items = splitProperties(inner);
+ return 'PROPERTIES (\n ' + items.join(',\n ') + '\n)';
+ }
+ );
+
+ s = s.replace(
+ /\bAS\s+SELECT\b(.+)/is,
+ (_m, rest: string) => {
+ if (hasTopLevelUnionAll(rest)) {
+ return 'AS\n' + formatUnionSelect(rest);
+ }
+ let formatted = rest
+ .replace(/\s+FROM\b/gi, '\nFROM')
+ .replace(/\s+WHERE\b/gi, '\nWHERE')
+ .replace(/\s+GROUP\s+BY\b/gi, '\nGROUP BY')
+ .replace(/\s+HAVING\b/gi, '\nHAVING')
+ .replace(/\s+ORDER\s+BY\b/gi, '\nORDER BY')
+ .replace(/\s+LIMIT\b/gi, '\nLIMIT');
+ formatted = 'AS\nSELECT' + splitSelectColumns(formatted);
+ return formatSubquery(formatted);
+ }
+ );
+
+ return s;
+}
+
+function hasTopLevelUnionAll(sql: string): boolean {
+ let depth = 0;
+ let inQuote = false;
+ let quoteChar = '';
+ const upper = sql.toUpperCase();
+ for (let i = 0; i < sql.length; i++) {
+ const ch = sql[i];
+ if (inQuote) {
+ if (ch === quoteChar && sql[i - 1] !== '\\') {
+ inQuote = false;
+ }
+ continue;
+ }
+ if (ch === '"' || ch === "'") {
+ inQuote = true;
+ quoteChar = ch;
+ continue;
+ }
+ if (ch === '(') { depth++; continue; }
+ if (ch === ')') { depth--; continue; }
+ if (depth === 0 && upper.substring(i).startsWith('UNION ALL')) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function splitProperties(content: string): string[] {
+ const items: string[] = [];
+ let current = '';
+ let inQuote = false;
+ let quoteChar = '';
+
+ for (let i = 0; i < content.length; i++) {
+ const ch = content[i];
+
+ if (inQuote) {
+ current += ch;
+ if (ch === quoteChar && content[i - 1] !== '\\') {
+ inQuote = false;
+ }
+ continue;
+ }
+
+ if (ch === '"' || ch === "'") {
+ inQuote = true;
+ quoteChar = ch;
+ current += ch;
+ continue;
+ }
+
+ if (ch === ',') {
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+ current = '';
+ continue;
+ }
+
+ current += ch;
+ }
+
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+
+ return items;
+}
+
+function splitSelectColumns(selectPart: string): string {
+ const fromIdx = selectPart.search(/\nFROM\b/i);
+ const selectBody = fromIdx >= 0 ? selectPart.substring(0, fromIdx) : selectPart;
+ const afterFrom = fromIdx >= 0 ? selectPart.substring(fromIdx) : '';
+
+ const trimmed = selectBody.trim();
+ if (!trimmed) return selectPart;
+
+ const cols: string[] = [];
+ let current = '';
+ let depth = 0;
+ let inQuote = false;
+ let quoteChar = '';
+
+ for (let i = 0; i < trimmed.length; i++) {
+ const ch = trimmed[i];
+
+ if (inQuote) {
+ current += ch;
+ if (ch === quoteChar && trimmed[i - 1] !== '\\') {
+ inQuote = false;
+ }
+ continue;
+ }
+
+ if (ch === '"' || ch === "'") {
+ inQuote = true;
+ quoteChar = ch;
+ current += ch;
+ continue;
+ }
+
+ if (ch === '(') { depth++; current += ch; continue; }
+ if (ch === ')') { depth--; current += ch; continue; }
+
+ if (ch === ',' && depth === 0) {
+ const c = current.trim();
+ if (c) cols.push(c);
+ current = '';
+ continue;
+ }
+
+ current += ch;
+ }
+
+ const c = current.trim();
+ if (c) cols.push(c);
+
+ if (cols.length <= 1) return selectPart;
+
+ return '\n ' + cols.join(',\n ') + afterFrom;
+}
+
+function formatUnionSelect(sql: string): string {
+ const parts = sql.split(/\bUNION\s+ALL\b/i).map(p => p.trim());
+ const formattedParts = parts.map((part, idx) => {
+ let p = part;
+ if (!/^\s*SELECT\b/i.test(p)) {
+ p = 'SELECT ' + p;
+ }
+ p = p
+ .replace(/\bSELECT\b\s*/i, 'SELECT\n ')
+ .replace(/\s+FROM\b/gi, '\nFROM')
+ .replace(/\s+WHERE\b/gi, '\nWHERE')
+ .replace(/\s+GROUP\s+BY\b/gi, '\nGROUP BY')
+ .replace(/\s+HAVING\b/gi, '\nHAVING')
+ .replace(/\s+ORDER\s+BY\b/gi, '\nORDER BY')
+ .replace(/\s+LIMIT\b/gi, '\nLIMIT');
+ p = formatSelectColsInLine(p);
+ if (idx < parts.length - 1) {
+ return p + '\nUNION ALL';
+ }
+ return p;
+ });
+ const lastPart = formattedParts[formattedParts.length - 1];
+ const unionParts = formattedParts.slice(0, -1);
+ const match = lastPart.match(/^([\s\S]*?)(FROM\b[\s\S]*)$/i);
+ if (match) {
+ const selectBlock = match[1].trimEnd();
+ const restBlock = match[2];
+ const formatted = restBlock
+ .replace(/FROM\b\s*/i, 'FROM\n ')
+ .replace(/\s+WHERE\b/gi, '\nWHERE')
+ .replace(/\s+GROUP\s+BY\b/gi, '\nGROUP BY')
+ .replace(/\s+ORDER\s+BY\b/gi, '\nORDER BY')
+ .replace(/\s+LIMIT\b/gi, '\nLIMIT');
+ return [...unionParts, selectBlock, formatted].join('\n');
+ }
+ return formattedParts.join('\n');
+}
+
+function formatSelectColsInLine(selectBlock: string): string {
+ const lines = selectBlock.split('\n');
+ if (lines.length < 2) return selectBlock;
+ const keyword = lines[0];
+ const rest = lines.slice(1).join('\n');
+ const fromIdx = rest.search(/\n\s*FROM\b/i);
+ const colsPart = fromIdx >= 0 ? rest.substring(0, fromIdx) : rest;
+ const afterPart = fromIdx >= 0 ? rest.substring(fromIdx) : '';
+ const cols = colsPart.split(/\s*,\s*/).filter(c => c.trim());
+ if (cols.length <= 1) return selectBlock;
+ return keyword + '\n ' + cols.join(',\n ') + afterPart;
+}
+
+function formatSubquery(sql: string): string {
+ let result = '';
+ let i = 0;
+ while (i < sql.length) {
+ if (sql[i] === '(') {
+ let depth = 1;
+ let j = i + 1;
+ while (j < sql.length && depth > 0) {
+ if (sql[j] === '(') depth++;
+ if (sql[j] === ')') depth--;
+ j++;
+ }
+ const inner = sql.substring(i + 1, j - 1).trim();
+ if (/^\s*SELECT\b/i.test(inner)) {
+ if (/\bUNION\s+ALL\b/i.test(inner)) {
+ const parts = inner.split(/\bUNION\s+ALL\b/i).map(p => p.trim());
+ const formatted = parts.map((p, idx) => {
+ let fp = p
+ .replace(/\bSELECT\b\s*/i, 'SELECT\n ')
+ .replace(/\s+FROM\b/gi, '\n FROM')
+ .replace(/\s+WHERE\b/gi, '\n WHERE');
+ fp = formatSubqueryCols(fp);
+ if (idx < parts.length - 1) {
+ return fp + '\n UNION ALL';
+ }
+ return fp;
+ });
+ result += '(\n ' + formatted.join('\n') + '\n)';
+ } else {
+ result += '(' + inner + ')';
+ }
+ } else {
+ result += sql.substring(i, j);
+ }
+ i = j;
+ } else {
+ result += sql[i];
+ i++;
+ }
+ }
+ return result;
+}
+
+function formatSubqueryCols(block: string): string {
+ const lines = block.split('\n');
+ if (lines.length < 2) return block;
+ const keyword = lines[0].trim();
+ const rest = lines.slice(1).join('\n');
+ const fromIdx = rest.search(/\n\s*FROM\b/i);
+ const colsPart = fromIdx >= 0 ? rest.substring(0, fromIdx) : rest;
+ const afterPart = fromIdx >= 0 ? rest.substring(fromIdx) : '';
+ const cols = colsPart.split(/\s*,\s*/).map(c => c.trim()).filter(c => c);
+ if (cols.length <= 1) return block;
+ return keyword + '\n ' + cols.join(',\n ') + afterPart;
+}
export function registerSchemaCommands(
_context: vscode.ExtensionContext,
@@ -64,7 +392,14 @@ export function registerSchemaCommands(
const connectionId = getNodeField(node, 'connectionId');
const databaseName = getNodeField(node, 'databaseName');
- const name = node.type === 'table' ? getNodeField(node, 'tableName') : getNodeField(node, 'viewName');
+ let name = '';
+ if (node.type === 'table') {
+ name = getNodeField(node, 'tableName');
+ } else if (node.type === 'view') {
+ name = getNodeField(node, 'viewName');
+ } else if (node.type === 'materializedView') {
+ name = getNodeField(node, 'mvName');
+ }
const connectionManager = getConnectionManager();
const adapter = connectionManager.getAdapter(connectionId);
@@ -135,8 +470,9 @@ export function registerSchemaCommands(
if (!adapter) return;
try {
const ddl = await getDDL(adapter, databaseName, getNodeName(node));
+ const formatted = formatDdlOutput(ddl);
const document = await vscode.workspace.openTextDocument({
- content: ddl,
+ content: formatted,
language: 'sql'
});
await vscode.window.showTextDocument(document);
@@ -149,6 +485,7 @@ export function registerSchemaCommands(
disposables.push(
createViewDDLCommand('hive-formatter.viewTableDDL', n => getNodeField(n, 'tableName'), (a, db, name) => a.schemaAdapter.getTableDDL(db, name)),
createViewDDLCommand('hive-formatter.viewViewDDL', n => getNodeField(n, 'viewName'), (a, db, name) => a.schemaAdapter.getViewDDL(db, name)),
+ createViewDDLCommand('hive-formatter.viewMaterializedViewDDL', n => getNodeField(n, 'mvName'), (a, db, name) => a.schemaAdapter.getMaterializedViewDDL(db, name)),
createViewDDLCommand('hive-formatter.viewFunctionDDL', n => getNodeField(n, 'functionName'), (a, db, name) => a.schemaAdapter.getFunctionDDL(db, name)),
createViewDDLCommand('hive-formatter.viewProcedureDDL', n => getNodeField(n, 'procedureName'), (a, db, name) => a.schemaAdapter.getProcedureDDL(db, name)),
createViewDDLCommand('hive-formatter.viewTriggerDDL', n => getNodeField(n, 'triggerName'), (a, db, name) => a.schemaAdapter.getTriggerDDL(db, name)),
@@ -230,8 +567,18 @@ export function registerSchemaCommands(
(c) => c.id === connectionId
);
if (conn) {
- const name = node.type === 'table' ? getNodeField(node, 'tableName') : getNodeField(node, 'viewName');
- const type: 'table' | 'view' = node.type === 'table' ? 'table' : 'view';
+ let name = '';
+ let type: 'table' | 'view' = 'view';
+ if (node.type === 'table') {
+ name = getNodeField(node, 'tableName');
+ type = 'table';
+ } else if (node.type === 'view') {
+ name = getNodeField(node, 'viewName');
+ type = 'view';
+ } else if (node.type === 'materializedView') {
+ name = getNodeField(node, 'mvName');
+ type = 'view';
+ }
await vscode.commands.executeCommand(
'hive-formatter.addTreeFavorite',
connectionId,
diff --git a/src/database/schema/SchemaCache.ts b/src/database/schema/SchemaCache.ts
index 192b780..e4d76cf 100644
--- a/src/database/schema/SchemaCache.ts
+++ b/src/database/schema/SchemaCache.ts
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
-import type { DatabaseInfo, TableInfo, ColumnInfo, FunctionInfo, ProcedureInfo, ViewInfo } from '../adapters/IDatabaseAdapter';
+import type { DatabaseInfo, TableInfo, ColumnInfo, FunctionInfo, ProcedureInfo, ViewInfo, MaterializedViewInfo } from '../adapters/IDatabaseAdapter';
import { getConnectionManager } from '../connection/ConnectionManager';
import { getConfigManager } from '../../core/configManager';
import { getContainer, Tokens } from '../../core/diContainer';
@@ -11,7 +11,7 @@ interface CacheEntry {
expireAt: number;
}
-type InvalidateScope = 'database' | 'table' | 'column' | 'function' | 'procedure' | 'view';
+type InvalidateScope = 'database' | 'table' | 'column' | 'function' | 'procedure' | 'view' | 'materializedView';
export class SchemaCache {
private static readonly MAX_ENTRIES_PER_CACHE = 200;
@@ -30,6 +30,7 @@ export class SchemaCache {
private functionCache = new LRUCache>({ maxSize: SchemaCache.MAX_ENTRIES_PER_CACHE, maxAge: SchemaCache.MAX_AGE_MS });
private procedureCache = new LRUCache>({ maxSize: SchemaCache.MAX_ENTRIES_PER_CACHE, maxAge: SchemaCache.MAX_AGE_MS });
private viewCache = new LRUCache>({ maxSize: SchemaCache.MAX_ENTRIES_PER_CACHE, maxAge: SchemaCache.MAX_AGE_MS });
+ private materializedViewCache = new LRUCache>({ maxSize: SchemaCache.MAX_ENTRIES_PER_CACHE, maxAge: SchemaCache.MAX_AGE_MS });
private pendingRequests = new Map>();
private cachedTtls: Record = {};
private ttlConfigDisposable: vscode.Disposable | undefined;
@@ -51,6 +52,7 @@ export class SchemaCache {
function: cfgMgr.get('schemaCache.functionTtl', 600),
procedure: cfgMgr.get('schemaCache.procedureTtl', 600),
view: cfgMgr.get('schemaCache.tableTtl', 300),
+ materializedView: cfgMgr.get('schemaCache.tableTtl', 300),
};
}
@@ -179,6 +181,18 @@ export class SchemaCache {
);
}
+ async getMaterializedViews(connectionId: string, database: string): Promise {
+ return this.cachedFetch(
+ this.materializedViewCache,
+ this.makeKey(connectionId, database),
+ 'materializedView',
+ async () => {
+ const adapter = getConnectionManager().getAdapter(connectionId);
+ return adapter ? await adapter.metadataAdapter.listMaterializedViews(database) : [];
+ }
+ );
+ }
+
invalidate(connectionId: string, scope?: InvalidateScope, database?: string, table?: string): void {
if (!scope) {
this.invalidateByPrefix(this.databaseCache, connectionId);
@@ -187,6 +201,7 @@ export class SchemaCache {
this.invalidateByPrefix(this.functionCache, connectionId);
this.invalidateByPrefix(this.procedureCache, connectionId);
this.invalidateByPrefix(this.viewCache, connectionId);
+ this.invalidateByPrefix(this.materializedViewCache, connectionId);
return;
}
@@ -220,6 +235,11 @@ export class SchemaCache {
this.viewCache.delete(this.makeKey(connectionId, database));
}
break;
+ case 'materializedView':
+ if (database) {
+ this.materializedViewCache.delete(this.makeKey(connectionId, database));
+ }
+ break;
}
}
@@ -256,6 +276,7 @@ export class SchemaCache {
this.functionCache.clear();
this.procedureCache.clear();
this.viewCache.clear();
+ this.materializedViewCache.clear();
this.pendingRequests.clear();
}
}
diff --git a/src/formatter/nodeFormatters/DDLFormatter.ts b/src/formatter/nodeFormatters/DDLFormatter.ts
index b45ce62..191f09e 100644
--- a/src/formatter/nodeFormatters/DDLFormatter.ts
+++ b/src/formatter/nodeFormatters/DDLFormatter.ts
@@ -258,7 +258,7 @@ export class DDLFormatter {
private formatTableOptions(options: Record[]): void {
for (const opt of options) {
- this.layout.add(WS.SPACE);
+ this.layout.add(WS.MANDATORY_NEWLINE, WS.INDENT);
if (typeof opt === 'string') {
this.layout.add(opt);
} else if (typeof opt === 'object') {
diff --git a/src/i18n/messages.en.json b/src/i18n/messages.en.json
index 8a9e39e..71fe629 100644
--- a/src/i18n/messages.en.json
+++ b/src/i18n/messages.en.json
@@ -668,12 +668,14 @@
"explorer.database": "Database: {0}",
"explorer.tables": "Tables",
"explorer.views": "Views",
+ "explorer.materializedViews": "Materialized Views",
"explorer.functions": "Functions",
"explorer.procedures": "Procedures",
"explorer.triggers": "Triggers",
"explorer.rows": "{0} rows",
"explorer.table": "Table: {0}",
"explorer.view": "View: {0}",
+ "explorer.materializedView": "Materialized View: {0}",
"explorer.function": "Function: {0}",
"explorer.procedure": "Procedure: {0}",
"explorer.trigger": "Trigger: {0}",
diff --git a/src/i18n/messages.zh.json b/src/i18n/messages.zh.json
index c8bef19..d555791 100644
--- a/src/i18n/messages.zh.json
+++ b/src/i18n/messages.zh.json
@@ -668,12 +668,14 @@
"explorer.database": "数据库: {0}",
"explorer.tables": "表",
"explorer.views": "视图",
+ "explorer.materializedViews": "物化视图",
"explorer.functions": "函数",
"explorer.procedures": "存储过程",
"explorer.triggers": "触发器",
"explorer.rows": "{0} 行",
"explorer.table": "表: {0}",
"explorer.view": "视图: {0}",
+ "explorer.materializedView": "物化视图: {0}",
"explorer.function": "函数: {0}",
"explorer.procedure": "存储过程: {0}",
"explorer.trigger": "触发器: {0}",
diff --git a/src/shared/treeNodeTypes.ts b/src/shared/treeNodeTypes.ts
index 97f7153..21dab07 100644
--- a/src/shared/treeNodeTypes.ts
+++ b/src/shared/treeNodeTypes.ts
@@ -10,6 +10,7 @@ export type TreeNodeType =
| 'objectGroup'
| 'table'
| 'view'
+ | 'materializedView'
| 'function'
| 'procedure'
| 'trigger'
diff --git a/src/test/dataImporter.test.ts b/src/test/dataImporter.test.ts
index 4ce47b7..dc60f8c 100644
--- a/src/test/dataImporter.test.ts
+++ b/src/test/dataImporter.test.ts
@@ -52,6 +52,7 @@ function createMockAdapter(executeFn: (sql: string, params?: QueryParam[]) => Pr
listSchemas: async () => [],
listTables: async () => [],
listViews: async () => [],
+ listMaterializedViews: async () => [],
listFunctions: async () => [],
listProcedures: async () => [],
listTriggers: async () => [],
@@ -60,6 +61,7 @@ function createMockAdapter(executeFn: (sql: string, params?: QueryParam[]) => Pr
describeTable: async () => ({ columns: [], indexes: [], foreignKeys: [], triggers: [] }),
getTableDDL: async () => '',
getViewDDL: async () => '',
+ getMaterializedViewDDL: async () => '',
getFunctionDDL: async () => '',
getProcedureDDL: async () => '',
getTriggerDDL: async () => '',
diff --git a/src/test/materializedViewDesigner.test.ts b/src/test/materializedViewDesigner.test.ts
new file mode 100644
index 0000000..09f2c0f
--- /dev/null
+++ b/src/test/materializedViewDesigner.test.ts
@@ -0,0 +1,106 @@
+import * as assert from 'assert';
+import { describe, it } from 'mocha';
+import type { MaterializedViewDesign } from '../views/materializedViewDesigner/MaterializedViewDesignerPanel';
+
+describe('MaterializedViewDesigner', () => {
+ describe('MaterializedViewDesign Interface', () => {
+ it('should accept valid create mode design', () => {
+ const data: MaterializedViewDesign = {
+ viewName: 'mv_test',
+ database: 'test_db',
+ mode: 'create',
+ ddl: 'CREATE MATERIALIZED VIEW `mv_test`\nAS\nSELECT id, name FROM users',
+ originalDDL: '',
+ refreshType: 'ASYNC'
+ };
+
+ assert.strictEqual(data.viewName, 'mv_test');
+ assert.strictEqual(data.mode, 'create');
+ assert.strictEqual(data.refreshType, 'ASYNC');
+ });
+
+ it('should accept valid alter mode design', () => {
+ const data: MaterializedViewDesign = {
+ viewName: 'mv_test',
+ database: 'test_db',
+ mode: 'alter',
+ ddl: 'CREATE MATERIALIZED VIEW `mv_test`\nREFRESH MANUAL\nAS\nSELECT id, name FROM users',
+ originalDDL: 'CREATE MATERIALIZED VIEW `mv_test`\nREFRESH ASYNC\nAS\nSELECT id FROM users',
+ refreshType: 'MANUAL'
+ };
+
+ assert.strictEqual(data.viewName, 'mv_test');
+ assert.strictEqual(data.mode, 'alter');
+ assert.strictEqual(data.refreshType, 'MANUAL');
+ assert.notStrictEqual(data.ddl, data.originalDDL);
+ });
+
+ it('should handle all refresh types', () => {
+ const refreshTypes = ['ASYNC', 'SYNC', 'MANUAL'];
+
+ refreshTypes.forEach(function(refreshType) {
+ const data: MaterializedViewDesign = {
+ viewName: 'mv_test',
+ database: 'test_db',
+ mode: 'create',
+ ddl: 'CREATE MATERIALIZED VIEW `mv_test`\nREFRESH ' + refreshType + '\nAS\nSELECT 1',
+ originalDDL: '',
+ refreshType: refreshType
+ };
+
+ assert.strictEqual(data.refreshType, refreshType);
+ });
+ });
+ });
+
+ describe('Diff Computation', () => {
+ it('should detect no changes when DDL is same', function() {
+ const oldText = 'CREATE MATERIALIZED VIEW `mv_test`\nAS\nSELECT id FROM users';
+ const newText = 'CREATE MATERIALIZED VIEW `mv_test`\nAS\nSELECT id FROM users';
+
+ assert.strictEqual(oldText, newText);
+ });
+
+ it('should detect changes when DDL is different', function() {
+ const oldText = 'CREATE MATERIALIZED VIEW `mv_test`\nREFRESH ASYNC\nAS\nSELECT id FROM users';
+ const newText = 'CREATE MATERIALIZED VIEW `mv_test`\nREFRESH MANUAL\nAS\nSELECT id, name FROM users';
+
+ assert.notStrictEqual(oldText, newText);
+ });
+ });
+
+ describe('SQL Generation', () => {
+ it('should generate SWAP WITH SQL for alter mode', function() {
+ const viewName = 'order_mv';
+ const tempViewName = viewName + '_tmp_' + Date.now();
+ const newDDL = 'CREATE MATERIALIZED VIEW `order_mv`\nREFRESH MANUAL\nAS\nSELECT * FROM orders WHERE status = 1';
+
+ const tempDDL = newDDL.replace(
+ new RegExp('CREATE\\s+MATERIALIZED\\s+VIEW\\s+`?' + viewName + '`?', 'i'),
+ 'CREATE MATERIALIZED VIEW `' + tempViewName + '`'
+ );
+
+ const sqlStatements = [
+ tempDDL,
+ 'ALTER MATERIALIZED VIEW `' + viewName + '` SWAP WITH `' + tempViewName + '`',
+ 'DROP MATERIALIZED VIEW IF EXISTS `' + tempViewName + '`'
+ ];
+
+ assert.strictEqual(sqlStatements.length, 3);
+ assert.ok(sqlStatements[0].includes('CREATE MATERIALIZED VIEW'));
+ assert.ok(sqlStatements[1].includes('SWAP WITH'));
+ assert.ok(sqlStatements[2].includes('DROP MATERIALIZED VIEW'));
+ });
+
+ it('should not generate SWAP WITH for create mode', function() {
+ const mode = 'create';
+ const ddl = 'CREATE MATERIALIZED VIEW `mv_test`\nAS\nSELECT 1';
+
+ if (mode === 'create') {
+ assert.ok(ddl.includes('CREATE MATERIALIZED VIEW'));
+ } else {
+ assert.fail('Should be create mode');
+ }
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/views/databaseExplorer/DatabaseTreeProvider.ts b/src/views/databaseExplorer/DatabaseTreeProvider.ts
index 21f9479..3e6e2bf 100644
--- a/src/views/databaseExplorer/DatabaseTreeProvider.ts
+++ b/src/views/databaseExplorer/DatabaseTreeProvider.ts
@@ -10,6 +10,7 @@ import {
ObjectGroupTreeNode,
TableTreeNode,
ViewTreeNode,
+ MaterializedViewTreeNode,
FunctionTreeNode,
ProcedureTreeNode,
TriggerTreeNode,
@@ -209,6 +210,13 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider
arguments: [element]
};
}
+ if (element instanceof MaterializedViewTreeNode) {
+ return {
+ command: 'hive-formatter.viewTableData',
+ title: t('explorer.cmd.viewData'),
+ arguments: [element]
+ };
+ }
if (element instanceof TableTreeNode) {
return {
command: 'hive-formatter.viewTableData',
@@ -442,6 +450,9 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider
return [];
}
+ const capabilities = adapter.schemaAdapter.getDialectCapabilities();
+ const supportsMaterializedView = capabilities.supportedObjectTypes.includes('materializedView');
+
const [tables, views, functions, procedures, triggers] = await Promise.all([
this.schemaCache.getTables(parent.connectionId, parent.databaseName),
this.schemaCache.getViews(parent.connectionId, parent.databaseName),
@@ -454,11 +465,19 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider
const children: ITreeNode[] = [
new ObjectGroupTreeNode('tables', parent.connectionId, parent.databaseName, Math.min(tables.length, maxTableSize), parent),
- new ObjectGroupTreeNode('views', parent.connectionId, parent.databaseName, views.length, parent),
+ new ObjectGroupTreeNode('views', parent.connectionId, parent.databaseName, views.length, parent)
+ ];
+
+ if (supportsMaterializedView) {
+ const mvs = await this.schemaCache.getMaterializedViews(parent.connectionId, parent.databaseName);
+ children.push(new ObjectGroupTreeNode('materializedViews', parent.connectionId, parent.databaseName, mvs.length, parent));
+ }
+
+ children.push(
new ObjectGroupTreeNode('functions', parent.connectionId, parent.databaseName, functions.length, parent),
new ObjectGroupTreeNode('procedures', parent.connectionId, parent.databaseName, procedures.length, parent),
new ObjectGroupTreeNode('triggers', parent.connectionId, parent.databaseName, triggers.length, parent)
- ];
+ );
this.nodeCache.set(cacheKey, children);
return children;
@@ -515,6 +534,20 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider
}
break;
+ case 'materializedViews':
+ const mvs = await this.schemaCache.getMaterializedViews(parent.connectionId, parent.databaseName);
+ for (const mv of mvs) {
+ children.push(new MaterializedViewTreeNode(
+ mv.name,
+ parent.connectionId,
+ parent.databaseName,
+ mv.comment,
+ parent,
+ mv.status
+ ));
+ }
+ break;
+
case 'functions':
functions = await this.schemaCache.getFunctions(parent.connectionId, parent.databaseName);
for (const func of functions) {
diff --git a/src/views/databaseExplorer/treeNodes.ts b/src/views/databaseExplorer/treeNodes.ts
index 0921ac6..86e5168 100644
--- a/src/views/databaseExplorer/treeNodes.ts
+++ b/src/views/databaseExplorer/treeNodes.ts
@@ -159,7 +159,7 @@ export class DatabaseTreeNode extends BaseTreeNode {
}
}
-export type ObjectGroupType = 'tables' | 'views' | 'functions' | 'procedures' | 'triggers';
+export type ObjectGroupType = 'tables' | 'views' | 'materializedViews' | 'functions' | 'procedures' | 'triggers';
export class ObjectGroupTreeNode extends BaseTreeNode {
readonly type: TreeNodeType = 'objectGroup';
@@ -190,6 +190,10 @@ export class ObjectGroupTreeNode extends BaseTreeNode {
iconPath = new vscode.ThemeIcon('eye');
label = t('explorer.views');
break;
+ case 'materializedViews':
+ iconPath = new vscode.ThemeIcon('layers');
+ label = t('explorer.materializedViews');
+ break;
case 'functions':
iconPath = new vscode.ThemeIcon('zap');
label = t('explorer.functions');
@@ -291,6 +295,45 @@ export class ViewTreeNode extends BaseTreeNode {
}
}
+export class MaterializedViewTreeNode extends BaseTreeNode {
+ readonly type: TreeNodeType = 'materializedView';
+ readonly id: string;
+ readonly label: string;
+ readonly contextValue?: string = 'materializedView';
+ readonly mvName: string;
+ readonly connectionId: string;
+ readonly databaseName: string;
+ readonly comment?: string;
+ readonly status?: string;
+
+ constructor(
+ mvName: string,
+ connectionId: string,
+ databaseName: string,
+ comment?: string,
+ parent?: ITreeNode,
+ status?: string
+ ) {
+ const isInactive = status === 'inactive';
+ super({
+ iconPath: isInactive
+ ? new vscode.ThemeIcon('layers', new vscode.ThemeColor('disabledForeground'))
+ : new vscode.ThemeIcon('layers'),
+ collapsibleState: vscode.TreeItemCollapsibleState.None,
+ parent,
+ tooltip: comment ? `${t('explorer.materializedView', mvName)}\n${comment}` : t('explorer.materializedView', mvName)
+ });
+
+ this.mvName = mvName;
+ this.connectionId = connectionId;
+ this.databaseName = databaseName;
+ this.label = mvName;
+ this.id = `mv-${connectionId}-${databaseName}-${mvName}`;
+ this.comment = comment;
+ this.status = status;
+ }
+}
+
export class FunctionTreeNode extends BaseTreeNode {
readonly type: TreeNodeType = 'function';
readonly id: string;
diff --git a/src/views/materializedViewDesigner/MaterializedViewDesignerPanel.ts b/src/views/materializedViewDesigner/MaterializedViewDesignerPanel.ts
new file mode 100644
index 0000000..90cc708
--- /dev/null
+++ b/src/views/materializedViewDesigner/MaterializedViewDesignerPanel.ts
@@ -0,0 +1,619 @@
+import * as vscode from 'vscode';
+import { BaseWebviewPanel, type WebviewPanelConfig } from '../BaseWebviewPanel';
+import type { IConnectionService, ISchemaService } from '../../application/ports';
+import type { MaterializedViewInfo } from '../../database/adapters/IDatabaseAdapter';
+import { handleError, ErrorCategory } from '../../core/errorHandler';
+import { getLanguage } from '../../i18n';
+import { getTokenColors } from '../../utils/themeColors';
+
+function formatColumnDefs(ddl: string): string {
+ const viewMatch = ddl.match(/^CREATE\s+(?:MATERIALIZED\s+)?VIEW\s+`[^`]+`\s*\(/i);
+ if (!viewMatch) return ddl;
+ const startIdx = viewMatch.index! + viewMatch[0].length - 1;
+ let depth = 1;
+ let i = startIdx + 1;
+ let inQuote = false;
+ let quoteChar = '';
+ while (i < ddl.length && depth > 0) {
+ const ch = ddl[i];
+ if (inQuote) {
+ if (ch === quoteChar && ddl[i - 1] !== '\\') { inQuote = false; }
+ i++;
+ continue;
+ }
+ if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; i++; continue; }
+ if (ch === '(') { depth++; i++; continue; }
+ if (ch === ')') { depth--; if (depth === 0) break; i++; continue; }
+ i++;
+ }
+ if (depth !== 0) return ddl;
+ const endIdx = i;
+ const inner = ddl.substring(startIdx + 1, endIdx);
+ const cols = splitColumnDefs(inner);
+ if (cols.length <= 1) return ddl;
+ return ddl.substring(0, startIdx + 1) + '\n ' + cols.join(',\n ') + '\n' + ddl.substring(endIdx);
+}
+
+function splitColumnDefs(content: string): string[] {
+ const items: string[] = [];
+ let current = '';
+ let depth = 0;
+ let inQuote = false;
+ let quoteChar = '';
+ for (let i = 0; i < content.length; i++) {
+ const ch = content[i];
+ if (inQuote) {
+ current += ch;
+ if (ch === quoteChar && content[i - 1] !== '\\') { inQuote = false; }
+ continue;
+ }
+ if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; current += ch; continue; }
+ if (ch === '(') { depth++; current += ch; continue; }
+ if (ch === ')') { depth--; current += ch; continue; }
+ if (ch === ',' && depth === 0) {
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+ current = '';
+ continue;
+ }
+ current += ch;
+ }
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+ return items;
+}
+
+function splitSelectColumns(rest: string): string {
+ const fromMatch = rest.search(/\s+FROM\b/i);
+ if (fromMatch === -1) return rest;
+ const before = rest.substring(0, fromMatch);
+ const after = rest.substring(fromMatch);
+ const cols = splitColumnDefs(before.trim());
+ if (cols.length <= 1) return rest;
+ return '\n ' + cols.join(',\n ') + after;
+}
+
+function splitProperties(content: string): string[] {
+ const items: string[] = [];
+ let current = '';
+ let inQuote = false;
+ let quoteChar = '';
+ for (let i = 0; i < content.length; i++) {
+ const ch = content[i];
+ if (inQuote) {
+ current += ch;
+ if (ch === quoteChar && content[i - 1] !== '\\') { inQuote = false; }
+ continue;
+ }
+ if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; current += ch; continue; }
+ if (ch === ',' && !inQuote) {
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+ current = '';
+ continue;
+ }
+ current += ch;
+ }
+ const trimmed = current.trim();
+ if (trimmed) items.push(trimmed);
+ return items;
+}
+
+function hasTopLevelUnionAll(sql: string): boolean {
+ let depth = 0;
+ let inQuote = false;
+ let quoteChar = '';
+ const upper = sql.toUpperCase();
+ for (let i = 0; i < sql.length; i++) {
+ const ch = sql[i];
+ if (inQuote) {
+ if (ch === quoteChar && sql[i - 1] !== '\\') { inQuote = false; }
+ continue;
+ }
+ if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; continue; }
+ if (ch === '(') { depth++; continue; }
+ if (ch === ')') { depth--; continue; }
+ if (depth === 0 && upper.substring(i).startsWith('UNION ALL')) { return true; }
+ }
+ return false;
+}
+
+function formatUnionSelect(sql: string): string {
+ const parts: string[] = [];
+ let current = '';
+ let depth = 0;
+ let inQuote = false;
+ let quoteChar = '';
+ const upper = sql.toUpperCase();
+ let i = 0;
+ while (i < sql.length) {
+ const ch = sql[i];
+ if (inQuote) {
+ current += ch;
+ if (ch === quoteChar && sql[i - 1] !== '\\') { inQuote = false; }
+ i++;
+ continue;
+ }
+ if (ch === '"' || ch === "'") { inQuote = true; quoteChar = ch; current += ch; i++; continue; }
+ if (ch === '(') { depth++; current += ch; i++; continue; }
+ if (ch === ')') { depth--; current += ch; i++; continue; }
+ if (depth === 0 && upper.substring(i).startsWith('UNION ALL')) {
+ parts.push(current.trim());
+ current = '';
+ i += 'UNION ALL'.length;
+ continue;
+ }
+ current += ch;
+ i++;
+ }
+ if (current.trim()) parts.push(current.trim());
+ return parts.join('\nUNION ALL\n');
+}
+
+function formatSubquery(sql: string): string {
+ return sql.replace(/\(\s*(SELECT\b)/gi, '(\n $1');
+}
+
+function formatDdlOutput(ddl: string): string {
+ let s = ddl.replace(/\s+/g, ' ').trim();
+ s = formatColumnDefs(s);
+ s = s.replace(/\)\s+(COMMENT\s)/i, ')\n$1');
+ s = s.replace(/\)\s+(DISTRIBUTED\b)/i, ')\n$1');
+ s = s.replace(/\)\s+(REFRESH\b)/i, ')\n$1');
+ s = s.replace(/\)\s+(PROPERTIES\s*\()/i, ')\n$1');
+ s = s.replace(/\)\s+(AS\s+SELECT\b)/i, ')\n$1');
+ s = s.replace(/"\s+(DISTRIBUTED\b)/g, '"\n$1');
+ s = s.replace(/"\s+(REFRESH\b)/g, '"\n$1');
+ s = s.replace(/"\s+(PROPERTIES\s*\()/g, '"\n$1');
+ s = s.replace(/"\s+(AS\s+SELECT\b)/g, '"\n$1');
+ s = s.replace(
+ /PROPERTIES\s*\(([^)]+)\)/i,
+ (_m: string, inner: string) => {
+ const items = splitProperties(inner);
+ return 'PROPERTIES (\n ' + items.join(',\n ') + '\n)';
+ }
+ );
+ s = s.replace(
+ /\bAS\s+SELECT\b(.+)/is,
+ (_m: string, rest: string) => {
+ if (hasTopLevelUnionAll(rest)) {
+ return 'AS\n' + formatUnionSelect(rest);
+ }
+ let formatted = rest
+ .replace(/\s+FROM\b/gi, '\nFROM')
+ .replace(/\s+WHERE\b/gi, '\nWHERE')
+ .replace(/\s+GROUP\s+BY\b/gi, '\nGROUP BY')
+ .replace(/\s+HAVING\b/gi, '\nHAVING')
+ .replace(/\s+ORDER\s+BY\b/gi, '\nORDER BY')
+ .replace(/\s+LIMIT\b/gi, '\nLIMIT');
+ formatted = 'AS\nSELECT' + splitSelectColumns(formatted);
+ return formatSubquery(formatted);
+ }
+ );
+ return s;
+}
+
+export interface MaterializedViewDesign {
+ viewName: string;
+ database: string;
+ mode: 'create' | 'alter';
+ ddl: string;
+ originalDDL: string;
+ refreshType: string;
+ activeStatus?: string;
+ originalActiveStatus?: string;
+ originalView?: MaterializedViewInfo;
+}
+
+interface DesignerMessage {
+ command: string;
+ data?: MaterializedViewDesign;
+ viewName?: string;
+ sql?: string;
+}
+
+export class MaterializedViewDesignerPanel extends BaseWebviewPanel {
+ public static readonly viewType = 'sqlAllInOneMaterializedViewDesigner';
+
+ protected readonly panelConfig: WebviewPanelConfig = {
+ viewType: MaterializedViewDesignerPanel.viewType,
+ htmlFileName: 'materialized-view-designer.html',
+ cssFileName: 'materialized-view-designer.css',
+ jsFileName: 'materialized-view-designer.js',
+ };
+
+ private _mode: 'create' | 'alter' = 'create';
+ private _database = '';
+ private _viewName = '';
+ private _refreshType = 'ASYNC';
+ private readonly _connectionService: IConnectionService;
+ private readonly _schemaService: ISchemaService;
+
+ public static createOrShow(
+ extensionUri: vscode.Uri,
+ _context: vscode.ExtensionContext,
+ connectionService: IConnectionService,
+ schemaService: ISchemaService,
+ ): MaterializedViewDesignerPanel {
+ const column = vscode.window.activeTextEditor
+ ? vscode.window.activeTextEditor.viewColumn
+ : undefined;
+
+ if (MaterializedViewDesignerPanel.revealExisting(MaterializedViewDesignerPanel.viewType, column)) {
+ return MaterializedViewDesignerPanel.getExistingInstance(
+ MaterializedViewDesignerPanel.viewType,
+ )!;
+ }
+
+ const panel = MaterializedViewDesignerPanel.createWebviewPanel(
+ MaterializedViewDesignerPanel.viewType,
+ 'Materialized View Designer',
+ extensionUri,
+ );
+
+ const instance = new MaterializedViewDesignerPanel(panel, extensionUri, connectionService, schemaService);
+ MaterializedViewDesignerPanel.registerInstance(instance);
+ return instance;
+ }
+
+ private constructor(
+ panel: vscode.WebviewPanel,
+ extensionUri: vscode.Uri,
+ connectionService: IConnectionService,
+ schemaService: ISchemaService,
+ ) {
+ super(panel, extensionUri);
+ this._connectionService = connectionService;
+ this._schemaService = schemaService;
+ this._initialize();
+ }
+
+ private async _initialize(): Promise {
+ this._disposables.push(
+ vscode.window.onDidChangeActiveColorTheme(async (theme) => {
+ this._panel.webview.postMessage({
+ type: 'themeChange',
+ data: { kind: theme.kind, tokenColors: await getTokenColors() },
+ });
+ })
+ );
+
+ const monacoLoaderUri = this._panel.webview.asWebviewUri(
+ vscode.Uri.joinPath(this._extensionUri, 'media', 'monaco', 'vs', 'loader.js')
+ );
+ const monacoBaseUri = this._panel.webview.asWebviewUri(
+ vscode.Uri.joinPath(this._extensionUri, 'media', 'monaco', 'vs')
+ );
+
+ const configData = {
+ mode: this._mode,
+ database: this._database,
+ viewName: this._viewName,
+ monacoBasePath: monacoBaseUri.toString(),
+ themeKind: vscode.window.activeColorTheme.kind,
+ tokenColors: await getTokenColors(),
+ lang: getLanguage(),
+ };
+ const configJson = JSON.stringify(configData).replace(/<\/script>/gi, '<\\/script>');
+ const configScript = '';
+
+ await this.initializeHtml([
+ { placeholder: '{{MONACO_LOADER_URI}}', value: monacoLoaderUri.toString() },
+ { placeholder: '{{CONFIG_INJECT}}', value: configScript },
+ ]);
+
+ this._setupMessageHandling();
+ }
+
+ public async openForCreate(database: string): Promise {
+ this._mode = 'create';
+ this._database = database;
+ this._viewName = '';
+ this._refreshType = 'ASYNC';
+
+ const activeConn = this._connectionService.getActiveConnection();
+ if (!activeConn) {
+ vscode.window.showErrorMessage('No active connection');
+ return;
+ }
+
+ const adapter = this._connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ vscode.window.showErrorMessage('No adapter found for connection');
+ return;
+ }
+
+ const defaultDDL = `CREATE MATERIALIZED VIEW \`\`\nAS\nSELECT *\nFROM ;`;
+
+ this._panel.title = `New Materialized View - ${database}`;
+ this._panel.webview.postMessage({
+ type: 'materializedViewStructure',
+ data: {
+ viewName: '',
+ database,
+ mode: 'create',
+ ddl: defaultDDL,
+ originalDDL: '',
+ refreshType: 'ASYNC',
+ activeStatus: 'ACTIVE',
+ },
+ });
+ }
+
+ public async openForEdit(database: string, viewName: string): Promise {
+ this._mode = 'alter';
+ this._database = database;
+ this._viewName = viewName;
+
+ const activeConn = this._connectionService.getActiveConnection();
+ if (!activeConn) {
+ vscode.window.showErrorMessage('No active connection');
+ return;
+ }
+
+ const adapter = this._connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ vscode.window.showErrorMessage('No adapter found for connection');
+ return;
+ }
+
+ try {
+ const rawDdl = await adapter.schemaAdapter.getMaterializedViewDDL(database, viewName);
+ const ddl = formatDdlOutput(rawDdl);
+
+ const refreshMatch = ddl.match(/REFRESH\s+(ASYNC|SYNC|MANUAL)/i);
+ this._refreshType = refreshMatch ? refreshMatch[1].toUpperCase() : 'ASYNC';
+
+ let activeStatus = 'ACTIVE';
+ try {
+ const mvResult = await adapter.queryAdapter.execute(
+ `SHOW MATERIALIZED VIEWS FROM \`${database}\``
+ );
+ if (mvResult.status === 'success' && mvResult.rows.length > 0) {
+ const row = mvResult.rows.find((r: Record) => {
+ return String(r.name || r.Name || r.TABLE_NAME || '').toLowerCase() === viewName.toLowerCase();
+ });
+ if (row) {
+ const isActive = row.is_active ?? row.active ?? row.IS_ACTIVE;
+ if (isActive !== undefined && isActive !== null) {
+ const activeBool = isActive === true || isActive === 'true' || isActive === 'TRUE' || isActive === 1 || isActive === '1';
+ if (!activeBool) {
+ activeStatus = 'INACTIVE';
+ }
+ }
+ }
+ }
+ } catch {
+ // ignore query failures; default to ACTIVE
+ }
+
+ this._panel.title = `Edit Materialized View - ${viewName}`;
+ this._panel.webview.postMessage({
+ type: 'materializedViewStructure',
+ data: {
+ viewName,
+ database,
+ mode: 'alter',
+ ddl,
+ originalDDL: ddl,
+ refreshType: this._refreshType,
+ activeStatus,
+ },
+ });
+ } catch (error) {
+ handleError(error, 'openForEdit', ErrorCategory.SUB_ITEM);
+ vscode.window.showErrorMessage(`Failed to load materialized view: ${error}`);
+ }
+ }
+
+ private _setupMessageHandling(): void {
+ this._panel.webview.onDidReceiveMessage(
+ async (message: DesignerMessage) => {
+ try {
+ switch (message.command) {
+ case 'save':
+ if (message.data) {
+ await this._handleSave(message.data);
+ }
+ break;
+ case 'refresh':
+ if (message.viewName) {
+ await this._handleRefresh(message.viewName);
+ }
+ break;
+ case 'exportSql':
+ if (message.sql) {
+ await this._handleExportSql(message.sql);
+ }
+ break;
+ case 'close':
+ this.dispose();
+ break;
+ case 'ready':
+ if (this._mode === 'create') {
+ await this.openForCreate(this._database);
+ } else {
+ await this.openForEdit(this._database, this._viewName);
+ }
+ break;
+ }
+ } catch (error) {
+ handleError(error, 'setupMessageHandling', ErrorCategory.FEATURE);
+ this._panel.webview.postMessage({
+ type: 'error',
+ message: `Operation failed: ${error}`,
+ });
+ }
+ },
+ null,
+ this._disposables,
+ );
+ }
+
+ private async _handleSave(data: MaterializedViewDesign): Promise {
+ const validationError = this._validateDesign(data);
+ if (validationError) {
+ this._panel.webview.postMessage({
+ type: 'queryError',
+ data: { message: validationError },
+ });
+ return;
+ }
+
+ try {
+ const activeConn = this._connectionService.getActiveConnection();
+ if (!activeConn) {
+ throw new Error('No active connection');
+ }
+
+ const adapter = this._connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ throw new Error('No adapter found for connection');
+ }
+
+ const hasDdlChanges = data.mode === 'create' || data.ddl !== data.originalDDL;
+ const hasStatusChanges = data.mode === 'alter' && data.activeStatus !== data.originalActiveStatus;
+
+ if (!hasDdlChanges && !hasStatusChanges) {
+ this._panel.webview.postMessage({
+ type: 'querySuccess',
+ data: { message: 'No changes to save' },
+ });
+ return;
+ }
+
+ const statements: string[] = [];
+
+ if (hasDdlChanges) {
+ if (data.mode === 'create') {
+ statements.push(data.ddl);
+ } else {
+ const tempViewName = `${data.viewName}_tmp_${Date.now()}`;
+ let tempDDL = data.ddl.replace(
+ new RegExp(`CREATE\\s+MATERIALIZED\\s+VIEW\\s+\`?${data.viewName}\`?`, 'i'),
+ `CREATE MATERIALIZED VIEW \`${tempViewName}\``
+ );
+ statements.push(tempDDL);
+ statements.push(`ALTER MATERIALIZED VIEW \`${data.viewName}\` SWAP WITH \`${tempViewName}\``);
+ statements.push(`DROP MATERIALIZED VIEW IF EXISTS \`${tempViewName}\``);
+ }
+ }
+
+ if (hasStatusChanges) {
+ const statusCmd = data.activeStatus === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE';
+ statements.push(`ALTER MATERIALIZED VIEW \`${data.viewName}\` ${statusCmd}`);
+ }
+
+ const sqlToExecute = statements.join(';\n');
+
+ const confirmed = await vscode.window.showWarningMessage(
+ `Confirm execution of the following SQL?\n\n${sqlToExecute}`,
+ { modal: true },
+ 'Execute',
+ 'Cancel',
+ );
+
+ if (confirmed !== 'Execute') {
+ return;
+ }
+
+ const execStatements = sqlToExecute.split(';').map(s => s.trim()).filter(s => s);
+
+ this._panel.webview.postMessage({
+ type: 'queryStart',
+ data: { sql: execStatements[0] || '' }
+ });
+
+ let allSuccess = true;
+ let lastError = '';
+ for (let i = 0; i < execStatements.length; i++) {
+ const stmt = execStatements[i];
+ const result = await adapter.queryAdapter.execute(stmt);
+ if (result.status === 'error' && result.error) {
+ allSuccess = false;
+ lastError = result.error.message || 'Unknown error';
+ break;
+ }
+ }
+
+ if (allSuccess) {
+ this._panel.webview.postMessage({
+ type: 'querySuccess',
+ data: { message: 'Query executed successfully' }
+ });
+ } else {
+ this._panel.webview.postMessage({
+ type: 'queryError',
+ data: { message: lastError }
+ });
+ }
+
+ if (allSuccess) {
+ this._schemaService.invalidate(activeConn.id, 'materializedView', this._database);
+ }
+
+ if (data.mode === 'alter' && allSuccess) {
+ const newDDL = await adapter.schemaAdapter.getMaterializedViewDDL(data.database, data.viewName);
+ const formattedDDL = formatDdlOutput(newDDL);
+ this._panel.webview.postMessage({
+ type: 'updateOriginalDDL',
+ data: { originalDDL: formattedDDL, originalActiveStatus: data.activeStatus },
+ });
+ }
+ } catch (error: any) {
+ handleError(error, 'handleSave', ErrorCategory.SUB_ITEM);
+ const errorMsg = error?.message || String(error);
+ this._panel.webview.postMessage({
+ type: 'queryError',
+ data: { message: errorMsg },
+ });
+ }
+ }
+
+ private async _handleRefresh(viewName: string): Promise {
+ try {
+ const activeConn = this._connectionService.getActiveConnection();
+ if (!activeConn) {
+ throw new Error('No active connection');
+ }
+
+ const adapter = this._connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ throw new Error('No adapter found for connection');
+ }
+
+ const sql = `REFRESH MATERIALIZED VIEW \`${this._database}\`.\`${viewName}\``;
+ await adapter.queryAdapter.execute(sql);
+
+ vscode.window.showInformationMessage(`Materialized view ${viewName} refresh initiated`);
+ } catch (error) {
+ handleError(error, 'handleRefresh', ErrorCategory.SUB_ITEM);
+ vscode.window.showErrorMessage(`Failed to refresh materialized view: ${error}`);
+ }
+ }
+
+ private async _handleExportSql(sql: string): Promise {
+ const document = await vscode.workspace.openTextDocument({
+ content: sql,
+ language: 'sql',
+ });
+ await vscode.window.showTextDocument(document);
+ }
+
+ private _validateDesign(data: MaterializedViewDesign): string | null {
+ if (!data.viewName || data.viewName.trim() === '') {
+ return 'View name is required';
+ }
+
+ if (!data.ddl || data.ddl.trim() === '') {
+ return 'DDL is required';
+ }
+
+ return null;
+ }
+
+ public override dispose(): void {
+ MaterializedViewDesignerPanel.unregisterInstance(MaterializedViewDesignerPanel.viewType);
+ super.dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/views/materializedViewDesigner/materializedViewDesignerCommands.ts b/src/views/materializedViewDesigner/materializedViewDesignerCommands.ts
new file mode 100644
index 0000000..00fa4e2
--- /dev/null
+++ b/src/views/materializedViewDesigner/materializedViewDesignerCommands.ts
@@ -0,0 +1,212 @@
+import * as vscode from 'vscode';
+import type { IConnectionService, ISchemaService } from '../../application/ports';
+import { MaterializedViewDesignerPanel } from './MaterializedViewDesignerPanel';
+import { handleError, ErrorCategory } from '../../core/errorHandler';
+import type { MaterializedViewTreeNode } from '../databaseExplorer/treeNodes';
+
+export function registerMaterializedViewDesignerCommands(
+ context: vscode.ExtensionContext,
+ connectionService: IConnectionService,
+ schemaService: ISchemaService,
+): vscode.Disposable[] {
+ const disposables: vscode.Disposable[] = [];
+
+ disposables.push(
+ vscode.commands.registerCommand(
+ 'sqlAllInOne.createMaterializedView',
+ async (node?: MaterializedViewTreeNode) => {
+ try {
+ const database = node?.databaseName ?? (await askForDatabase(connectionService));
+ if (!database) {
+ return;
+ }
+
+ const panel = MaterializedViewDesignerPanel.createOrShow(
+ context.extensionUri,
+ context,
+ connectionService,
+ schemaService,
+ );
+ await panel.openForCreate(database);
+ } catch (error) {
+ handleError(error, 'createMaterializedView', ErrorCategory.FEATURE);
+ }
+ },
+ ),
+ );
+
+ disposables.push(
+ vscode.commands.registerCommand(
+ 'sqlAllInOne.editMaterializedView',
+ async (node?: MaterializedViewTreeNode) => {
+ try {
+ if (!node) {
+ vscode.window.showErrorMessage('No materialized view selected');
+ return;
+ }
+
+ const panel = MaterializedViewDesignerPanel.createOrShow(
+ context.extensionUri,
+ context,
+ connectionService,
+ schemaService,
+ );
+ await panel.openForEdit(node.databaseName, node.mvName);
+ } catch (error) {
+ handleError(error, 'editMaterializedView', ErrorCategory.FEATURE);
+ }
+ },
+ ),
+ );
+
+ disposables.push(
+ vscode.commands.registerCommand(
+ 'sqlAllInOne.dropMaterializedView',
+ async (node?: MaterializedViewTreeNode) => {
+ try {
+ if (!node) {
+ vscode.window.showErrorMessage('No materialized view selected');
+ return;
+ }
+
+ const confirmed = await vscode.window.showWarningMessage(
+ `Are you sure you want to drop materialized view "${node.mvName}"?`,
+ { modal: true },
+ 'Drop',
+ 'Cancel',
+ );
+
+ if (confirmed !== 'Drop') {
+ return;
+ }
+
+ const activeConn = connectionService.getActiveConnection();
+ if (!activeConn) {
+ vscode.window.showErrorMessage('No active connection');
+ return;
+ }
+
+ const adapter = connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ vscode.window.showErrorMessage('No adapter found for connection');
+ return;
+ }
+
+ const sql = `DROP MATERIALIZED VIEW \`${node.databaseName}\`.\`${node.mvName}\``;
+ await adapter.queryAdapter.execute(sql);
+
+ schemaService.invalidate(activeConn.id, 'materializedView', node.databaseName);
+ vscode.window.showInformationMessage(`Materialized view "${node.mvName}" dropped successfully`);
+ } catch (error) {
+ handleError(error, 'dropMaterializedView', ErrorCategory.SUB_ITEM);
+ vscode.window.showErrorMessage(`Failed to drop materialized view: ${error}`);
+ }
+ },
+ ),
+ );
+
+ disposables.push(
+ vscode.commands.registerCommand(
+ 'sqlAllInOne.refreshMaterializedView',
+ async (node?: MaterializedViewTreeNode) => {
+ try {
+ if (!node) {
+ vscode.window.showErrorMessage('No materialized view selected');
+ return;
+ }
+
+ const activeConn = connectionService.getActiveConnection();
+ if (!activeConn) {
+ vscode.window.showErrorMessage('No active connection');
+ return;
+ }
+
+ const adapter = connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ vscode.window.showErrorMessage('No adapter found for connection');
+ return;
+ }
+
+ const sql = `REFRESH MATERIALIZED VIEW \`${node.databaseName}\`.\`${node.mvName}\``;
+ await adapter.queryAdapter.execute(sql);
+
+ vscode.window.showInformationMessage(`Materialized view "${node.mvName}" refresh initiated`);
+ } catch (error) {
+ handleError(error, 'refreshMaterializedView', ErrorCategory.SUB_ITEM);
+ vscode.window.showErrorMessage(`Failed to refresh materialized view: ${error}`);
+ }
+ },
+ ),
+ );
+
+ disposables.push(
+ vscode.commands.registerCommand(
+ 'sqlAllInOne.viewMaterializedViewDDL',
+ async (node?: MaterializedViewTreeNode) => {
+ try {
+ if (!node) {
+ vscode.window.showErrorMessage('No materialized view selected');
+ return;
+ }
+
+ const activeConn = connectionService.getActiveConnection();
+ if (!activeConn) {
+ vscode.window.showErrorMessage('No active connection');
+ return;
+ }
+
+ const adapter = connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ vscode.window.showErrorMessage('No adapter found for connection');
+ return;
+ }
+
+ const ddl = await adapter.schemaAdapter.getMaterializedViewDDL(
+ node.databaseName,
+ node.mvName,
+ );
+
+ const document = await vscode.workspace.openTextDocument({
+ content: ddl,
+ language: 'sql',
+ });
+ await vscode.window.showTextDocument(document);
+ } catch (error) {
+ handleError(error, 'viewMaterializedViewDDL', ErrorCategory.SUB_ITEM);
+ vscode.window.showErrorMessage(`Failed to get materialized view DDL: ${error}`);
+ }
+ },
+ ),
+ );
+
+ return disposables;
+}
+
+async function askForDatabase(connectionService: IConnectionService): Promise {
+ const activeConn = connectionService.getActiveConnection();
+ if (!activeConn) {
+ vscode.window.showErrorMessage('No active connection');
+ return undefined;
+ }
+
+ const adapter = connectionService.getAdapter(activeConn.id);
+ if (!adapter) {
+ vscode.window.showErrorMessage('No adapter found for connection');
+ return undefined;
+ }
+
+ const databases = await adapter.metadataAdapter.listDatabases();
+ if (databases.length === 0) {
+ vscode.window.showErrorMessage('No databases found');
+ return undefined;
+ }
+
+ if (databases.length === 1) {
+ return databases[0].name;
+ }
+
+ return vscode.window.showQuickPick(
+ databases.map((db) => db.name),
+ { placeHolder: 'Select database' },
+ );
+}
\ No newline at end of file
diff --git a/src/views/viewsCommands.ts b/src/views/viewsCommands.ts
index ef73284..8f252a4 100644
--- a/src/views/viewsCommands.ts
+++ b/src/views/viewsCommands.ts
@@ -3,9 +3,10 @@ import { ExplainPlanPanel } from './explainPlan/ExplainPlanPanel';
import { DataTransferDialog } from './dataTransfer/DataTransferDialog';
import { registerQueryResultCommands } from './queryResult/queryResultCommands';
import { registerTableDesignerCommands } from './tableDesigner/tableDesignerCommands';
+import { registerMaterializedViewDesignerCommands } from './materializedViewDesigner/materializedViewDesignerCommands';
import { registerTreeProviderCommands } from './databaseExplorer/treeProviderCommands';
import { getContainer, Tokens } from '../core/diContainer';
-import type { IConnectionService, IDataTransferService, IExplainPlanService } from '../application/ports';
+import type { IConnectionService, IDataTransferService, IExplainPlanService, ISchemaService } from '../application/ports';
/**
* Register every views-layer command handler that the database layer
@@ -47,6 +48,14 @@ export function registerViewsCommands(context: vscode.ExtensionContext): vscode.
// 3. Table designer handler (openTableDesigner).
disposables.push(...registerTableDesignerCommands(context));
+ // 3.5 Materialized view designer handler.
+ const container = getContainer();
+ disposables.push(...registerMaterializedViewDesignerCommands(
+ context,
+ container.get(Tokens.ConnectionService),
+ container.get(Tokens.SchemaService),
+ ));
+
// 4. Explain-plan handler. The database layer fires
// `hive-formatter.showExplainPlan(sql, isPanel?)` from
// SchemaCommands.explainQuery; the views layer owns the panel.