From b785b016fabe1e069502aa5d51f7b739481d7464 Mon Sep 17 00:00:00 2001 From: Kacper Kula Date: Fri, 15 Aug 2025 14:08:58 +0100 Subject: [PATCH 1/2] feat: variables editor --- .changeset/late-pears-peel.md | 5 + README.md | 7 +- .../codeblockHandler/CodeblockProcessor.ts | 8 + src/modules/explorer/Editor.ts | 196 ++++++++++++++++-- src/modules/explorer/SQLSealFileView.ts | 39 +++- src/modules/explorer/explorer/ExplorerView.ts | 15 +- src/modules/explorer/explorer/style.scss | 79 ++++++- .../variables/SqlVariableParser.test.ts | 157 ++++++++++++++ .../explorer/variables/SqlVariableParser.ts | 67 ++++++ .../variables/VariableInputInterface.ts | 154 ++++++++++++++ .../variables/VariablePersistence.test.ts | 150 ++++++++++++++ .../explorer/variables/VariablePersistence.ts | 115 ++++++++++ src/styles/syntaxHighlight.scss | 12 ++ 13 files changed, 980 insertions(+), 24 deletions(-) create mode 100644 .changeset/late-pears-peel.md create mode 100644 src/modules/explorer/variables/SqlVariableParser.test.ts create mode 100644 src/modules/explorer/variables/SqlVariableParser.ts create mode 100644 src/modules/explorer/variables/VariableInputInterface.ts create mode 100644 src/modules/explorer/variables/VariablePersistence.test.ts create mode 100644 src/modules/explorer/variables/VariablePersistence.ts diff --git a/.changeset/late-pears-peel.md b/.changeset/late-pears-peel.md new file mode 100644 index 0000000..7d45529 --- /dev/null +++ b/.changeset/late-pears-peel.md @@ -0,0 +1,5 @@ +--- +"sqlseal": minor +--- + +added variable editor to the SQLSealExplorer - now if your query uses @variables they will automatically show up below the code so you can set their values. diff --git a/README.md b/README.md index 7981ab6..6083ce0 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,12 @@ You can define multiple tables in a single snippet. You can also point to the ta For more comprehensive documentation head to [hypersphere.blog/sql-seal](https://hypersphere.blog/sql-seal). # Disclaimer -The plugin authors do not take any resposibility for any potential data loss. Always backup your files before usage. That said, plugin does not modify any files in the Vault so you should be fine :) +The plugin authors do not take any responsibility for any potential data loss. Always backup your files before usage. This plugin may modify files in your vault in the following situations (the list might not be exhaustive): + +- **.sql and .sqlseal files**: Variable values are saved as comments at the end of these files +- **Markdown files**: When interacting with task using `tasks` table, the plugin will update source markdown files. + +Please ensure you have proper backups before using this plugin. # Stay in Touch! diff --git a/src/modules/editor/codeblockHandler/CodeblockProcessor.ts b/src/modules/editor/codeblockHandler/CodeblockProcessor.ts index 8fe47d3..b5db450 100644 --- a/src/modules/editor/codeblockHandler/CodeblockProcessor.ts +++ b/src/modules/editor/codeblockHandler/CodeblockProcessor.ts @@ -146,12 +146,20 @@ export class CodeblockProcessor extends MarkdownRenderChild { }; } + // Merge with context frontmatter (used for Explorer variables) + variables = { + ...variables, + ...this.ctx.frontmatter, + }; + + if (this.flags.explain) { // Rendering explain const result = await this.db.explain(transformedQuery, variables); this.explainEl.textContent = result; } + const { data, columns } = (await this.db.select( transformedQuery, variables, diff --git a/src/modules/explorer/Editor.ts b/src/modules/explorer/Editor.ts index 7c4ccae..8bcabc6 100644 --- a/src/modules/explorer/Editor.ts +++ b/src/modules/explorer/Editor.ts @@ -9,33 +9,72 @@ import { activateView } from "./activateView"; import { App } from "obsidian"; import { GLOBAL_TABLES_VIEW_TYPE } from "../globalTables/GlobalTablesView"; import { GridApi } from "ag-grid-community"; +import { SqlVariableParser } from "./variables/SqlVariableParser"; +import { VariableInputInterface } from "./variables/VariableInputInterface"; +import { VariablePersistence } from "./variables/VariablePersistence"; +import { parseWithDefaults, ParserResult } from "../editor/parser"; +import { RendererRegistry } from "../editor/renderer/rendererRegistry"; type CodeblockProcessorWrapper = ( el: HTMLElement, source: string, + variables?: Record ) => Promise; const DEFAULT_QUERY = "SELECT *\nFROM files\nLIMIT 10"; export class Editor { + private globalKeyHandler?: (event: KeyboardEvent) => void; + private containerElement?: HTMLElement; + constructor( private codeblockProcessorGenerator: CodeblockProcessorWrapper, private viewPluginGenerator: ViewPluginGeneratorType, private app: App, private query: string = DEFAULT_QUERY, - private db: MemoryDatabase | null = null - ) {} + private db: MemoryDatabase | null = null, + private isTextFile: boolean = false, + private rendererRegistry?: RendererRegistry + ) { + // Extract and load variables from the initial query content (only for text files) + if (this.isTextFile) { + this.loadVariablesFromContent(); + } else { + this.fullContent = this.query; + } + } codeblockElement: HTMLElement | null = null; + variableInterface: VariableInputInterface | null = null; + private currentVariableValues: Record = {}; + private fullContent: string = ""; // Store full content including variable comments render(el: HTMLElement) { el.empty(); const menuBar = new EditorMenuBar(!!this.db); const c = el.createDiv({ cls: "sqlseal-explorer-container" }); + this.containerElement = c; menuBar.render(c); + + // Setup global CMD+R handler + this.setupGlobalKeyHandler(); const grid = c.createDiv({ cls: "sqlseal-explorer-grid-container" }); const codeSidebar = grid.createDiv({ cls: "sqlseal-explorer-code" }); - codeSidebar.classList.add("cm-sqlseal-explorer"); - // codeSidebar.textContent = "CODE" + + // Create code editor container (first) + const editorContainer = codeSidebar.createDiv({ cls: "cm-sqlseal-explorer" }); + + // Create variables interface container (second, at bottom) + const variablesContainer = codeSidebar.createDiv({ cls: "sqlseal-variables-container" }); + this.variableInterface = new VariableInputInterface(variablesContainer); + + // Setup variable change handler + this.variableInterface.onChange((values) => { + this.currentVariableValues = values; + // Note: Variables will be saved when file is saved + // Auto-run query when variables change (optional behavior) + // this.play(); + }); + const rightPane = grid.createDiv({ cls: 'sqlseal-explorer-right-pane' }) const contentSidebar = rightPane.createDiv({ cls: "sqlseal-explorer-render" }); const structure = rightPane.createDiv({ cls: 'sqlseal-explorer-structure' }) @@ -43,8 +82,14 @@ export class Editor { this.codeblockElement = contentSidebar; - this.createEditor(codeSidebar); + this.createEditor(editorContainer); this.createCodeblockProcessor(this.codeblockElement, this.query); + this.updateVariableInterface(); + + // Load saved variable values into the interface + if (this.variableInterface && Object.keys(this.currentVariableValues).length > 0) { + this.variableInterface.setValues(this.currentVariableValues); + } if (this.db) { const vis = new SchemaVisualiser(this.db) @@ -77,8 +122,8 @@ export class Editor { }) } - createCodeblockProcessor(el: HTMLElement, source: string) { - return this.codeblockProcessorGenerator(el, source); + createCodeblockProcessor(el: HTMLElement, source: string, variables?: Record) { + return this.codeblockProcessorGenerator(el, source, variables); } editor: EditorView; @@ -87,12 +132,16 @@ export class Editor { doc: this.query, extensions: [ // this.createCustomLanguage(), - // this.createChangeListener(), + this.createChangeListener(), this.createKeyBindings(), this.viewPluginGenerator(true), EditorView.theme({ - "&": { height: "100%" }, - ".cm-scroller": { fontFamily: "monospace" }, + "&": { + height: "100%" + }, + ".cm-scroller": { + fontFamily: "monospace" + }, ".cm-content": { caretColor: "var(--color-base-100)", }, @@ -106,6 +155,56 @@ export class Editor { }); } + createChangeListener() { + return EditorView.updateListener.of((update) => { + if (update.docChanged) { + // Update variable interface when query changes + this.updateVariableInterface(); + } + }); + } + + private updateVariableInterface() { + if (!this.variableInterface) return; + + try { + const currentContent = this.getCurrentQuery(); + + // Use OHM parser to extract actual SQL query from the codeblock + let extractedQuery = currentContent; + if (this.rendererRegistry) { + try { + const defaults: ParserResult = { + flags: { refresh: false, explain: false }, + query: "", + renderer: { options: "", type: "GRID" }, + tables: [], + }; + + const parsed = parseWithDefaults( + currentContent, + this.rendererRegistry.getViewDefinitions(), + defaults, + this.rendererRegistry.flags + ); + + // Use the extracted query if available, otherwise fall back to current content + extractedQuery = parsed.query || currentContent; + } catch (ohmError) { + console.warn('[SQLSeal Variables] OHM parsing failed, using raw content:', ohmError); + // Fall back to using the raw content + } + } + + const variables = SqlVariableParser.extractVariables(extractedQuery); + this.variableInterface.setVariables(variables); + } catch (error) { + // If parsing fails, keep previous variables and don't clear the interface + console.warn('[SQLSeal Variables] Failed to parse variables, keeping previous values:', error); + // Don't call clear() - this preserves the existing variables and user input + } + } + createKeyBindings() { return keymap.of([ { @@ -120,8 +219,15 @@ export class Editor { async play() { this.query = this.editor.state.doc.toString(); + if (this.codeblockElement) { - const processor = await this.createCodeblockProcessor(this.codeblockElement, this.query); + // Prepare variables for SQL execution + const sqlVariables = Object.keys(this.currentVariableValues).length > 0 + ? SqlVariableParser.createParameterObject(this.currentVariableValues) + : undefined; + + + await this.createCodeblockProcessor(this.codeblockElement, this.query, sqlVariables); // const renderer = processor.renderer // if ('communicator' in renderer && 'gridApi' in (renderer as any)['communicator']) { // const api: GridApi = (renderer.communicator as any).gridApi @@ -134,16 +240,80 @@ export class Editor { return this.editor?.state.doc.toString() || this.query; } + /** + * Get the current query content combined with variable definitions + * Only includes variables for text files (.sql/.sqlseal) + */ + getFullContent(): string { + const cleanQuery = this.getCurrentQuery(); + // Only add variables for text files, never for database files + if (this.isTextFile) { + return VariablePersistence.updateVariableValues(cleanQuery, this.currentVariableValues); + } else { + return cleanQuery; + } + } + setQuery(newQuery: string) { - this.query = newQuery; + // Store full content and extract clean query (only for text files) + this.fullContent = newQuery; + + if (this.isTextFile) { + this.loadVariablesFromContent(); + // Set clean query in editor + const cleanQuery = VariablePersistence.getCleanSqlContent(newQuery); + this.query = cleanQuery; + } else { + // For database files, use content as-is + this.query = newQuery; + } + if (this.editor) { this.editor.dispatch({ changes: { from: 0, to: this.editor.state.doc.length, - insert: newQuery + insert: this.query } }); } } + + /** + * Load variables from the current content + */ + private loadVariablesFromContent() { + this.fullContent = this.query; + + // Extract variable values from content + const savedVariables = VariablePersistence.extractVariableValues(this.fullContent); + this.currentVariableValues = savedVariables; + + // Get clean SQL content for editor + this.query = VariablePersistence.getCleanSqlContent(this.fullContent); + + } + + private setupGlobalKeyHandler() { + if (!this.containerElement) return; + + this.globalKeyHandler = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'r') { + event.preventDefault(); + event.stopPropagation(); + this.play(); + } + }; + + this.containerElement.addEventListener('keydown', this.globalKeyHandler); + // Make the container focusable so it can receive keyboard events + this.containerElement.setAttribute('tabindex', '0'); + } + + cleanup() { + if (this.globalKeyHandler && this.containerElement) { + this.containerElement.removeEventListener('keydown', this.globalKeyHandler); + this.globalKeyHandler = undefined; + } + } } diff --git a/src/modules/explorer/SQLSealFileView.ts b/src/modules/explorer/SQLSealFileView.ts index 86782f5..4567520 100644 --- a/src/modules/explorer/SQLSealFileView.ts +++ b/src/modules/explorer/SQLSealFileView.ts @@ -55,7 +55,14 @@ export class SQLSealFileView extends TextFileView { getViewData(): string { if (this.editor) { - return this.editor.getCurrentQuery(); + // CRITICAL: Only return full content with variables for SQL text files + // NEVER modify SQLite database files + if (this.file && (this.file.extension === 'sql' || this.file.extension === 'sqlseal')) { + return this.editor.getFullContent(); + } else { + // For database files, return clean query only + return this.editor.getCurrentQuery(); + } } return this.fileContent; } @@ -104,11 +111,11 @@ export class SQLSealFileView extends TextFileView { } private async render(initialQuery: string) { - const codeblockProcessorGenerator = async (el: HTMLElement, source: string) => { + const codeblockProcessorGenerator = async (el: HTMLElement, source: string, variables?: Record) => { const ctx: MarkdownPostProcessorContext = { docId: "", sourcePath: this.file?.path || "", - frontmatter: {}, + frontmatter: variables || {}, } as any; // Create a database adapter to handle both MemoryDatabase and SqlSealDatabase @@ -148,12 +155,17 @@ export class SQLSealFileView extends TextFileView { return processor; }; + // Determine if this is a text file that should support variables + const isTextFile = Boolean(this.file && (this.file.extension === 'sql' || this.file.extension === 'sqlseal')); + this.editor = new Editor( codeblockProcessorGenerator, this.viewPluginGenerator, this.app, initialQuery, - this.fileDb // Pass the file database (only for sqlite files) + this.fileDb, // Pass the file database (only for sqlite files) + isTextFile, // Only enable variables for SQL text files + this.rendererRegistry // Pass renderer registry for OHM parsing ); // Override the editor's play function to include save functionality @@ -211,7 +223,26 @@ export class SQLSealFileView extends TextFileView { return 'database'; } + async save(): Promise { + // CRITICAL: NEVER save database files - only save SQL text files + if (!this.file) return; + + const ext = this.file.extension.toLowerCase(); + if (ext === 'sqlite' || ext === 'db') { + console.warn('[SQLSeal] Prevented saving database file:', this.file.path); + return; // Do not save database files + } + + // Only save SQL text files + if (ext === 'sql' || ext === 'sqlseal') { + await super.save(); + } + } + async onClose() { // MemoryDatabase doesn't need explicit disconnection + if (this.editor) { + this.editor.cleanup(); + } } } \ No newline at end of file diff --git a/src/modules/explorer/explorer/ExplorerView.ts b/src/modules/explorer/explorer/ExplorerView.ts index dfaa490..024d720 100644 --- a/src/modules/explorer/explorer/ExplorerView.ts +++ b/src/modules/explorer/explorer/ExplorerView.ts @@ -28,6 +28,7 @@ export class ExplorerView extends ItemView { super(leaf); } private editor: EditorView; + private sqlSealEditor: Editor; getViewType() { return "sqlseal-explorer-view"; } @@ -42,11 +43,11 @@ export class ExplorerView extends ItemView { const content = this.contentEl; - const codeblockProcessorGenerator = async (el: HTMLElement, source: string) => { + const codeblockProcessorGenerator = async (el: HTMLElement, source: string, variables?: Record) => { const ctx: MarkdownPostProcessorContext = { docId: "", sourcePath: "", - frontmatter: {}, + frontmatter: variables || {}, } as any; const processor = new CodeblockProcessor( @@ -75,8 +76,14 @@ export class ExplorerView extends ItemView { return processor } - const editor = new Editor(codeblockProcessorGenerator, this.viewPluginGenerator, this.app) + this.sqlSealEditor = new Editor(codeblockProcessorGenerator, this.viewPluginGenerator, this.app, undefined, undefined, false, this.rendererRegistry) - editor.render(content) + this.sqlSealEditor.render(content) + } + + async onClose() { + if (this.sqlSealEditor) { + this.sqlSealEditor.cleanup(); + } } } diff --git a/src/modules/explorer/explorer/style.scss b/src/modules/explorer/explorer/style.scss index 9ef732b..9b24169 100644 --- a/src/modules/explorer/explorer/style.scss +++ b/src/modules/explorer/explorer/style.scss @@ -12,13 +12,25 @@ display: grid; grid-template-columns: 50% 50%; gap: 1em; - height: 100%; width: 100%; padding: 1em; + height: 100%; + overflow: hidden; } .sqlseal-explorer-code { - // background: orange; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + + .cm-sqlseal-explorer { + flex: 1; + min-height: 0; // Allow flex item to shrink + overflow: hidden; + height: 100%; + max-height: 100%; + } } .sqlseal-explorer-render { @@ -81,3 +93,66 @@ justify-content: center; } } + +// Variable input interface styles +.sqlseal-variables-container { + margin-top: 8px; + padding: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-secondary); + display: none; + flex-shrink: 0; // Don't shrink below content size + max-height: 40vh; // Limit maximum height to prevent overflow + overflow-y: auto; // Allow scrolling if too many variables +} + +.sqlseal-variables-header h4 { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 600; + color: var(--text-normal); +} + +.sqlseal-variables-inputs { + display: flex; + flex-direction: column; +} + +.sqlseal-variable-field { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.sqlseal-variable-field:last-child { + margin-bottom: 0; +} + +.sqlseal-variable-label { + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + font-family: var(--font-monospace); + white-space: nowrap; + min-width: fit-content; + flex-shrink: 0; +} + +.sqlseal-variable-input { + flex: 1; + padding: 6px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 3px; + background-color: var(--background-primary); + color: var(--text-normal); + font-size: 13px; + min-width: 0; // Allow input to shrink +} + +.sqlseal-variable-input:focus { + outline: none; + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} diff --git a/src/modules/explorer/variables/SqlVariableParser.test.ts b/src/modules/explorer/variables/SqlVariableParser.test.ts new file mode 100644 index 0000000..9d987c5 --- /dev/null +++ b/src/modules/explorer/variables/SqlVariableParser.test.ts @@ -0,0 +1,157 @@ +import { SqlVariableParser } from './SqlVariableParser'; + +describe('SqlVariableParser', () => { + describe('extractVariables', () => { + it('should extract single variable from simple query', () => { + const query = 'SELECT * FROM files WHERE name = @varA'; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['varA']); + }); + + it('should extract multiple variables from query', () => { + const query = 'SELECT * FROM files WHERE name = @varA AND path LIKE @varB'; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['varA', 'varB']); + }); + + it('should extract variables and sort them alphabetically', () => { + const query = 'SELECT * FROM files WHERE name = @zebra AND path = @alpha AND size > @beta'; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['alpha', 'beta', 'zebra']); + }); + + it('should handle duplicate variables by deduplicating', () => { + const query = 'SELECT * FROM files WHERE (name = @varA OR path = @varA) AND size > @varB'; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['varA', 'varB']); + }); + + it('should return empty array for query without variables', () => { + const query = 'SELECT * FROM files WHERE name = "test"'; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual([]); + }); + + it('should handle variables with underscores and numbers', () => { + const query = 'SELECT * FROM files WHERE name = @var_123 AND path = @test_var_2'; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['test_var_2', 'var_123']); + }); + + it('should handle variables in complex queries with CTEs', () => { + const query = ` + WITH filtered_files AS ( + SELECT * FROM files + WHERE name LIKE @pattern + ) + SELECT * FROM filtered_files + WHERE size > @minSize AND path = @targetPath + `; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['minSize', 'pattern', 'targetPath']); + }); + + it('should handle variables in subqueries', () => { + const query = ` + SELECT * FROM files + WHERE id IN ( + SELECT file_id FROM tags + WHERE tag = @tagName + ) AND name = @fileName + `; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['fileName', 'tagName']); + }); + + it('should handle variables in JOIN conditions', () => { + const query = ` + SELECT f.name, t.tag + FROM files f + JOIN tags t ON f.id = t.file_id + WHERE f.path = @basePath AND t.tag = @filterTag + `; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['basePath', 'filterTag']); + }); + + it('should handle variables in window functions', () => { + const query = ` + SELECT name, + ROW_NUMBER() OVER (ORDER BY name) as rn + FROM files + WHERE created_date >= @startDate + `; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['startDate']); + }); + + it('should handle variables in aggregate functions', () => { + const query = ` + SELECT COUNT(*) as file_count + FROM files + WHERE size > @minSize + GROUP BY CASE WHEN size > @largeFileThreshold THEN 'large' ELSE 'small' END + `; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['largeFileThreshold', 'minSize']); + }); + + it('should ignore @ symbols that are not variables (in strings)', () => { + const query = `SELECT * FROM files WHERE name = '@notAVariable' AND path = @realVariable`; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['realVariable']); + }); + + it('should handle variables that start with underscore', () => { + const query = 'SELECT * FROM files WHERE name = @_privateVar AND id = @_id123'; + const variables = SqlVariableParser.extractVariables(query); + expect(variables).toEqual(['_id123', '_privateVar']); + }); + }); + + describe('hasVariables', () => { + it('should return true for query with variables', () => { + const query = 'SELECT * FROM files WHERE name = @varA'; + expect(SqlVariableParser.hasVariables(query)).toBe(true); + }); + + it('should return false for query without variables', () => { + const query = 'SELECT * FROM files WHERE name = "test"'; + expect(SqlVariableParser.hasVariables(query)).toBe(false); + }); + + it('should return true for query with multiple variables', () => { + const query = 'SELECT * FROM files WHERE name = @varA AND path = @varB'; + expect(SqlVariableParser.hasVariables(query)).toBe(true); + }); + }); + + describe('createParameterObject', () => { + it('should create parameter object without @ prefixes', () => { + const values = { varA: 'testValue', varB: 'anotherValue' }; + const params = SqlVariableParser.createParameterObject(values); + expect(params).toEqual({ + varA: 'testValue', + varB: 'anotherValue' + }); + }); + + it('should handle empty values object', () => { + const values = {}; + const params = SqlVariableParser.createParameterObject(values); + expect(params).toEqual({}); + }); + + it('should handle values with special characters', () => { + const values = { + searchTerm: "test's file", + path: '/path/with spaces/file.txt' + }; + const params = SqlVariableParser.createParameterObject(values); + expect(params).toEqual({ + searchTerm: "test's file", + path: '/path/with spaces/file.txt' + }); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/explorer/variables/SqlVariableParser.ts b/src/modules/explorer/variables/SqlVariableParser.ts new file mode 100644 index 0000000..1ed301e --- /dev/null +++ b/src/modules/explorer/variables/SqlVariableParser.ts @@ -0,0 +1,67 @@ +import { parse, cstVisitor } from 'sql-parser-cst'; + +/** + * Utility class for parsing SQL variables from query strings using sql-parser-cst + * Detects variables in the format @variableName + */ +export class SqlVariableParser { + /** + * Extract all unique variables from a SQL query string + * @param query - The SQL query string + * @returns Array of unique variable names (without @ prefix) + */ + static extractVariables(query: string): string[] { + + const cst = parse(query, { + dialect: 'sqlite', + includeSpaces: true, + includeComments: true, + includeNewlines: true, + paramTypes: ['@name'] + }); + + const variables = new Set(); + + const variableVisitor = cstVisitor({ + parameter: (param) => { + // Parameters in sql-parser-cst - use text property + const paramText = param.text || ''; + + if (paramText.startsWith('@')) { + const varName = paramText.substring(1); + variables.add(varName); + } + } + }); + + variableVisitor(cst); + + const result = Array.from(variables).sort(); + return result; + } + + /** + * Check if a query contains any variables + * @param query - The SQL query string + * @returns True if query contains variables + */ + static hasVariables(query: string): boolean { + return this.extractVariables(query).length > 0; + } + + /** + * Convert user input values to parameter format (without @ prefix) + * @param values - Map of variable names to their string values + * @returns Parameter object compatible with frontmatter format + */ + static createParameterObject(values: Record): Record { + const params: Record = {}; + + for (const [key, value] of Object.entries(values)) { + // Don't add @ prefix - the system will add it automatically + params[key] = value; + } + + return params; + } +} \ No newline at end of file diff --git a/src/modules/explorer/variables/VariableInputInterface.ts b/src/modules/explorer/variables/VariableInputInterface.ts new file mode 100644 index 0000000..5f8272c --- /dev/null +++ b/src/modules/explorer/variables/VariableInputInterface.ts @@ -0,0 +1,154 @@ +/** + * Interface for managing SQL variable inputs in the SQLSeal Explorer + * Creates input fields for detected variables and manages their values + */ +export class VariableInputInterface { + private container: HTMLElement; + private variables: string[] = []; + private values: Record = {}; + private changeCallback: (values: Record) => void = () => {}; + + constructor(container: HTMLElement) { + this.container = container; + } + + /** + * Set the callback function to be called when variable values change + * @param callback - Function to call with updated variable values + */ + onChange(callback: (values: Record) => void): void { + this.changeCallback = callback; + } + + /** + * Update the interface with new variables + * @param variables - Array of variable names (without @ prefix) + */ + setVariables(variables: string[]): void { + + // Only update if variables actually changed to avoid unnecessary re-renders + if (this.areVariablesEqual(this.variables, variables)) { + return; + } + + this.variables = variables; + + // Preserve existing values for variables that still exist + const newValues: Record = {}; + for (const variable of variables) { + newValues[variable] = this.values[variable] || ''; + } + this.values = newValues; + + this.render(); + } + + /** + * Check if two variable arrays are equal + * @param arr1 - First array + * @param arr2 - Second array + * @returns True if arrays contain the same variables + */ + private areVariablesEqual(arr1: string[], arr2: string[]): boolean { + if (arr1.length !== arr2.length) return false; + const sorted1 = [...arr1].sort(); + const sorted2 = [...arr2].sort(); + return sorted1.every((val, index) => val === sorted2[index]); + } + + /** + * Get current variable values + * @returns Object mapping variable names to their values + */ + getValues(): Record { + return { ...this.values }; + } + + /** + * Set variable values programmatically + * @param values - Object mapping variable names to their values + */ + setValues(values: Record): void { + this.values = { ...this.values, ...values }; + this.render(); + } + + /** + * Show the interface + */ + show(): void { + this.container.style.display = 'block'; + } + + /** + * Hide the interface + */ + hide(): void { + this.container.style.display = 'none'; + } + + /** + * Clear the interface + */ + clear(): void { + this.variables = []; + this.values = {}; + this.render(); + } + + private render(): void { + this.container.empty(); + + if (this.variables.length === 0) { + this.hide(); + return; + } + + this.show(); + + // Create header + const header = this.container.createDiv({ cls: 'sqlseal-variables-header' }); + header.createEl('h4', { text: 'Query Variables' }); + + // Create input fields for each variable + const inputsContainer = this.container.createDiv({ cls: 'sqlseal-variables-inputs' }); + + for (const variable of this.variables) { + const fieldContainer = inputsContainer.createDiv({ cls: 'sqlseal-variable-field' }); + + // Label (inline with input) + const label = fieldContainer.createEl('label', { + cls: 'sqlseal-variable-label', + text: `@${variable}` + }); + label.setAttribute('for', `var-${variable}`); + + // Input + const input = fieldContainer.createEl('input', { + cls: 'sqlseal-variable-input', + type: 'text', + attr: { + id: `var-${variable}`, + placeholder: `Enter value...` + } + }); + + input.value = this.values[variable] || ''; + + // Add change listener + input.addEventListener('input', (event) => { + const target = event.target as HTMLInputElement; + this.values[variable] = target.value; + this.changeCallback(this.getValues()); + }); + + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + // Trigger change to notify parent that user wants to execute + this.changeCallback(this.getValues()); + } + }); + } + } +} \ No newline at end of file diff --git a/src/modules/explorer/variables/VariablePersistence.test.ts b/src/modules/explorer/variables/VariablePersistence.test.ts new file mode 100644 index 0000000..411f5f6 --- /dev/null +++ b/src/modules/explorer/variables/VariablePersistence.test.ts @@ -0,0 +1,150 @@ +import { VariablePersistence } from './VariablePersistence'; + +describe('VariablePersistence', () => { + const sampleSql = `SELECT * FROM files +WHERE name LIKE @searchTerm +AND size > @minSize`; + + const sampleVariables = { + searchTerm: '%.txt', + minSize: '1000' + }; + + const sqlWithVariables = `SELECT * FROM files +WHERE name LIKE @searchTerm +AND size > @minSize + +-- [SQLSealVariables] +-- { +-- "searchTerm": "%.txt", +-- "minSize": "1000" +-- } +-- [/SQLSealVariables]`; + + describe('extractVariableValues', () => { + it('should extract variables from content with variable comments', () => { + const variables = VariablePersistence.extractVariableValues(sqlWithVariables); + expect(variables).toEqual(sampleVariables); + }); + + it('should return empty object when no variable comments exist', () => { + const variables = VariablePersistence.extractVariableValues(sampleSql); + expect(variables).toEqual({}); + }); + + it('should handle malformed JSON gracefully', () => { + const malformedContent = `SELECT * FROM files + +-- [SQLSealVariables] +-- { invalid json } +-- [/SQLSealVariables]`; + + const variables = VariablePersistence.extractVariableValues(malformedContent); + expect(variables).toEqual({}); + }); + + it('should handle missing end marker', () => { + const incompleteContent = `SELECT * FROM files + +-- [SQLSealVariables] +-- {"test": "value"}`; + + const variables = VariablePersistence.extractVariableValues(incompleteContent); + expect(variables).toEqual({}); + }); + + it('should handle multiple variable sections by using the last one', () => { + const multipleContent = `SELECT * FROM files + +-- [SQLSealVariables] +-- {"old": "value"} +-- [/SQLSealVariables] + +SELECT * FROM other_table + +-- [SQLSealVariables] +-- {"new": "value"} +-- [/SQLSealVariables]`; + + const variables = VariablePersistence.extractVariableValues(multipleContent); + expect(variables).toEqual({ new: 'value' }); + }); + }); + + describe('getCleanSqlContent', () => { + it('should remove variable comments from content', () => { + const cleanSql = VariablePersistence.getCleanSqlContent(sqlWithVariables); + expect(cleanSql).toBe(sampleSql); + }); + + it('should return original content when no variable comments exist', () => { + const cleanSql = VariablePersistence.getCleanSqlContent(sampleSql); + expect(cleanSql).toBe(sampleSql); + }); + + it('should handle content with only variable comments', () => { + const onlyComments = `-- [SQLSealVariables] +-- {"test": "value"} +-- [/SQLSealVariables]`; + + const cleanSql = VariablePersistence.getCleanSqlContent(onlyComments); + expect(cleanSql).toBe(''); + }); + + it('should handle missing end marker by removing to end of file', () => { + const incompleteContent = `SELECT * FROM files + +-- [SQLSealVariables] +-- {"test": "value"}`; + + const cleanSql = VariablePersistence.getCleanSqlContent(incompleteContent); + expect(cleanSql).toBe('SELECT * FROM files'); + }); + }); + + describe('injectVariableValues', () => { + it('should inject variables into clean SQL content', () => { + const result = VariablePersistence.injectVariableValues(sampleSql, sampleVariables); + expect(result).toBe(sqlWithVariables); + }); + + it('should return original content when no variables provided', () => { + const result = VariablePersistence.injectVariableValues(sampleSql, {}); + expect(result).toBe(sampleSql); + }); + + it('should handle empty SQL content', () => { + const result = VariablePersistence.injectVariableValues('', sampleVariables); + expect(result).toContain('-- [SQLSealVariables]'); + expect(result).toContain('-- [/SQLSealVariables]'); + }); + }); + + describe('updateVariableValues', () => { + it('should update existing variable values', () => { + const newVariables = { searchTerm: '%.pdf', minSize: '2000' }; + const result = VariablePersistence.updateVariableValues(sqlWithVariables, newVariables); + + const extractedVars = VariablePersistence.extractVariableValues(result); + expect(extractedVars).toEqual(newVariables); + + const cleanSql = VariablePersistence.getCleanSqlContent(result); + expect(cleanSql).toBe(sampleSql); + }); + + it('should add variables to content without existing variables', () => { + const result = VariablePersistence.updateVariableValues(sampleSql, sampleVariables); + expect(result).toBe(sqlWithVariables); + }); + }); + + describe('hasVariableDefinitions', () => { + it('should return true when content has variable definitions', () => { + expect(VariablePersistence.hasVariableDefinitions(sqlWithVariables)).toBe(true); + }); + + it('should return false when content has no variable definitions', () => { + expect(VariablePersistence.hasVariableDefinitions(sampleSql)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/src/modules/explorer/variables/VariablePersistence.ts b/src/modules/explorer/variables/VariablePersistence.ts new file mode 100644 index 0000000..8d64988 --- /dev/null +++ b/src/modules/explorer/variables/VariablePersistence.ts @@ -0,0 +1,115 @@ +/** + * Utility class for persisting variable values in SQL file comments + * Variables are stored as hidden comments at the end of the file + */ +export class VariablePersistence { + private static readonly COMMENT_MARKER = '-- [SQLSealVariables]'; + private static readonly COMMENT_END_MARKER = '-- [/SQLSealVariables]'; + + /** + * Extract variable values from SQL file content + * @param content - The full SQL file content + * @returns Object mapping variable names to their values + */ + static extractVariableValues(content: string): Record { + + const markerIndex = content.lastIndexOf(this.COMMENT_MARKER); + if (markerIndex === -1) { + return {}; + } + + const endMarkerIndex = content.indexOf(this.COMMENT_END_MARKER, markerIndex); + if (endMarkerIndex === -1) { + return {}; + } + + const variableSection = content.substring( + markerIndex + this.COMMENT_MARKER.length, + endMarkerIndex + ).trim(); + + try { + // Remove comment prefixes to get clean JSON + const cleanJson = variableSection + .split('\n') + .map(line => line.replace(/^--\s?/, '').trim()) + .filter(line => line.length > 0) + .join('\n'); + + const variables = JSON.parse(cleanJson); + return variables; + } catch (error) { + console.warn('[SQLSeal Variables] Failed to parse variable JSON:', error); + return {}; + } + } + + /** + * Get SQL content without the variable comment section + * @param content - The full SQL file content + * @returns SQL content with variable comments removed + */ + static getCleanSqlContent(content: string): string { + const markerIndex = content.lastIndexOf(this.COMMENT_MARKER); + if (markerIndex === -1) { + return content; + } + + // Find the end marker or end of file + const endMarkerIndex = content.indexOf(this.COMMENT_END_MARKER, markerIndex); + if (endMarkerIndex === -1) { + // If no end marker, remove from marker to end of file + return content.substring(0, markerIndex).trimEnd(); + } + + // Remove the entire variable section including end marker + const beforeVariable = content.substring(0, markerIndex); + const afterVariable = content.substring(endMarkerIndex + this.COMMENT_END_MARKER.length); + + return (beforeVariable + afterVariable).trim(); + } + + /** + * Inject variable values into SQL content as comments + * @param sqlContent - The clean SQL content (without existing variable comments) + * @param variables - Object mapping variable names to their values + * @returns SQL content with variable comments appended + */ + static injectVariableValues(sqlContent: string, variables: Record): string { + if (Object.keys(variables).length === 0) { + return sqlContent; + } + + + const variableJson = JSON.stringify(variables, null, 2); + // Format JSON with comment prefixes for each line + const commentedJson = variableJson + .split('\n') + .map(line => line ? `-- ${line}` : '--') + .join('\n'); + + const variableComment = `\n\n${this.COMMENT_MARKER}\n${commentedJson}\n${this.COMMENT_END_MARKER}`; + + return sqlContent.trimEnd() + variableComment; + } + + /** + * Update variable values in existing SQL content + * @param content - The full SQL file content + * @param variables - Object mapping variable names to their values + * @returns Updated SQL content with new variable values + */ + static updateVariableValues(content: string, variables: Record): string { + const cleanContent = this.getCleanSqlContent(content); + return this.injectVariableValues(cleanContent, variables); + } + + /** + * Check if content contains variable definitions + * @param content - The SQL file content + * @returns True if content has variable definitions + */ + static hasVariableDefinitions(content: string): boolean { + return content.includes(this.COMMENT_MARKER); + } +} \ No newline at end of file diff --git a/src/styles/syntaxHighlight.scss b/src/styles/syntaxHighlight.scss index 9951b9b..fcedf73 100644 --- a/src/styles/syntaxHighlight.scss +++ b/src/styles/syntaxHighlight.scss @@ -146,4 +146,16 @@ border-left-width: 0; background: var(--color-base-20); padding: 8px; + height: 100%; + max-height: 100%; + overflow: hidden; +} + +.cm-sqlseal-explorer .cm-scroller { + max-height: 100%; + overflow: auto; +} + +.cm-sqlseal-explorer .cm-content { + min-height: 100%; } \ No newline at end of file From e8ed8c866b810344bee78fd91d8bfa17906ab949 Mon Sep 17 00:00:00 2001 From: Kacper Kula Date: Fri, 15 Aug 2025 14:55:42 +0100 Subject: [PATCH 2/2] chore: fixing code issue --- src/modules/explorer/SQLSealFileView.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/explorer/SQLSealFileView.ts b/src/modules/explorer/SQLSealFileView.ts index 4567520..35da33f 100644 --- a/src/modules/explorer/SQLSealFileView.ts +++ b/src/modules/explorer/SQLSealFileView.ts @@ -145,7 +145,7 @@ export class SQLSealFileView extends TextFileView { // Resizing and layout configuration for explorer const renderer = processor.renderer; - if ('communicator' in renderer && 'gridApi' in (renderer as any)['communicator']) { + if (renderer && 'communicator' in renderer && 'gridApi' in (renderer as any)['communicator']) { const api: GridApi = (renderer.communicator as any).gridApi; api.setGridOption('paginationAutoPageSize', true); api.setGridOption('domLayout', 'normal'); // Override autoHeight for proper pagination