diff --git a/MARKETPLACE.md b/MARKETPLACE.md index a202118..1890201 100644 --- a/MARKETPLACE.md +++ b/MARKETPLACE.md @@ -12,21 +12,19 @@ ## ๐Ÿ“ธ Screenshots -### ๐Ÿ“Š Real-Time Database Dashboard -![Dashboard](resources/screenshots/pg-exp-dash.png) -*Monitor connections, queries, and performance metrics in real-time* +### ๐ŸŽฅ Video Guides -### ๐Ÿ”— Connection Management -![Connection Management](resources/screenshots/pg-exp-connection.png) -*Manage multiple database connections with an intuitive interface* +#### 1. Quick Setup +![PgStudio Setup](docs/assets/01-setup.gif) -### ๐Ÿ““ Interactive SQL Notebooks -![SQL Notebooks](resources/screenshots/pg-exp-view.png) -*Write and execute queries with rich output formatting* +#### 2. Database Explorer +![Database Explorer](docs/assets/03-explorer.gif) -### ๐Ÿ› ๏ธ Object Creation -![Object Creation](resources/screenshots/pg-exp-create.png) -*Create database objects with intelligent templates* +#### 3. AI Assistant Configuration +![AI Assistant Setup](docs/assets/02-ai-assist-setup.gif) + +#### 4. AI Assistant In-Action +![AI Assistant Usage](docs/assets/04-ai-assist.gif) --- diff --git a/README.md b/README.md index a1cd8b4..22a5aa7 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,19 @@ --- -## ๐Ÿ“ธ Preview +## ๐Ÿ“บ Video Guides -![Dashboard](resources/screenshots/pg-exp-dash.png) +### 1. Setup +![PgStudio Setup](docs/assets/01-setup.gif) + +### 2. Database Explorer +![Database Explorer](docs/assets/03-explorer.gif) + +### 3. AI Assistant Setup +![AI Assistant Setup](docs/assets/02-ai-assist-setup.gif) + +### 4. AI Assistant Usage +![AI Assistant Usage](docs/assets/04-ai-assist.gif) --- diff --git a/docs/assets/01-setup.gif b/docs/assets/01-setup.gif new file mode 100644 index 0000000..7cd8e4f Binary files /dev/null and b/docs/assets/01-setup.gif differ diff --git a/docs/assets/02-ai-assist-setup.gif b/docs/assets/02-ai-assist-setup.gif new file mode 100644 index 0000000..0f4eb36 Binary files /dev/null and b/docs/assets/02-ai-assist-setup.gif differ diff --git a/docs/assets/03-explorer.gif b/docs/assets/03-explorer.gif new file mode 100644 index 0000000..7b0fbf4 Binary files /dev/null and b/docs/assets/03-explorer.gif differ diff --git a/docs/assets/04-ai-assist.gif b/docs/assets/04-ai-assist.gif new file mode 100644 index 0000000..061b800 Binary files /dev/null and b/docs/assets/04-ai-assist.gif differ diff --git a/docs/index.html b/docs/index.html index 49bff41..d9e14d9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -27,11 +27,12 @@ 🐘 PgStudio - Professional Database Management + Professional Database Management - + + @@ -64,8 +68,8 @@

Powerful PostgreSQL
Management Inside VS Code class="btn btn-primary"> โฌ‡๏ธ Install Now - - โœจ Explore Features + + โœจ Get started @@ -355,6 +359,60 @@

Schema Understanding

+ +
+
+

Video Guides

+

See PgStudio in action with these step-by-step guides

+ + + + +
+
+
@@ -388,6 +446,15 @@

Install

+ + + diff --git a/docs/script.js b/docs/script.js index bf5f403..b3decb7 100644 --- a/docs/script.js +++ b/docs/script.js @@ -5,14 +5,14 @@ const htmlElement = document.documentElement // Load theme preference const savedTheme = localStorage.getItem("theme") || "dark" if (savedTheme === "light") { - htmlElement.classList.add("light-mode") + htmlElement.classList.add("light-mode") } // Toggle theme themeToggle.addEventListener("click", () => { - htmlElement.classList.toggle("light-mode") - const isLight = htmlElement.classList.contains("light-mode") - localStorage.setItem("theme", isLight ? "light" : "dark") + htmlElement.classList.toggle("light-mode") + const isLight = htmlElement.classList.contains("light-mode") + localStorage.setItem("theme", isLight ? "light" : "dark") }) // Mobile menu (optional) @@ -20,7 +20,250 @@ const mobileMenuBtn = document.querySelector(".mobile-menu-btn") const navLinks = document.querySelector(".nav-links") if (mobileMenuBtn) { - mobileMenuBtn.addEventListener("click", () => { - navLinks.style.display = navLinks.style.display === "flex" ? "none" : "flex" - }) + mobileMenuBtn.addEventListener("click", () => { + navLinks.style.display = navLinks.style.display === "flex" ? "none" : "flex" + }) } + +// Video Carousel Logic (Infinite Loop) +document.addEventListener("DOMContentLoaded", () => { + const carousel = document.querySelector(".video-carousel") + const prevBtn = document.querySelector(".prev-btn") + const nextBtn = document.querySelector(".next-btn") + const indicators = document.querySelectorAll(".indicator") + const items = document.querySelectorAll(".carousel-item") + + if (!carousel || items.length === 0) return + + // Clone first and last items for infinite loop + const firstClone = items[0].cloneNode(true) + const lastClone = items[items.length - 1].cloneNode(true) + + // Add clones + carousel.appendChild(firstClone) + carousel.insertBefore(lastClone, items[0]) + + const totalItems = items.length + let currentIndex = 1 // Start at 1 (first real item) because of prepended clone + let isTransitioning = false + + // Initial scroll to first real item (skip clone) + const scrollToRealFirst = () => { + carousel.style.scrollBehavior = "auto" + carousel.scrollLeft = carousel.offsetWidth + carousel.style.scrollBehavior = "smooth" + } + // Wait for layout + setTimeout(scrollToRealFirst, 50) + + const updateIndicators = (realIndex) => { + indicators.forEach((ind, i) => { + if (i === realIndex) ind.classList.add("active") + else ind.classList.remove("active") + }) + } + + const scrollCarousel = (index, smooth = true) => { + if (isTransitioning) return + isTransitioning = true + + carousel.style.scrollBehavior = smooth ? "smooth" : "auto" + carousel.scrollLeft = index * carousel.offsetWidth + currentIndex = index + + // Update indicators immediately for better UX (calculating real index) + let realIndex = currentIndex - 1 + if (currentIndex === 0) realIndex = totalItems - 1 + if (currentIndex === totalItems + 1) realIndex = 0 + updateIndicators(realIndex) + + setTimeout(() => { isTransitioning = false }, 500) + } + + // Button Listeners + if (nextBtn) { + nextBtn.addEventListener("click", () => { + if (currentIndex >= totalItems + 1) return + scrollCarousel(currentIndex + 1) + }) + } + + if (prevBtn) { + prevBtn.addEventListener("click", () => { + if (currentIndex <= 0) return + scrollCarousel(currentIndex - 1) + }) + } + + // Infinite Loop Logic on Scroll End + carousel.addEventListener("scroll", () => { + if (isTransitioning) return // Ignore scroll events during button transition + + const scrollLeft = carousel.scrollLeft + const width = carousel.offsetWidth + const index = Math.round(scrollLeft / width) + + // If manual scroll changed index, update it + if (index !== currentIndex) { + currentIndex = index + let realIndex = currentIndex - 1 + if (realIndex < 0) realIndex = totalItems - 1 + if (realIndex >= totalItems) realIndex = 0 + updateIndicators(realIndex) + } + + // Silent Loop Jumps + clearTimeout(carousel.scrollTimeout) + carousel.scrollTimeout = setTimeout(() => { + if (currentIndex === 0) { // At Last Clone -> Jump to Real Last + carousel.style.scrollBehavior = "auto" + currentIndex = totalItems + carousel.scrollLeft = currentIndex * width + carousel.style.scrollBehavior = "smooth" + } + if (currentIndex === totalItems + 1) { // At First Clone -> Jump to Real First + carousel.style.scrollBehavior = "auto" + currentIndex = 1 + carousel.scrollLeft = currentIndex * width + carousel.style.scrollBehavior = "smooth" + } + }, 150) + }) + + // Indicator clicks + indicators.forEach((ind, i) => { + ind.addEventListener("click", () => { + scrollCarousel(i + 1) // +1 because 0 is a clone + }) + }) + + // Handle Resize + window.addEventListener("resize", () => { + carousel.style.scrollBehavior = "auto" + carousel.scrollLeft = currentIndex * carousel.offsetWidth + carousel.style.scrollBehavior = "smooth" + }) +}) + +// Media Modal Logic (handles both images and videos) +document.addEventListener("DOMContentLoaded", () => { + const modal = document.getElementById("video-modal") + const modalImg = modal.querySelector("img") + const modalVideo = modal.querySelector("video") + const closeBtn = document.querySelector(".close-modal") + const expandBtns = document.querySelectorAll(".expand-btn") + + if (!modal) return + + expandBtns.forEach(btn => { + btn.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + const videoWrapper = btn.closest(".video-wrapper") + + // Check if it's an image or video + const sourceImg = videoWrapper.querySelector("img") + const sourceVideo = videoWrapper.querySelector("video") + + if (sourceImg) { + // It's an image (GIF) + const sourceSrc = sourceImg.getAttribute("src") + modalImg.src = sourceSrc + modalImg.style.display = "block" + modalVideo.style.display = "none" + modal.style.display = "flex" + } else if (sourceVideo) { + // It's a video + const sourceSrc = sourceVideo.getAttribute("src") + modalVideo.src = sourceSrc + modalVideo.style.display = "block" + modalImg.style.display = "none" + modal.style.display = "flex" + // Slight delay to ensure display:flex renders before playing + requestAnimationFrame(() => { + modalVideo.play().catch(err => console.error("Auto-play failed:", err)) + }) + } + }) + }) + + const closeModal = () => { + modal.style.display = "none" + modalVideo.pause() + modalVideo.currentTime = 0 + modalVideo.src = "" // Clear src to stop buffering + modalImg.src = "" // Clear img src + } + + closeBtn.addEventListener("click", closeModal) + + // Close on outside click + window.addEventListener("click", (e) => { + if (e.target === modal) { + closeModal() + } + }) + + // Close on Escape key + window.addEventListener("keydown", (e) => { + if (e.key === "Escape" && modal.style.display === "flex") { + closeModal() + } + }) +}) + +// Fetch Marketplace Stats +const fetchMarketplaceStats = async () => { + try { + const response = await fetch("https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json;api-version=3.0-preview.1" + }, + body: JSON.stringify({ + filters: [{ + criteria: [{ + filterType: 7, + value: "ric-v.postgres-explorer" + }] + }], + flags: 914 // Include statistics, versions, etc. + }) + }) + + const data = await response.json() + const extension = data.results[0].extensions[0] + + if (extension) { + // Stats + const installCount = extension.statistics.find(s => s.statisticName === "install")?.value + const rating = extension.statistics.find(s => s.statisticName === "weightedRating")?.value + const version = extension.versions[0].version + + // DOM Elements + const downloadEl = document.getElementById("stat-downloads") + const ratingEl = document.getElementById("stat-rating") + const versionEl = document.getElementById("stat-version") + const badgeVersionEl = document.getElementById("badge-version") + + // Format numbers + const formatNumber = (num) => { + if (num >= 1000000) return (num / 1000000).toFixed(1) + "M" + if (num >= 1000) return (num / 1000).toFixed(1) + "K" + return num + } + + // Update UI + if (downloadEl) downloadEl.textContent = formatNumber(installCount) + if (ratingEl) ratingEl.textContent = rating ? rating.toFixed(1) : "5.0" + if (versionEl) versionEl.textContent = "v" + version + if (badgeVersionEl) badgeVersionEl.textContent = version + } + } catch (error) { + console.error("Failed to fetch marketplace stats:", error) + } +} + +// Init +fetchMarketplaceStats() diff --git a/docs/styles.css b/docs/styles.css index 2172d91..e0885d3 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -2326,4 +2326,266 @@ footer { font-size: 0.85rem !important; padding: 0.5rem 1rem !important; } +} + +/* Video Carousel */ +.carousel-wrapper { + position: relative; + max-width: 900px; + margin: 3rem auto 1rem; + display: flex; + align-items: center; +} + +.video-carousel { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + gap: 1rem; + padding: 1rem 0; + scrollbar-width: none; + /* Firefox */ +} + +.video-carousel::-webkit-scrollbar { + display: none; + /* Chrome, Safari, Opera */ +} + +.carousel-item { + flex: 0 0 100%; + scroll-snap-align: center; + background: var(--bg-primary); + border: 1px solid var(--glass-border); + border-radius: 12px; + padding: 1rem; + transition: transform 0.3s ease; +} + +.video-wrapper { + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + aspect-ratio: 16/9; +} + +.video-wrapper video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.carousel-btn { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + color: var(--text-primary); + font-size: 1.2rem; + cursor: pointer; + width: 48px; + /* Fixed size circle */ + height: 48px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 10; + position: absolute; + top: 50%; + transform: translateY(-50%); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.carousel-btn:hover { + background: rgba(0, 212, 255, 0.1); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); + transform: translateY(-50%) scale(1.1); +} + +.prev-btn { + left: -20px; + /* Overlap slightly */ +} + +.next-btn { + right: -20px; + /* Overlap slightly */ +} + +@media (max-width: 900px) { + .prev-btn { + left: 10px; + background: rgba(0, 0, 0, 0.5); + } + + .next-btn { + right: 10px; + background: rgba(0, 0, 0, 0.5); + } + + .carousel-btn { + display: flex; + } + + /* Show on mobile but overlay */ +} + +.carousel-indicators { + display: flex; + justify-content: center; + gap: 0.5rem; + margin-top: 1rem; +} + +.indicator { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--glass-border); + cursor: pointer; + transition: all 0.3s; +} + +.indicator.active { + background: var(--accent-cyan); + transform: scale(1.2); +} + +/* Video Expand Button */ +.video-wrapper { + position: relative; +} + +.expand-btn { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + border: none; + border-radius: 4px; + width: 32px; + height: 32px; + font-size: 1.2rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.3s ease, background 0.3s ease; +} + +.video-wrapper:hover .expand-btn { + opacity: 1; +} + +.expand-btn:hover { + background: var(--accent-cyan); +} + +/* Video Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.9); + backdrop-filter: blur(5px); + align-items: center; + justify-content: center; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.3s ease; +} + +.modal-content { + position: relative; + max-width: 90%; + max-height: 90%; + width: auto; + height: auto; + display: flex; + justify-content: center; +} + +.close-modal { + position: absolute; + top: 15px; + right: 35px; + color: #f1f1f1; + font-size: 40px; + font-weight: bold; + cursor: pointer; + transition: color 0.3s; + z-index: 1001; +} + +.close-modal:hover, +.close-modal:focus { + color: var(--accent-cyan); + text-decoration: none; + cursor: pointer; +} + +/* Optimized Mobile Topbar */ +.cta-icon { + display: none; + font-size: 1.2rem; +} + +@media (max-width: 768px) { + + /* Hide Logo Subtitle */ + .logo-subtitle { + display: none; + } + + /* Hide Navigation Links except CTA */ + .nav-links a:not(.nav-cta) { + display: none; + } + + /* Keep Nav Links container visible (override potential mobile menu logic) */ + .nav-links { + display: flex; + gap: 1rem; + } + + /* Optimize Install Button */ + .nav-cta { + padding: 0.4rem 0.6rem !important; + font-size: 0 !important; + /* Hide text */ + } + + .cta-text { + display: none; + } + + .cta-icon { + display: inline-block; + font-size: 1.2rem; + } + + /* Hide Hamburger (if not already hidden by inline style) */ + .mobile-menu-btn { + display: none !important; + } + + /* Adjust logo size */ + .logo { + font-size: 1.2rem; + } } \ No newline at end of file diff --git a/package.json b/package.json index 977bdfe..bafcd78 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PostgreSQL Explorer", - "version": "0.5.3", + "version": "0.6.0", "description": "PostgreSQL database explorer for VS Code with notebook support", "publisher": "ric-v", "private": false, @@ -285,6 +285,66 @@ "title": "Create Foreign Table", "icon": "$(add)" }, + { + "command": "postgres-explorer.foreignDataWrapperOperations", + "title": "FDW Operations" + }, + { + "command": "postgres-explorer.showForeignDataWrapperProperties", + "title": "Show FDW Properties", + "icon": "$(info)" + }, + { + "command": "postgres-explorer.refreshForeignDataWrapper", + "title": "Refresh FDW", + "icon": "$(refresh)" + }, + { + "command": "postgres-explorer.createForeignServer", + "title": "Create Foreign Server", + "icon": "$(add)" + }, + { + "command": "postgres-explorer.foreignServerOperations", + "title": "Server Operations" + }, + { + "command": "postgres-explorer.showForeignServerProperties", + "title": "Show Server Properties", + "icon": "$(info)" + }, + { + "command": "postgres-explorer.dropForeignServer", + "title": "Drop Foreign Server" + }, + { + "command": "postgres-explorer.refreshForeignServer", + "title": "Refresh Server", + "icon": "$(refresh)" + }, + { + "command": "postgres-explorer.createUserMapping", + "title": "Create User Mapping", + "icon": "$(add)" + }, + { + "command": "postgres-explorer.userMappingOperations", + "title": "User Mapping Operations" + }, + { + "command": "postgres-explorer.showUserMappingProperties", + "title": "Show User Mapping Properties", + "icon": "$(info)" + }, + { + "command": "postgres-explorer.dropUserMapping", + "title": "Drop User Mapping" + }, + { + "command": "postgres-explorer.refreshUserMapping", + "title": "Refresh User Mapping", + "icon": "$(refresh)" + }, { "command": "postgres-explorer.createRole", "title": "Create Role", @@ -1095,6 +1155,56 @@ "when": "view == postgresExplorer && viewItem == category-foreign-tables", "group": "inline" }, + { + "command": "postgres-explorer.createForeignServer", + "when": "view == postgresExplorer && viewItem == category-foreign-data-wrappers", + "group": "inline" + }, + { + "command": "postgres-explorer.foreignDataWrapperOperations", + "when": "view == postgresExplorer && viewItem == foreign-data-wrapper", + "group": "1_operations@0" + }, + { + "command": "postgres-explorer.showForeignDataWrapperProperties", + "when": "view == postgresExplorer && viewItem == foreign-data-wrapper", + "group": "inline@1" + }, + { + "command": "postgres-explorer.foreignServerOperations", + "when": "view == postgresExplorer && viewItem == foreign-server", + "group": "1_operations@0" + }, + { + "command": "postgres-explorer.showForeignServerProperties", + "when": "view == postgresExplorer && viewItem == foreign-server", + "group": "inline@1" + }, + { + "command": "postgres-explorer.createUserMapping", + "when": "view == postgresExplorer && viewItem == foreign-server", + "group": "1_operations@1" + }, + { + "command": "postgres-explorer.dropForeignServer", + "when": "view == postgresExplorer && viewItem == foreign-server", + "group": "9_delete" + }, + { + "command": "postgres-explorer.userMappingOperations", + "when": "view == postgresExplorer && viewItem == user-mapping", + "group": "1_operations@0" + }, + { + "command": "postgres-explorer.showUserMappingProperties", + "when": "view == postgresExplorer && viewItem == user-mapping", + "group": "inline@1" + }, + { + "command": "postgres-explorer.dropUserMapping", + "when": "view == postgresExplorer && viewItem == user-mapping", + "group": "9_delete" + }, { "command": "postgres-explorer.addColumn", "when": "view == postgresExplorer && viewItem == category-columns", diff --git a/resources/screenshots/pg-exp-connection.png b/resources/screenshots/pg-exp-connection.png deleted file mode 100644 index 9d15726..0000000 Binary files a/resources/screenshots/pg-exp-connection.png and /dev/null differ diff --git a/resources/screenshots/pg-exp-create.png b/resources/screenshots/pg-exp-create.png deleted file mode 100644 index 848e632..0000000 Binary files a/resources/screenshots/pg-exp-create.png and /dev/null differ diff --git a/resources/screenshots/pg-exp-dash.png b/resources/screenshots/pg-exp-dash.png deleted file mode 100644 index 3c0e783..0000000 Binary files a/resources/screenshots/pg-exp-dash.png and /dev/null differ diff --git a/resources/screenshots/pg-exp-view.png b/resources/screenshots/pg-exp-view.png deleted file mode 100644 index 384fff0..0000000 Binary files a/resources/screenshots/pg-exp-view.png and /dev/null differ diff --git a/src/commands/foreignDataWrappers.ts b/src/commands/foreignDataWrappers.ts new file mode 100644 index 0000000..9540091 --- /dev/null +++ b/src/commands/foreignDataWrappers.ts @@ -0,0 +1,624 @@ +import * as vscode from 'vscode'; + +import { DatabaseTreeItem, DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; +import { + MarkdownUtils, + ErrorHandlers, + getDatabaseConnection, + NotebookBuilder, + QueryBuilder, + validateCategoryItem +} from './helper'; +import { ForeignDataWrapperSQL } from './sql'; + + + +/** + * cmdForeignDataWrapperOperations - Command to create operations notebook for a Foreign Data Wrapper + * @param {DatabaseTreeItem} item - The selected FDW item in the database tree. + * @param {vscode.ExtensionContext} context - The extension context. + */ +export async function cmdForeignDataWrapperOperations(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + // FDW nodes don't have schema, use validateCategoryItem + const { connection, client, metadata } = await getDatabaseConnection(item, validateCategoryItem); + + try { + const fdwResult = await client.query(ForeignDataWrapperSQL.query.fdwDetails(item.label)); + const serversResult = await client.query(ForeignDataWrapperSQL.query.listServers(item.label)); + + const fdw = fdwResult.rows[0] || {}; + const servers = serversResult.rows; + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`๐Ÿ”Œ Foreign Data Wrapper Operations: \`${item.label}\``) + + MarkdownUtils.infoBox('This notebook contains operations for managing the Foreign Data Wrapper (FDW). Execute the cells below to perform operations.') + + `\n\n#### ๐Ÿ“Š FDW Information\n\n` + + MarkdownUtils.propertiesTable({ + 'FDW Name': fdw.fdw_name || item.label, + 'Owner': fdw.owner || 'N/A', + 'Handler Function': fdw.handler_function || 'N/A', + 'Validator Function': fdw.validator_function || 'N/A', + 'Servers Using This FDW': `${fdw.server_count || 0}` + }) + + `\n\n#### ๐ŸŽฏ Available Operations\n\n` + + MarkdownUtils.operationsTable([ + { operation: '๐Ÿ“ List Servers', description: 'Show all foreign servers using this FDW' }, + { operation: 'โž• Create Server', description: 'Create a new foreign server' }, + { operation: '๐Ÿ” View Details', description: 'Detailed FDW information and functions' }, + { operation: '๐Ÿ” Grant USAGE', description: 'Grant permissions to roles' }, + { operation: 'โŒ Drop FDW', description: 'Remove FDW (Warning: CASCADE required)' } + ]) + ) + .addMarkdown('##### ๐Ÿ“ List Foreign Servers') + .addSql(ForeignDataWrapperSQL.query.listServers(item.label)) + .addMarkdown('##### โž• Create New Foreign Server') + .addSql(ForeignDataWrapperSQL.create.server.basic(item.label)) + .addMarkdown('##### ๐Ÿ” FDW Details and Functions') + .addSql(ForeignDataWrapperSQL.query.fdwFunctions(item.label)) + .addMarkdown('##### ๐Ÿ” Grant USAGE Permission') + .addSql(ForeignDataWrapperSQL.grant.usageOnFDW(item.label, 'role_name')) + .addMarkdown('##### โŒ Drop Foreign Data Wrapper') + .addSql(ForeignDataWrapperSQL.drop.fdw(item.label)) + .show(); + } finally { + // Do not close shared client + } + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'create FDW operations notebook'); + } +} + +/** + * cmdShowForeignDataWrapperProperties - Show detailed properties of a Foreign Data Wrapper + */ +export async function cmdShowForeignDataWrapperProperties(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + // FDW nodes don't have schema, use validateCategoryItem + const { connection, client, metadata } = await getDatabaseConnection(item, validateCategoryItem); + + try { + const fdwResult = await client.query(ForeignDataWrapperSQL.query.fdwDetails(item.label)); + const serversResult = await client.query(ForeignDataWrapperSQL.query.listServers(item.label)); + + const fdw = fdwResult.rows[0] || {}; + const servers = serversResult.rows; + + // Build servers table HTML + const serverRows = servers.map((srv: any) => { + return ` + ${srv.server_name} + ${srv.owner} + ${srv.user_mapping_count || 0} + ${srv.foreign_table_count || 0} + `; + }).join('\n'); + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`๐Ÿ”Œ Foreign Data Wrapper Properties: \`${item.label}\``) + + MarkdownUtils.infoBox(`**Owner:** ${fdw.owner || 'N/A'} ${fdw.comment ? `| **Comment:** ${fdw.comment}` : ''}`) + + `\n\n#### ๐Ÿ’พ General Information\n\n` + + MarkdownUtils.propertiesTable({ + 'FDW Name': fdw.fdw_name || item.label, + 'Owner': fdw.owner || 'N/A', + 'Handler Function': fdw.handler_function || 'None', + 'Validator Function': fdw.validator_function || 'None', + 'Total Servers': `${fdw.server_count || 0}` + }) + + (servers.length > 0 ? `\n\n#### ๐Ÿ–ฅ๏ธ Foreign Servers (${servers.length})\n\n` + + ` + + + + + + +${serverRows} +
Server NameOwnerUser MappingsForeign Tables
\n\n` : '\n\n_No servers using this FDW_\n\n') + + '---' + ) + .addMarkdown('##### ๐Ÿ“‹ FDW Details Query') + .addSql(ForeignDataWrapperSQL.query.fdwDetails(item.label)) + .addMarkdown('##### ๐Ÿ” Handler and Validator Functions') + .addSql(ForeignDataWrapperSQL.query.fdwFunctions(item.label)) + .show(); + } finally { + // Do not close shared client + } + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'show FDW properties'); + } +} + +/** + * cmdCreateForeignServer - Command to create a new foreign server + */ +export async function cmdCreateForeignServer(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + // Use validateCategoryItem for category-level calls, not validateItem which requires schema + const { connection, client, metadata } = await getDatabaseConnection(item, validateCategoryItem); + + // Determine the FDW name from context + // If called from category-level (+button), type will be 'category' + // If called from individual FDW node, type will be 'foreign-data-wrapper' + const fdwName = item.type === 'foreign-data-wrapper' ? item.label : 'postgres_fdw'; + const titleSuffix = item.type === 'foreign-data-wrapper' ? ` for: \`${item.label}\`` : ''; + + const markdown = MarkdownUtils.header(`โž• Create New Foreign Server${titleSuffix}`) + + MarkdownUtils.infoBox('This notebook provides templates for creating foreign servers. Modify the templates below and execute to create servers.') + + `\n\n#### ๐Ÿ“‹ Foreign Server Design Guidelines\n\n` + + MarkdownUtils.operationsTable([ + { operation: 'Naming', description: 'Use descriptive names (e.g., prod_db_server, analytics_server, remote_mysql)' }, + { operation: 'Security', description: 'Always use SSL/TLS for production servers. Store credentials securely.' }, + { operation: 'Options', description: 'Configure connection options (host, port, dbname, fetch_size, timeouts)' }, + { operation: 'Testing', description: 'Test connection before creating user mappings and foreign tables' }, + { operation: 'Permissions', description: 'Grant USAGE permission to roles that need access to the server' } + ]) + + `\n\n#### ๐Ÿท๏ธ Common Server Patterns\n\n` + + MarkdownUtils.propertiesTable({ + 'PostgreSQL Remote': 'Connect to another PostgreSQL database (postgres_fdw)', + 'MySQL/MariaDB': 'Connect to MySQL or MariaDB database (mysql_fdw)', + 'File-based': 'Access CSV or other file data (file_fdw)', + 'MongoDB': 'Connect to MongoDB (mongo_fdw)', + 'Oracle': 'Connect to Oracle Database (oracle_fdw)', + 'Custom FDW': 'Use specialized FDW extensions' + }) + + MarkdownUtils.successBox('Foreign servers define connection parameters. You\'ll need to create USER MAPPING after creating the server to specify authentication credentials.') + + `\n\n---`; + + await new NotebookBuilder(metadata) + .addMarkdown(markdown) + .addMarkdown('##### ๐Ÿ“ Basic Foreign Server (Recommended Start)') + .addSql(ForeignDataWrapperSQL.create.server.basic(fdwName)) + .addMarkdown('##### ๐Ÿ˜ PostgreSQL Remote Server') + .addSql(ForeignDataWrapperSQL.create.server.postgres(fdwName)) + .addMarkdown('##### ๐Ÿฌ MySQL Server') + .addSql(ForeignDataWrapperSQL.create.server.mysql()) + .addMarkdown('##### ๐Ÿ“ File-based Server') + .addSql(ForeignDataWrapperSQL.create.server.file()) + .addMarkdown('##### ๐Ÿ” Server with SSL Authentication') + .addSql(ForeignDataWrapperSQL.create.server.withAuth(fdwName)) + .addMarkdown('##### โœ… Test Server Connection') + .addSql(ForeignDataWrapperSQL.test.connection('server_name')) + .addMarkdown(MarkdownUtils.warningBox('Remember to: 1) Replace placeholder values with actual connection details, 2) Use SSL/TLS for production, 3) Test the connection, 4) Create USER MAPPING for authentication, 5) Grant USAGE permission to appropriate roles.')) + .show(); + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'create foreign server notebook'); + } +} + +/** + * cmdForeignServerOperations - Command to create operations notebook for a foreign server + */ +export async function cmdForeignServerOperations(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + const { connection, client, metadata } = await getDatabaseConnection(item); + + try { + const serverResult = await client.query(ForeignDataWrapperSQL.query.serverDetails(item.label)); + const mappingsResult = await client.query(ForeignDataWrapperSQL.query.listUserMappings(item.label)); + const tablesResult = await client.query(ForeignDataWrapperSQL.query.foreignTablesByServer(item.label)); + + const server = serverResult.rows[0] || {}; + const mappings = mappingsResult.rows; + const tables = tablesResult.rows; + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`๐Ÿ–ฅ๏ธ Foreign Server Operations: \`${item.label}\``) + + MarkdownUtils.infoBox('This notebook contains operations for managing the foreign server. Execute the cells below to perform operations.') + + `\n\n#### ๐Ÿ“Š Server Information\n\n` + + MarkdownUtils.propertiesTable({ + 'Server Name': server.server_name || item.label, + 'FDW': server.fdw_name || 'N/A', + 'Owner': server.owner || 'N/A', + 'User Mappings': `${mappings.length}`, + 'Foreign Tables': `${tables.length}` + }) + + `\n\n#### ๐ŸŽฏ Available Operations\n\n` + + MarkdownUtils.operationsTable([ + { operation: '๐Ÿ“‹ Server Details', description: 'View server configuration and options' }, + { operation: '๐Ÿ‘ฅ User Mappings', description: 'List all user mappings for this server' }, + { operation: '๐Ÿ“Š Foreign Tables', description: 'List all foreign tables using this server' }, + { operation: 'โœ๏ธ Alter Server', description: 'Modify server options or owner' }, + { operation: 'โž• Create User Mapping', description: 'Add authentication for users' }, + { operation: '๐Ÿ” Grant USAGE', description: 'Grant server access to roles' }, + { operation: 'โœ… Test Connection', description: 'Verify server connectivity' }, + { operation: 'โŒ Drop Server', description: 'Delete server (Warning: CASCADE required)' } + ]) + ) + .addMarkdown('##### ๐Ÿ“‹ Server Details and Options') + .addSql(ForeignDataWrapperSQL.query.serverDetails(item.label)) + .addMarkdown('##### ๐Ÿ‘ฅ User Mappings') + .addSql(ForeignDataWrapperSQL.query.listUserMappings(item.label)) + .addMarkdown('##### ๐Ÿ“Š Foreign Tables Using This Server') + .addSql(ForeignDataWrapperSQL.query.foreignTablesByServer(item.label)) + .addMarkdown('##### โœ๏ธ Alter Server Options') + .addSql(ForeignDataWrapperSQL.alter.serverOptions(item.label)) + .addMarkdown('##### โž• Create User Mapping') + .addSql(ForeignDataWrapperSQL.create.userMapping.basic(item.label)) + .addMarkdown('##### ๐Ÿ” Grant USAGE Permission') + .addSql(ForeignDataWrapperSQL.grant.usageOnServer(item.label, 'role_name')) + .addMarkdown('##### โœ… Test Server Connection') + .addSql(ForeignDataWrapperSQL.test.connection(item.label)) + .addMarkdown('##### โŒ Drop Foreign Server') + .addSql(ForeignDataWrapperSQL.drop.server(item.label)) + .show(); + } finally { + // Do not close shared client + } + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'create foreign server operations notebook'); + } +} + +/** + * cmdShowForeignServerProperties - Show detailed properties of a foreign server + */ +export async function cmdShowForeignServerProperties(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + const { connection, client, metadata } = await getDatabaseConnection(item); + + try { + const serverResult = await client.query(ForeignDataWrapperSQL.query.serverDetails(item.label)); + const mappingsResult = await client.query(ForeignDataWrapperSQL.query.listUserMappings(item.label)); + const tablesResult = await client.query(ForeignDataWrapperSQL.query.foreignTablesByServer(item.label)); + const optionsResult = await client.query(ForeignDataWrapperSQL.manage.showServerOptions(item.label)); + const statsResult = await client.query(ForeignDataWrapperSQL.manage.serverStatistics(item.label)); + + const server = serverResult.rows[0] || {}; + const mappings = mappingsResult.rows; + const tables = tablesResult.rows; + const options = optionsResult.rows; + const stats = statsResult.rows[0] || {}; + + // Build user mappings table HTML + const mappingRows = mappings.map((mapping: any) => { + return ` + ${mapping.user_name} + ${mapping.options ? mapping.options.filter((opt: string) => !opt.includes('password')).join(', ') : 'โ€”'} + `; + }).join('\n'); + + // Build foreign tables table HTML + const tableRows = tables.map((table: any) => { + return ` + ${table.schema_name}.${table.table_name} + ${table.size || 'โ€”'} + ${table.comment || 'โ€”'} + `; + }).join('\n'); + + // Build options table HTML + const optionRows = options.map((opt: any) => { + const optParts = opt.option?.split('=') || ['', '']; + return ` + ${optParts[0]} + ${optParts[1] || 'โ€”'} + `; + }).join('\n'); + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`๐Ÿ–ฅ๏ธ Foreign Server Properties: \`${item.label}\``) + + MarkdownUtils.infoBox(`**Owner:** ${server.owner || 'N/A'} | **FDW:** ${server.fdw_name || 'N/A'} ${server.comment ? `| **Comment:** ${server.comment}` : ''}`) + + `\n\n#### ๐Ÿ’พ General Information\n\n` + + MarkdownUtils.propertiesTable({ + 'Server Name': server.server_name || item.label, + 'FDW': server.fdw_name || 'N/A', + 'Owner': server.owner || 'N/A', + 'Server Type': server.server_type || 'N/A', + 'Server Version': server.server_version || 'N/A', + 'User Mappings': `${stats.user_mappings || 0}`, + 'Foreign Tables': `${stats.foreign_tables || 0}`, + 'Total Size': stats.total_size || '0 bytes' + }) + + (options.length > 0 ? `\n\n#### โš™๏ธ Server Options\n\n` + + ` + + + + +${optionRows} +
OptionValue
\n\n` : '\n\n_No options configured_\n\n') + + (mappings.length > 0 ? `#### ๐Ÿ‘ฅ User Mappings (${mappings.length})\n\n` + + ` + + + + +${mappingRows} +
UserOptions
\n\n` : '\n\n_No user mappings_\n\n') + + (tables.length > 0 ? `#### ๐Ÿ“Š Foreign Tables (${tables.length})\n\n` + + ` + + + + + +${tableRows} +
TableSizeComment
\n\n` : '\n\n_No foreign tables using this server_\n\n') + + '---' + ) + .addMarkdown('##### ๐Ÿ“ CREATE SERVER Script') + .addSql(`-- Recreate server (modify options as needed)\n${ForeignDataWrapperSQL.create.server.basic(server.fdw_name || 'postgres_fdw')}`) + .addMarkdown('##### โœ๏ธ ALTER SERVER Templates') + .addSql(`${ForeignDataWrapperSQL.alter.serverOptions(item.label)}\n\n${ForeignDataWrapperSQL.alter.serverOwner(item.label)}\n\n${ForeignDataWrapperSQL.alter.serverRename(item.label)}`) + .addMarkdown('##### ๐Ÿ—‘๏ธ DROP SERVER') + .addSql(ForeignDataWrapperSQL.drop.server(item.label, true)) + .show(); + } finally { + // Do not close shared client + } + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'show foreign server properties'); + } +} + +/** + * cmdCreateUserMapping - Command to create a new user mapping + */ +export async function cmdCreateUserMapping(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + const { connection, client, metadata } = await getDatabaseConnection(item); + + const serverName = item.type === 'foreign-server' ? item.label : 'server_name'; + + const markdown = MarkdownUtils.header(`โž• Create New User Mapping${item.type === 'foreign-server' ? ` for: \`${item.label}\`` : ''}`) + + MarkdownUtils.infoBox('User mappings define authentication credentials for accessing foreign servers. Each database user needs a mapping to use the foreign server.') + + `\n\n#### ๐Ÿ“‹ User Mapping Design Guidelines\n\n` + + MarkdownUtils.operationsTable([ + { operation: 'Security', description: 'Never hardcode passwords in scripts. Use secure storage or environment variables.' }, + { operation: 'Credentials', description: 'Each user can have different remote credentials. Use least-privilege principle.' }, + { operation: 'Scope', description: 'Mappings apply per-user or PUBLIC (all users)' }, + { operation: 'Testing', description: 'Test user mapping by querying a foreign table' }, + { operation: 'Rotation', description: 'Regularly rotate passwords using ALTER USER MAPPING' } + ]) + + `\n\n#### ๐Ÿท๏ธ Common User Mapping Patterns\n\n` + + MarkdownUtils.propertiesTable({ + 'Per-User Mapping': 'Each database user maps to their own remote account', + 'PUBLIC Mapping': 'All users share the same remote credentials (read-only recommended)', + 'Service Account': 'Map to dedicated service account for application access', + 'Role-based': 'Different roles map to different remote accounts', + 'Passwordless': 'Use certificate-based authentication where supported' + }) + + MarkdownUtils.warningBox('โš ๏ธ **Security Warning:** Passwords in user mappings are stored encrypted in PostgreSQL, but can be viewed by superusers. Use least-privilege accounts on remote servers.') + + `\n\n---`; + + await new NotebookBuilder(metadata) + .addMarkdown(markdown) + .addMarkdown('##### ๐Ÿ“ Basic User Mapping (Recommended Start)') + .addSql(ForeignDataWrapperSQL.create.userMapping.basic(serverName)) + .addMarkdown('##### ๐Ÿ” User Mapping with Password') + .addSql(ForeignDataWrapperSQL.create.userMapping.withPassword(serverName)) + .addMarkdown('##### ๐ŸŒ PUBLIC User Mapping (All Users)') + .addSql(ForeignDataWrapperSQL.create.userMapping.public(serverName)) + .addMarkdown('##### โš™๏ธ User Mapping with Advanced Options') + .addSql(ForeignDataWrapperSQL.create.userMapping.withOptions(serverName)) + .addMarkdown('##### โœ… Test User Mapping') + .addSql(`-- Test by querying a foreign table\n-- SELECT * FROM foreign_table_name LIMIT 1;\n\n-- Or test permissions\n${ForeignDataWrapperSQL.test.permissions(serverName)}`) + .addMarkdown(MarkdownUtils.successBox('After creating user mapping, test it by querying a foreign table. If you get permission errors, check the remote server permissions and credentials.', 'Tip')) + .show(); + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'create user mapping notebook'); + } +} + +/** + * cmdUserMappingOperations - Command to create operations notebook for a user mapping + */ +export async function cmdUserMappingOperations(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + const { connection, client, metadata } = await getDatabaseConnection(item); + + // For user mappings, we need server name from parent and username from label + const serverName = item.schema || 'server_name'; // Using schema field to store server name + const userName = item.label; + + try { + const mappingResult = await client.query(ForeignDataWrapperSQL.query.userMappingDetails(serverName, userName)); + const serverResult = await client.query(ForeignDataWrapperSQL.query.serverDetails(serverName)); + + const mapping = mappingResult.rows[0] || {}; + const server = serverResult.rows[0] || {}; + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`๐Ÿ‘ค User Mapping Operations: \`${userName}@${serverName}\``) + + MarkdownUtils.infoBox('This notebook contains operations for managing the user mapping. Execute the cells below to perform operations.') + + `\n\n#### ๐Ÿ“Š User Mapping Information\n\n` + + MarkdownUtils.propertiesTable({ + 'User': userName, + 'Server': serverName, + 'FDW': mapping.fdw_name || server.fdw_name || 'N/A', + 'Server Owner': mapping.server_owner || server.owner || 'N/A' + }) + + `\n\n#### ๐ŸŽฏ Available Operations\n\n` + + MarkdownUtils.operationsTable([ + { operation: '๐Ÿ“‹ View Details', description: 'Show mapping configuration and options' }, + { operation: 'โœ๏ธ Alter Mapping', description: 'Update credentials or options' }, + { operation: 'โœ… Test Access', description: 'Verify connection and permissions' }, + { operation: '๐Ÿ”„ Rotate Credentials', description: 'Update remote password' }, + { operation: 'โŒ Drop Mapping', description: 'Remove user mapping' } + ]) + ) + .addMarkdown('##### ๐Ÿ“‹ User Mapping Details') + .addSql(ForeignDataWrapperSQL.query.userMappingDetails(serverName, userName)) + .addMarkdown('##### โœ๏ธ Alter User Mapping Options') + .addSql(ForeignDataWrapperSQL.alter.userMappingOptions(serverName, userName)) + .addMarkdown('##### ๐Ÿ”„ Rotate Password (Change Remote Credentials)') + .addSql(`-- Update remote password\nALTER USER MAPPING FOR ${userName}\nSERVER ${serverName}\nOPTIONS (SET password 'new_password_here');`) + .addMarkdown('##### โœ… Test Permissions') + .addSql(ForeignDataWrapperSQL.test.permissions(serverName)) + .addMarkdown('##### โŒ Drop User Mapping') + .addSql(ForeignDataWrapperSQL.drop.userMapping(serverName, userName)) + .show(); + } catch (err: any) { + // If query fails, still show templates + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`๐Ÿ‘ค User Mapping Operations: \`${userName}@${serverName}\``) + + MarkdownUtils.infoBox('Operations for managing the user mapping.') + ) + .addMarkdown('##### ๐Ÿ“‹ User Mapping Details') + .addSql(ForeignDataWrapperSQL.query.userMappingDetails(serverName, userName)) + .addMarkdown('##### โœ๏ธ Alter User Mapping') + .addSql(ForeignDataWrapperSQL.alter.userMappingOptions(serverName, userName)) + .addMarkdown('##### โŒ Drop User Mapping') + .addSql(ForeignDataWrapperSQL.drop.userMapping(serverName, userName)) + .show(); + } + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'create user mapping operations notebook'); + } +} + +/** + * cmdShowUserMappingProperties - Show detailed properties of a user mapping + */ +export async function cmdShowUserMappingProperties(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + const { connection, client, metadata } = await getDatabaseConnection(item); + + const serverName = item.schema || 'server_name'; + const userName = item.label; + + try { + const mappingResult = await client.query(ForeignDataWrapperSQL.query.userMappingDetails(serverName, userName)); + const serverResult = await client.query(ForeignDataWrapperSQL.query.serverDetails(serverName)); + const optionsResult = await client.query(ForeignDataWrapperSQL.manage.showUserMappingOptions(serverName, userName)); + + const mapping = mappingResult.rows[0] || {}; + const server = serverResult.rows[0] || {}; + const options = optionsResult.rows; + + // Build options table HTML (censor passwords) + const optionRows = options.map((opt: any) => { + const optParts = opt.option?.split('=') || ['', '']; + const value = optParts[0] === 'password' ? '********' : (optParts[1] || 'โ€”'); + return ` + ${optParts[0]} + ${value} + `; + }).join('\n'); + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`๐Ÿ‘ค User Mapping Properties: \`${userName}@${serverName}\``) + + MarkdownUtils.infoBox(`Maps database user **${userName}** to remote credentials on server **${serverName}**`) + + `\n\n#### ๐Ÿ’พ General Information\n\n` + + MarkdownUtils.propertiesTable({ + 'Database User': userName, + 'Foreign Server': serverName, + 'FDW': mapping.fdw_name || server.fdw_name || 'N/A', + 'Server Owner': mapping.server_owner || server.owner || 'N/A' + }) + + (options.length > 0 ? `\n\n#### โš™๏ธ Mapping Options\n\n` + + MarkdownUtils.warningBox('Password values are censored for security. Only superusers can view actual passwords in system catalogs.', 'Security') + + `\n + + + + +${optionRows} +
OptionValue
\n\n` : '\n\n_No options configured_\n\n') + + `#### ๐Ÿ“Š Associated Server Information\n\n` + + MarkdownUtils.propertiesTable({ + 'Server Name': server.server_name || serverName, + 'FDW': server.fdw_name || 'N/A', + 'Owner': server.owner || 'N/A' + }) + + '---' + ) + .addMarkdown('##### ๐Ÿ“ CREATE USER MAPPING Script') + .addSql(`-- Recreate user mapping (update password!)\n${ForeignDataWrapperSQL.create.userMapping.withPassword(serverName)}`) + .addMarkdown('##### โœ๏ธ ALTER USER MAPPING Templates') + .addSql(ForeignDataWrapperSQL.alter.userMappingOptions(serverName, userName)) + .addMarkdown('##### ๐Ÿ—‘๏ธ DROP USER MAPPING') + .addSql(ForeignDataWrapperSQL.drop.userMapping(serverName, userName)) + .show(); + } finally { + // Do not close shared client + } + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'show user mapping properties'); + } +} + +/** + * cmdDropForeignServer - Command to drop a foreign server + */ +export async function cmdDropForeignServer(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + const { connection, client, metadata } = await getDatabaseConnection(item); + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`โŒ Drop Foreign Server: \`${item.label}\``) + + MarkdownUtils.dangerBox('This action will PERMANENTLY DELETE the foreign server. All foreign tables and user mappings using this server will also be dropped with CASCADE.', 'Caution') + + `\n\n#### ๐Ÿ” Pre-Drop Checklist\n\n` + + MarkdownUtils.propertiesTable({ + 'โœ… Backups': 'Do you have backups of data accessed through this server?', + 'โœ… Dependencies': 'Check for foreign tables and user mappings', + 'โœ… Applications': 'Ensure no applications are actively using this server', + 'โœ… Documentation': 'Update documentation and connection inventories' + }) + + `\n\n#### ๐Ÿ”— Check Dependencies\n\nRun this query first to see what will be dropped:` + ) + .addSql(ForeignDataWrapperSQL.manage.dependencies(item.label)) + .addMarkdown('#### ๐Ÿ—‘๏ธ Drop Server') + .addSql(ForeignDataWrapperSQL.drop.server(item.label)) + .addMarkdown('#### ๐Ÿ—‘๏ธ Drop Server with CASCADE (Drops All Foreign Tables)') + .addSql(ForeignDataWrapperSQL.drop.server(item.label, true)) + .show(); + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'create drop server notebook'); + } +} + +/** + * cmdDropUserMapping - Command to drop a user mapping + */ +export async function cmdDropUserMapping(item: DatabaseTreeItem, context: vscode.ExtensionContext) { + try { + const { connection, client, metadata } = await getDatabaseConnection(item); + + const serverName = item.schema || 'server_name'; + const userName = item.label; + + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`โŒ Drop User Mapping: \`${userName}@${serverName}\``) + + MarkdownUtils.warningBox('Dropping this user mapping will remove access to the foreign server for this user. They will no longer be able to query foreign tables.') + + `\n\n#### ๐Ÿ” Verification Checklist\n\n` + + MarkdownUtils.propertiesTable({ + 'โœ… User': `Confirm this is for user: ${userName}`, + 'โœ… Server': `Confirm this is for server: ${serverName}`, + 'โœ… Impact': 'User will lose access to all foreign tables on this server', + 'โœ… Alternative': 'Can you revoke access another way instead?' + }) + ) + .addMarkdown('#### ๐Ÿ—‘๏ธ Drop User Mapping') + .addSql(ForeignDataWrapperSQL.drop.userMapping(serverName, userName)) + .show(); + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'create drop user mapping notebook'); + } +} + +/** + * Refresh commands for tree view + */ +export async function cmdRefreshForeignDataWrapper(item: DatabaseTreeItem, context: vscode.ExtensionContext, databaseTreeProvider?: DatabaseTreeProvider) { + databaseTreeProvider?.refresh(item); +} + +export async function cmdRefreshForeignServer(item: DatabaseTreeItem, context: vscode.ExtensionContext, databaseTreeProvider?: DatabaseTreeProvider) { + databaseTreeProvider?.refresh(item); +} + +export async function cmdRefreshUserMapping(item: DatabaseTreeItem, context: vscode.ExtensionContext, databaseTreeProvider?: DatabaseTreeProvider) { + databaseTreeProvider?.refresh(item); +} diff --git a/src/commands/sql/foreignDataWrappers.ts b/src/commands/sql/foreignDataWrappers.ts new file mode 100644 index 0000000..0708d03 --- /dev/null +++ b/src/commands/sql/foreignDataWrappers.ts @@ -0,0 +1,477 @@ +/** + * SQL Templates for Foreign Data Wrapper Operations + */ + +export const ForeignDataWrapperSQL = { + /** + * CREATE Templates + */ + create: { + server: { + basic: (fdwName: string) => + `-- Create basic foreign server +CREATE SERVER server_name +FOREIGN DATA WRAPPER ${fdwName} +OPTIONS ( + -- Add server-specific options here + host 'hostname', + port '5432', + dbname 'database_name' +); + +-- Add comment +COMMENT ON SERVER server_name IS 'Description of the foreign server';`, + + postgres: (fdwName: string = 'postgres_fdw') => + `-- Enable postgres_fdw extension if not already enabled +CREATE EXTENSION IF NOT EXISTS postgres_fdw; + +-- Create PostgreSQL foreign server +CREATE SERVER remote_postgres_server +FOREIGN DATA WRAPPER ${fdwName} +OPTIONS ( + host 'remote.example.com', + port '5432', + dbname 'remote_database', + fetch_size '1000', + use_remote_estimate 'true' +); + +-- Add comment +COMMENT ON SERVER remote_postgres_server IS 'Remote PostgreSQL database connection';`, + + mysql: (fdwName: string = 'mysql_fdw') => + `-- Enable mysql_fdw extension if not already enabled +CREATE EXTENSION IF NOT EXISTS mysql_fdw; + +-- Create MySQL foreign server +CREATE SERVER mysql_server +FOREIGN DATA WRAPPER ${fdwName} +OPTIONS ( + host 'mysql.example.com', + port '3306' +); + +-- Add comment +COMMENT ON SERVER mysql_server IS 'MySQL database connection';`, + + file: () => + `-- Enable file_fdw extension if not already enabled +CREATE EXTENSION IF NOT EXISTS file_fdw; + +-- Create file-based foreign server +CREATE SERVER file_server +FOREIGN DATA WRAPPER file_fdw; + +-- Add comment +COMMENT ON SERVER file_server IS 'File-based data access (CSV, etc.)';`, + + withAuth: (fdwName: string) => + `-- Create foreign server with authentication options +CREATE SERVER secure_server +FOREIGN DATA WRAPPER ${fdwName} +OPTIONS ( + host 'secure.example.com', + port '5432', + dbname 'production_db', + sslmode 'require', + sslcert '/path/to/client-cert.pem', + sslkey '/path/to/client-key.pem', + sslrootcert '/path/to/ca-cert.pem' +); + +-- Add comment +COMMENT ON SERVER secure_server IS 'Secure remote database with SSL authentication';` + }, + + userMapping: { + basic: (serverName: string) => + `-- Create user mapping for current user +CREATE USER MAPPING FOR CURRENT_USER +SERVER ${serverName} +OPTIONS ( + user 'remote_username', + password 'remote_password' +);`, + + withPassword: (serverName: string) => + `-- Create user mapping with password +CREATE USER MAPPING FOR username +SERVER ${serverName} +OPTIONS ( + user 'remote_username', + password 'secure_password_here' +); + +-- Note: Passwords are stored securely in PostgreSQL`, + + public: (serverName: string) => + `-- Create PUBLIC user mapping (applies to all users without specific mapping) +CREATE USER MAPPING FOR PUBLIC +SERVER ${serverName} +OPTIONS ( + user 'readonly_user', + password 'readonly_password' +); + +-- Warning: PUBLIC mappings apply to all database users`, + + withOptions: (serverName: string) => + `-- Create user mapping with advanced options +CREATE USER MAPPING FOR username +SERVER ${serverName} +OPTIONS ( + user 'remote_username', + password 'remote_password', + -- postgres_fdw specific options + fetch_size '1000', + use_remote_estimate 'true', + async_capable 'true' +);` + } + }, + + /** + * ALTER Templates + */ + alter: { + serverOptions: (serverName: string) => + `-- Alter server options +ALTER SERVER ${serverName} +OPTIONS ( + SET host 'new-hostname', + SET port '5432', + ADD dbname 'new_database' +); + +-- Drop an option +-- ALTER SERVER ${serverName} +-- OPTIONS (DROP option_name);`, + + serverOwner: (serverName: string) => + `-- Change server owner +ALTER SERVER ${serverName} +OWNER TO new_owner_role;`, + + serverRename: (serverName: string) => + `-- Rename server +ALTER SERVER ${serverName} +RENAME TO new_server_name;`, + + userMappingOptions: (serverName: string, userName: string = 'CURRENT_USER') => + `-- Alter user mapping options +ALTER USER MAPPING FOR ${userName} +SERVER ${serverName} +OPTIONS ( + SET user 'new_remote_username', + SET password 'new_remote_password' +);`, + + addOption: (serverName: string) => + `-- Add option to server +ALTER SERVER ${serverName} +OPTIONS (ADD option_name 'option_value');`, + + dropOption: (serverName: string) => + `-- Drop option from server +ALTER SERVER ${serverName} +OPTIONS (DROP option_name);` + }, + + /** + * QUERY Templates + */ + query: { + listFDWs: () => + `-- List all foreign data wrappers +SELECT + fdwname as fdw_name, + fdwowner::regrole as owner, + fdwhandler::regproc as handler, + fdwvalidator::regproc as validator, + fdwoptions as options +FROM pg_foreign_data_wrapper +ORDER BY fdwname;`, + + fdwDetails: (fdwName: string) => + `-- Get detailed FDW information +SELECT + fdw.fdwname as fdw_name, + fdw.fdwowner::regrole as owner, + fdw.fdwhandler::regproc as handler_function, + fdw.fdwvalidator::regproc as validator_function, + fdw.fdwoptions as options, + fdw.fdwacl as access_privileges, + obj_description(fdw.oid, 'pg_foreign_data_wrapper') as comment, + COUNT(srv.oid) as server_count +FROM pg_foreign_data_wrapper fdw +LEFT JOIN pg_foreign_server srv ON srv.srvfdw = fdw.oid +WHERE fdw.fdwname = '${fdwName}' +GROUP BY fdw.oid, fdw.fdwname, fdw.fdwowner, fdw.fdwhandler, fdw.fdwvalidator, fdw.fdwoptions, fdw.fdwacl;`, + + listServers: (fdwName?: string) => + fdwName + ? `-- List all foreign servers for ${fdwName} +SELECT + srv.srvname as server_name, + fdw.fdwname as fdw_name, + srv.srvowner::regrole as owner, + srv.srvoptions as options, + obj_description(srv.oid, 'pg_foreign_server') as comment, + COUNT(DISTINCT um.umid) as user_mapping_count, + COUNT(DISTINCT ft.ftrelid) as foreign_table_count +FROM pg_foreign_server srv +JOIN pg_foreign_data_wrapper fdw ON srv.srvfdw = fdw.oid +LEFT JOIN pg_user_mappings um ON um.srvid = srv.oid +LEFT JOIN pg_foreign_table ft ON ft.ftserver = srv.oid +WHERE fdw.fdwname = '${fdwName}' +GROUP BY srv.oid, srv.srvname, fdw.fdwname, srv.srvowner, srv.srvoptions +ORDER BY srv.srvname;` + : `-- List all foreign servers +SELECT + srv.srvname as server_name, + fdw.fdwname as fdw_name, + srv.srvowner::regrole as owner, + srv.srvoptions as options, + obj_description(srv.oid, 'pg_foreign_server') as comment, + COUNT(DISTINCT um.umid) as user_mapping_count, + COUNT(DISTINCT ft.ftrelid) as foreign_table_count +FROM pg_foreign_server srv +JOIN pg_foreign_data_wrapper fdw ON srv.srvfdw = fdw.oid +LEFT JOIN pg_user_mappings um ON um.srvid = srv.oid +LEFT JOIN pg_foreign_table ft ON ft.ftserver = srv.oid +GROUP BY srv.oid, srv.srvname, fdw.fdwname, srv.srvowner, srv.srvoptions +ORDER BY fdw.fdwname, srv.srvname;`, + + serverDetails: (serverName: string) => + `-- Get detailed server information +SELECT + srv.srvname as server_name, + fdw.fdwname as fdw_name, + srv.srvowner::regrole as owner, + srv.srvtype as server_type, + srv.srvversion as server_version, + srv.srvoptions as options, + srv.srvacl as access_privileges, + obj_description(srv.oid, 'pg_foreign_server') as comment +FROM pg_foreign_server srv +JOIN pg_foreign_data_wrapper fdw ON srv.srvfdw = fdw.oid +WHERE srv.srvname = '${serverName}';`, + + listUserMappings: (serverName?: string) => + serverName + ? `-- List user mappings for ${serverName} +SELECT + um.srvname as server_name, + um.usename as user_name, + um.umoptions as options +FROM pg_user_mappings um +WHERE um.srvname = '${serverName}' +ORDER BY um.usename;` + : `-- List all user mappings +SELECT + um.srvname as server_name, + fdw.fdwname as fdw_name, + um.usename as user_name, + um.umoptions as options +FROM pg_user_mappings um +JOIN pg_foreign_server srv ON um.srvid = srv.oid +JOIN pg_foreign_data_wrapper fdw ON srv.srvfdw = fdw.oid +ORDER BY um.srvname, um.usename;`, + + userMappingDetails: (serverName: string, userName: string) => + `-- Get detailed user mapping information +SELECT + um.srvname as server_name, + um.usename as user_name, + um.umoptions as options, + srv.srvowner::regrole as server_owner, + fdw.fdwname as fdw_name +FROM pg_user_mappings um +JOIN pg_foreign_server srv ON um.srvid = srv.oid +JOIN pg_foreign_data_wrapper fdw ON srv.srvfdw = fdw.oid +WHERE um.srvname = '${serverName}' + AND um.usename = '${userName}';`, + + foreignTablesByServer: (serverName: string) => + `-- List foreign tables using this server +SELECT + n.nspname as schema_name, + c.relname as table_name, + srv.srvname as server_name, + ft.ftoptions as table_options, + pg_size_pretty(pg_total_relation_size(c.oid)) as size, + obj_description(c.oid, 'pg_class') as comment +FROM pg_foreign_table ft +JOIN pg_class c ON ft.ftrelid = c.oid +JOIN pg_namespace n ON c.relnamespace = n.oid +JOIN pg_foreign_server srv ON ft.ftserver = srv.oid +WHERE srv.srvname = '${serverName}' +ORDER BY n.nspname, c.relname;`, + + fdwFunctions: (fdwName: string) => + `-- Show FDW handler and validator functions +SELECT + fdw.fdwname as fdw_name, + fdw.fdwhandler::regproc as handler_function, + fdw.fdwvalidator::regproc as validator_function, + ph.proname as handler_name, + pv.proname as validator_name, + pg_get_functiondef(fdw.fdwhandler) as handler_definition +FROM pg_foreign_data_wrapper fdw +LEFT JOIN pg_proc ph ON fdw.fdwhandler = ph.oid +LEFT JOIN pg_proc pv ON fdw.fdwvalidator = pv.oid +WHERE fdw.fdwname = '${fdwName}';` + }, + + /** + * GRANT Templates + */ + grant: { + usageOnServer: (serverName: string, roleName: string) => + `-- Grant USAGE on foreign server +GRANT USAGE ON FOREIGN SERVER ${serverName} TO ${roleName}; + +-- Revoke USAGE +-- REVOKE USAGE ON FOREIGN SERVER ${serverName} FROM ${roleName};`, + + usageOnFDW: (fdwName: string, roleName: string) => + `-- Grant USAGE on foreign data wrapper +GRANT USAGE ON FOREIGN DATA WRAPPER ${fdwName} TO ${roleName}; + +-- Revoke USAGE +-- REVOKE USAGE ON FOREIGN DATA WRAPPER ${fdwName} FROM ${roleName};` + }, + + /** + * DROP Templates + */ + drop: { + server: (serverName: string, cascade: boolean = false) => + cascade + ? `-- Drop server with CASCADE (will drop all foreign tables and user mappings) +DROP SERVER IF EXISTS ${serverName} CASCADE;` + : `-- Drop server (will fail if foreign tables or user mappings exist) +DROP SERVER IF EXISTS ${serverName}; + +-- To force drop with all dependencies: +-- DROP SERVER IF EXISTS ${serverName} CASCADE;`, + + userMapping: (serverName: string, userName: string = 'CURRENT_USER') => + `-- Drop user mapping +DROP USER MAPPING IF EXISTS FOR ${userName} +SERVER ${serverName};`, + + fdw: (fdwName: string, cascade: boolean = false) => + cascade + ? `-- Drop foreign data wrapper with CASCADE +DROP FOREIGN DATA WRAPPER IF EXISTS ${fdwName} CASCADE;` + : `-- Drop foreign data wrapper (will fail if servers exist) +DROP FOREIGN DATA WRAPPER IF EXISTS ${fdwName}; + +-- To force drop with all dependencies: +-- DROP FOREIGN DATA WRAPPER IF EXISTS ${fdwName} CASCADE;` + }, + + /** + * TEST Templates + */ + test: { + connection: (serverName: string) => + `-- Test server connection by creating a temporary foreign table +-- This will verify connectivity and permissions + +BEGIN; + +-- Create test foreign table +CREATE FOREIGN TABLE IF NOT EXISTS test_connection_temp ( + test_column text +) SERVER ${serverName} +OPTIONS (schema_name 'public', table_name 'dual'); + +-- Try to query (may fail if 'dual' table doesn't exist, but connection is tested) +-- SELECT * FROM test_connection_temp LIMIT 1; + +-- Clean up +DROP FOREIGN TABLE IF EXISTS test_connection_temp; + +ROLLBACK; + +-- If no errors, connection is working!`, + + permissions: (serverName: string) => + `-- Verify user has necessary permissions +-- Check if current user has USAGE permission on server +SELECT + has_server_privilege(CURRENT_USER, '${serverName}', 'USAGE') as has_usage_permission; + +-- Check server access privileges +SELECT + srvname, + srvacl +FROM pg_foreign_server +WHERE srvname = '${serverName}';` + }, + + /** + * MANAGEMENT Templates + */ + manage: { + showServerOptions: (serverName: string) => + `-- Display server options in readable format +SELECT + srv.srvname as server_name, + fdw.fdwname as fdw_name, + unnest(srv.srvoptions) as option +FROM pg_foreign_server srv +JOIN pg_foreign_data_wrapper fdw ON srv.srvfdw = fdw.oid +WHERE srv.srvname = '${serverName}';`, + + showUserMappingOptions: (serverName: string, userName: string = 'CURRENT_USER') => + `-- Display user mapping options (passwords are censored) +SELECT + um.srvname as server_name, + um.usename as user_name, + unnest(um.umoptions) as option +FROM pg_user_mappings um +WHERE um.srvname = '${serverName}' + AND um.usename = '${userName}';`, + + dependencies: (serverName: string) => + `-- Show all objects depending on a server +SELECT + n.nspname as schema_name, + c.relname as object_name, + c.relkind as object_type, + CASE c.relkind + WHEN 'f' THEN 'foreign table' + WHEN 'r' THEN 'table' + WHEN 'v' THEN 'view' + WHEN 'm' THEN 'materialized view' + ELSE 'other' + END as type_description +FROM pg_foreign_table ft +JOIN pg_class c ON ft.ftrelid = c.oid +JOIN pg_namespace n ON c.relnamespace = n.oid +JOIN pg_foreign_server srv ON ft.ftserver = srv.oid +WHERE srv.srvname = '${serverName}' +ORDER BY n.nspname, c.relname;`, + + serverStatistics: (serverName: string) => + `-- Server usage statistics +SELECT + srv.srvname as server_name, + fdw.fdwname as fdw_name, + srv.srvowner::regrole as owner, + COUNT(DISTINCT um.umid) as user_mappings, + COUNT(DISTINCT ft.ftrelid) as foreign_tables, + COALESCE(SUM(pg_total_relation_size(ft.ftrelid)), 0) as total_size_bytes, + pg_size_pretty(COALESCE(SUM(pg_total_relation_size(ft.ftrelid)), 0)) as total_size +FROM pg_foreign_server srv +JOIN pg_foreign_data_wrapper fdw ON srv.srvfdw = fdw.oid +LEFT JOIN pg_user_mappings um ON um.srvid = srv.oid +LEFT JOIN pg_foreign_table ft ON ft.ftserver = srv.oid +WHERE srv.srvname = '${serverName}' +GROUP BY srv.oid, srv.srvname, fdw.fdwname, srv.srvowner;` + } +}; diff --git a/src/commands/sql/index.ts b/src/commands/sql/index.ts index 9d7f6ba..5d2661d 100644 --- a/src/commands/sql/index.ts +++ b/src/commands/sql/index.ts @@ -15,4 +15,6 @@ export { TypeSQL } from './types'; export { ExtensionSQL } from './extensions'; export { MaterializedViewSQL } from './materializedViews'; export { ForeignTableSQL } from './foreignTables'; +export { ForeignDataWrapperSQL } from './foreignDataWrappers'; export { SQL_TEMPLATES, QueryBuilder, MaintenanceTemplates } from './helper'; + diff --git a/src/extension.ts b/src/extension.ts index ed39202..dd9789d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,6 +8,7 @@ import { showIndexProperties, copyIndexName, generateDropIndexScript, generateRe import { cmdAddObjectInDatabase, cmdBackupDatabase, cmdCreateDatabase, cmdDatabaseDashboard, cmdDatabaseOperations, cmdDeleteDatabase, cmdDisconnectDatabase as cmdDisconnectDatabaseLegacy, cmdGenerateCreateScript, cmdMaintenanceDatabase, cmdPsqlTool, cmdQueryTool, cmdRestoreDatabase, cmdScriptAlterDatabase, cmdShowConfiguration } from './commands/database'; import { cmdDropExtension, cmdEnableExtension, cmdExtensionOperations, cmdRefreshExtension } from './commands/extensions'; import { cmdCreateForeignTable, cmdEditForeignTable, cmdForeignTableOperations, cmdRefreshForeignTable } from './commands/foreignTables'; +import { cmdForeignDataWrapperOperations, cmdShowForeignDataWrapperProperties, cmdCreateForeignServer, cmdForeignServerOperations, cmdShowForeignServerProperties, cmdDropForeignServer, cmdCreateUserMapping, cmdUserMappingOperations, cmdShowUserMappingProperties, cmdDropUserMapping, cmdRefreshForeignDataWrapper, cmdRefreshForeignServer, cmdRefreshUserMapping } from './commands/foreignDataWrappers'; import { cmdCallFunction, cmdCreateFunction, cmdDropFunction, cmdEditFunction, cmdFunctionOperations, cmdRefreshFunction, cmdShowFunctionProperties } from './commands/functions'; import { cmdCreateMaterializedView, cmdDropMatView, cmdEditMatView, cmdMatViewOperations, cmdRefreshMatView, cmdViewMatViewData, cmdViewMatViewProperties } from './commands/materializedViews'; import { cmdNewNotebook } from './commands/notebook'; @@ -474,6 +475,61 @@ export async function activate(context: vscode.ExtensionContext) { command: 'postgres-explorer.createForeignTable', callback: async (item: DatabaseTreeItem) => await cmdCreateForeignTable(item, context) }, + // Foreign Data Wrapper commands + { + command: 'postgres-explorer.foreignDataWrapperOperations', + callback: async (item: DatabaseTreeItem) => await cmdForeignDataWrapperOperations(item, context) + }, + { + command: 'postgres-explorer.showForeignDataWrapperProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowForeignDataWrapperProperties(item, context) + }, + { + command: 'postgres-explorer.refreshForeignDataWrapper', + callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignDataWrapper(item, context, databaseTreeProvider) + }, + // Foreign Server commands + { + command: 'postgres-explorer.createForeignServer', + callback: async (item: DatabaseTreeItem) => await cmdCreateForeignServer(item, context) + }, + { + command: 'postgres-explorer.foreignServerOperations', + callback: async (item: DatabaseTreeItem) => await cmdForeignServerOperations(item, context) + }, + { + command: 'postgres-explorer.showForeignServerProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowForeignServerProperties(item, context) + }, + { + command: 'postgres-explorer.dropForeignServer', + callback: async (item: DatabaseTreeItem) => await cmdDropForeignServer(item, context) + }, + { + command: 'postgres-explorer.refreshForeignServer', + callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignServer(item, context, databaseTreeProvider) + }, + // User Mapping commands + { + command: 'postgres-explorer.createUserMapping', + callback: async (item: DatabaseTreeItem) => await cmdCreateUserMapping(item, context) + }, + { + command: 'postgres-explorer.userMappingOperations', + callback: async (item: DatabaseTreeItem) => await cmdUserMappingOperations(item, context) + }, + { + command: 'postgres-explorer.showUserMappingProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowUserMappingProperties(item, context) + }, + { + command: 'postgres-explorer.dropUserMapping', + callback: async (item: DatabaseTreeItem) => await cmdDropUserMapping(item, context) + }, + { + command: 'postgres-explorer.refreshUserMapping', + callback: async (item: DatabaseTreeItem) => await cmdRefreshUserMapping(item, context, databaseTreeProvider) + }, { command: 'postgres-explorer.createRole', callback: async (item: DatabaseTreeItem) => await cmdAddRole(item, context) diff --git a/src/providers/DatabaseTreeProvider.ts b/src/providers/DatabaseTreeProvider.ts index 999cbf7..4d149fc 100644 --- a/src/providers/DatabaseTreeProvider.ts +++ b/src/providers/DatabaseTreeProvider.ts @@ -127,7 +127,8 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider new DatabaseTreeItem( + row.name, + vscode.TreeItemCollapsibleState.Collapsed, + 'foreign-data-wrapper', + element.connectionId, + element.databaseName + )); } return []; @@ -399,6 +414,44 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider new DatabaseTreeItem( + row.name, + vscode.TreeItemCollapsibleState.Collapsed, + 'foreign-server', + element.connectionId, + element.databaseName, + element.label // Store FDW name in schema field + )); + + case 'foreign-server': + // Foreign server node - list all user mappings + const mappingsResult = await client.query( + `SELECT um.usename as name + FROM pg_user_mappings um + WHERE um.srvname = $1 + ORDER BY um.usename`, + [element.label] + ); + return mappingsResult.rows.map(row => new DatabaseTreeItem( + row.name, + vscode.TreeItemCollapsibleState.None, + 'user-mapping', + element.connectionId, + element.databaseName, + element.label, // Store server name in schema field + element.label // Store server name in tableName for context + )); + default: return []; } @@ -425,7 +478,7 @@ export class DatabaseTreeItem extends vscode.TreeItem { constructor( public readonly label: string, public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly type: 'connection' | 'database' | 'schema' | 'table' | 'view' | 'function' | 'column' | 'category' | 'materialized-view' | 'type' | 'foreign-table' | 'extension' | 'role' | 'databases-group' | 'constraint' | 'index', + public readonly type: 'connection' | 'database' | 'schema' | 'table' | 'view' | 'function' | 'column' | 'category' | 'materialized-view' | 'type' | 'foreign-table' | 'extension' | 'role' | 'databases-group' | 'constraint' | 'index' | 'foreign-data-wrapper' | 'foreign-server' | 'user-mapping', public readonly connectionId?: string, public readonly databaseName?: string, public readonly schema?: string, @@ -465,7 +518,10 @@ export class DatabaseTreeItem extends vscode.TreeItem { extension: new vscode.ThemeIcon(isInstalled ? 'extensions-installed' : 'extensions', isInstalled ? new vscode.ThemeColor('charts.green') : undefined), role: new vscode.ThemeIcon('person', new vscode.ThemeColor('charts.yellow')), constraint: new vscode.ThemeIcon('lock', new vscode.ThemeColor('charts.orange')), - index: new vscode.ThemeIcon('search', new vscode.ThemeColor('charts.purple')) + index: new vscode.ThemeIcon('search', new vscode.ThemeColor('charts.purple')), + 'foreign-data-wrapper': new vscode.ThemeIcon('extensions', new vscode.ThemeColor('charts.blue')), + 'foreign-server': new vscode.ThemeIcon('server', new vscode.ThemeColor('charts.green')), + 'user-mapping': new vscode.ThemeIcon('account', new vscode.ThemeColor('charts.yellow')) }[type]; } diff --git a/src/renderer_v2.ts b/src/renderer_v2.ts index 3c49e64..7ba640e 100644 --- a/src/renderer_v2.ts +++ b/src/renderer_v2.ts @@ -1436,7 +1436,7 @@ export const activate: ActivationFunction = context => { return { label: col, data: chartData.map(row => parseFloat(row[col]) || 0), - backgroundColor: ctx ? createGradient(ctx, colorIdx, customColor) : bgColor, + backgroundColor: ctx ? createGradient(ctx, colorIdx, customColor, !horizontalBars) : bgColor, borderColor: border, borderWidth: 2, borderRadius: 6, @@ -1537,7 +1537,7 @@ export const activate: ActivationFunction = context => { return { label: col, data: chartData.map(row => parseFloat(row[col]) || 0), - backgroundColor: ctx ? createGradient(ctx, colorIdx, customColor) : bgColor, + backgroundColor: ctx ? createGradient(ctx, colorIdx, customColor, !horizontalBars) : bgColor, borderColor: border, borderWidth: 1, borderRadius: 4,