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}} + + + +
+
+
+ 📊 + Materialized View Designer +
+
+ + + + + +
+
+ +
+
+ View Name + +
+
+ Active + +
+
+ Refresh Type + ASYNC +
+
+ Refresh Schedule + - +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + +
+ + + + 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.