Variable editor in SQLSeal Explorer (#185)

* feat: variables editor

* chore: fixing code issue
This commit is contained in:
Kacper Kula 2025-08-15 15:57:27 +02:00 committed by GitHub
parent c4278c2a9d
commit ce1da3f9ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 981 additions and 25 deletions

View file

@ -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.

View file

@ -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!

View file

@ -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,

View file

@ -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<string, any>
) => Promise<CodeblockProcessor>;
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<string, string> = {};
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<string, any>) {
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;
}
}
}

View file

@ -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<string, any>) => {
const ctx: MarkdownPostProcessorContext = {
docId: "",
sourcePath: this.file?.path || "",
frontmatter: {},
frontmatter: variables || {},
} as any;
// Create a database adapter to handle both MemoryDatabase and SqlSealDatabase
@ -138,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
@ -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<void> {
// 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();
}
}
}

View file

@ -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<string, any>) => {
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();
}
}
}

View file

@ -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);
}

View file

@ -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'
});
});
});
});

View file

@ -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<string>();
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<string, string>): Record<string, any> {
const params: Record<string, any> = {};
for (const [key, value] of Object.entries(values)) {
// Don't add @ prefix - the system will add it automatically
params[key] = value;
}
return params;
}
}

View file

@ -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<string, string> = {};
private changeCallback: (values: Record<string, string>) => 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<string, string>) => 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<string, string> = {};
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<string, string> {
return { ...this.values };
}
/**
* Set variable values programmatically
* @param values - Object mapping variable names to their values
*/
setValues(values: Record<string, string>): 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());
}
});
}
}
}

View file

@ -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);
});
});
});

View file

@ -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<string, string> {
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, string>): 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, string>): 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);
}
}

View file

@ -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%;
}