sql writer
Some checks are pending
CI / Test (1.23) (push) Waiting to run
CI / Test (1.24) (push) Waiting to run
CI / Test (1.25) (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Build (push) Waiting to run

This commit is contained in:
2025-12-17 20:44:02 +02:00
parent 40bc0be1cb
commit 5e1448dcdb
48 changed files with 4592 additions and 950 deletions

View File

@@ -0,0 +1,274 @@
# RelSpec Template Editor for VS Code
Visual editor and tooling for RelSpec PostgreSQL migration templates.
## Features
### 1. Template Preview
- **Command**: `RelSpec: Preview Template`
- **Shortcut**: Click the preview icon in the editor title bar
- Preview your templates with sample data
- Side-by-side view of template, data, and rendered SQL
### 2. Syntax Validation
- **Command**: `RelSpec: Validate Template`
- Automatic validation on save (configurable)
- Highlights syntax errors inline
- Checks for unclosed template tags
### 3. IntelliSense
- Auto-completion for template functions
- Function signatures and documentation on hover
- Keyword completions (`if`, `range`, `template`, etc.)
### 4. Template Scaffolding
- **Command**: `RelSpec: New Template`
- Quick scaffolding for common template types:
- DDL operations
- Constraints
- Indexes
- Audit trails
- Reusable fragments
### 5. Function Library
- **Command**: `RelSpec: List Available Functions`
- Browse all available template functions
- See examples and documentation
- Quick reference for template development
## Installation
### From Source
1. Clone the RelSpec repository
2. Navigate to the extension directory:
```bash
cd vscode-extension
```
3. Install dependencies:
```bash
npm install
```
4. Compile the extension:
```bash
npm run compile
```
5. Open in VS Code:
```bash
code .
```
6. Press `F5` to launch the extension in a new window
### From VSIX (when published)
```bash
code --install-extension relspec-template-editor-0.1.0.vsix
```
## Usage
### Opening Templates
1. Open your RelSpec project in VS Code
2. Navigate to `pkg/writers/pgsql/templates/`
3. Open any `.tmpl` file
4. The extension will automatically activate
### Previewing Templates
1. Open a template file
2. Click the preview icon in the editor title bar OR
3. Run command: `RelSpec: Preview Template` (Ctrl+Shift+P)
4. The preview pane will show:
- Your template source
- Sample data (configurable)
- Rendered SQL output
### Configuring Sample Data
Set custom sample data for preview in settings:
```json
{
"relspec.previewSampleData": {
"SchemaName": "public",
"TableName": "users",
"ColumnName": "email",
"Columns": [
{"Name": "id", "Type": "integer", "NotNull": true},
{"Name": "email", "Type": "text"}
]
}
}
```
### Creating New Templates
1. Run command: `RelSpec: New Template`
2. Select template type
3. Enter template name
4. The extension creates a scaffolded template
5. Edit and customize
### Using IntelliSense
Type `{{` to trigger auto-completion:
```gotmpl
{{upper // Shows function signature and documentation
{{. // Shows available fields from data structure
```
Hover over function names to see:
- Function signature
- Description
- Usage examples
## Configuration
Available settings:
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `relspec.templatePath` | string | `pkg/writers/pgsql/templates` | Path to template directory |
| `relspec.autoValidate` | boolean | `true` | Validate templates on save |
| `relspec.showDataStructures` | boolean | `true` | Show data structure hints in preview |
| `relspec.previewSampleData` | object | `{}` | Sample JSON data for preview |
## Keyboard Shortcuts
| Command | Shortcut | Description |
|---------|----------|-------------|
| Preview Template | N/A | Click preview icon in title bar |
| Validate Template | N/A | Use command palette |
| New Template | N/A | Use command palette |
## Template Functions Reference
The extension provides IntelliSense for all RelSpec template functions:
### String Manipulation
- `upper` - Convert to uppercase
- `lower` - Convert to lowercase
- `title` - Title case
- `snake_case` - Convert to snake_case
- `camelCase` - Convert to camelCase
### SQL Formatting
- `indent` - Indent text
- `quote` - Quote for SQL
- `escape` - Escape special characters
- `safe_identifier` - Make safe SQL identifier
### Type Conversion
- `goTypeToSQL` - Go type → SQL type
- `sqlTypeToGo` - SQL type → Go type
- `isNumeric` - Check if numeric type
- `isText` - Check if text type
### Collection Helpers
- `first` - First element
- `last` - Last element
- `filter` - Filter elements
- `mapFunc` - Map function
- `join_with` - Join with separator
- `join` - Join strings
See full documentation: [Template Functions](../pkg/writers/pgsql/TEMPLATE_FUNCTIONS.md)
## Code Snippets
The extension includes snippets for common patterns:
| Prefix | Description |
|--------|-------------|
| `tmpl-define` | Define a new template |
| `tmpl-template` | Use a template |
| `tmpl-block` | Define a block |
| `tmpl-if` | If statement |
| `tmpl-range` | Range loop |
| `tmpl-with` | With statement |
## Development
### Building
```bash
npm run compile
```
### Watching
```bash
npm run watch
```
### Linting
```bash
npm run lint
```
### Testing
```bash
npm test
```
## Troubleshooting
### Extension Not Activating
**Problem**: Extension doesn't activate when opening .tmpl files
**Solution**:
1. Check that file has `.tmpl` extension
2. Reload VS Code window (Ctrl+Shift+P → "Reload Window")
3. Check extension is enabled in Extensions panel
### Preview Not Working
**Problem**: Preview shows error or doesn't update
**Solution**:
1. Ensure RelSpec binary is in PATH
2. Check template syntax is valid
3. Verify sample data is valid JSON
### IntelliSense Not Working
**Problem**: Auto-completion doesn't trigger
**Solution**:
1. Type `{{` to trigger
2. Check language mode is set to "Go Template"
3. Reload window
## Contributing
Contributions welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Submit a pull request
## License
Same as RelSpec project license.
## Links
- [RelSpec Documentation](../README.md)
- [Template Documentation](../pkg/writers/pgsql/TEMPLATES.md)
- [Template Inheritance](../pkg/writers/pgsql/TEMPLATE_INHERITANCE.md)
- [Issue Tracker](https://github.com/yourorg/relspec/issues)
## Changelog
### 0.1.0 (Initial Release)
- Template preview with sample data
- Syntax validation
- IntelliSense for functions
- Template scaffolding
- Function library browser

View File

@@ -0,0 +1,119 @@
{
"name": "relspec-template-editor",
"displayName": "RelSpec Template Editor",
"description": "Visual editor for RelSpec PostgreSQL migration templates",
"version": "0.1.0",
"engines": {
"vscode": "^1.80.0"
},
"categories": [
"Programming Languages",
"Formatters",
"Snippets"
],
"activationEvents": [
"onLanguage:gotmpl",
"onCommand:relspec.previewTemplate",
"onCommand:relspec.validateTemplate",
"onCommand:relspec.newTemplate"
],
"main": "./out/extension.js",
"contributes": {
"languages": [
{
"id": "gotmpl",
"aliases": ["Go Template", "gotmpl"],
"extensions": [".tmpl"],
"configuration": "./language-configuration.json"
}
],
"grammars": [
{
"language": "gotmpl",
"scopeName": "source.gotmpl",
"path": "./syntaxes/gotmpl.tmLanguage.json"
}
],
"commands": [
{
"command": "relspec.previewTemplate",
"title": "RelSpec: Preview Template",
"icon": "$(preview)"
},
{
"command": "relspec.validateTemplate",
"title": "RelSpec: Validate Template"
},
{
"command": "relspec.newTemplate",
"title": "RelSpec: New Template"
},
{
"command": "relspec.listFunctions",
"title": "RelSpec: List Available Functions"
}
],
"menus": {
"editor/title": [
{
"command": "relspec.previewTemplate",
"when": "resourceLangId == gotmpl",
"group": "navigation"
}
],
"editor/context": [
{
"command": "relspec.validateTemplate",
"when": "resourceLangId == gotmpl",
"group": "relspec"
}
]
},
"configuration": {
"title": "RelSpec Template Editor",
"properties": {
"relspec.templatePath": {
"type": "string",
"default": "pkg/writers/pgsql/templates",
"description": "Path to template directory"
},
"relspec.autoValidate": {
"type": "boolean",
"default": true,
"description": "Automatically validate templates on save"
},
"relspec.showDataStructures": {
"type": "boolean",
"default": true,
"description": "Show data structure hints in preview"
},
"relspec.previewSampleData": {
"type": "string",
"default": "{}",
"description": "Sample JSON data for template preview"
}
}
},
"snippets": [
{
"language": "gotmpl",
"path": "./snippets/relspec.json"
}
]
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"lint": "eslint src --ext ts"
},
"devDependencies": {
"@types/vscode": "^1.80.0",
"@types/node": "16.x",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"eslint": "^8.41.0",
"typescript": "^5.0.4"
},
"dependencies": {}
}

View File

@@ -0,0 +1,438 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
// Extension activation
export function activate(context: vscode.ExtensionContext) {
console.log('RelSpec Template Editor activated');
// Register commands
context.subscriptions.push(
vscode.commands.registerCommand('relspec.previewTemplate', previewTemplate)
);
context.subscriptions.push(
vscode.commands.registerCommand('relspec.validateTemplate', validateTemplate)
);
context.subscriptions.push(
vscode.commands.registerCommand('relspec.newTemplate', newTemplate)
);
context.subscriptions.push(
vscode.commands.registerCommand('relspec.listFunctions', listFunctions)
);
// Register completion provider
const completionProvider = vscode.languages.registerCompletionItemProvider(
'gotmpl',
new TemplateCompletionProvider(),
'{{', '.'
);
context.subscriptions.push(completionProvider);
// Register hover provider
const hoverProvider = vscode.languages.registerHoverProvider(
'gotmpl',
new TemplateHoverProvider()
);
context.subscriptions.push(hoverProvider);
// Auto-validate on save
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument((document) => {
const config = vscode.workspace.getConfiguration('relspec');
if (config.get('autoValidate') && document.languageId === 'gotmpl') {
validateTemplate();
}
})
);
}
// Preview template with sample data
async function previewTemplate() {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document.languageId !== 'gotmpl') {
vscode.window.showErrorMessage('Please open a .tmpl file');
return;
}
const panel = vscode.window.createWebviewPanel(
'templatePreview',
'Template Preview',
vscode.ViewColumn.Beside,
{
enableScripts: true
}
);
const templateContent = editor.document.getText();
const config = vscode.workspace.getConfiguration('relspec');
const sampleDataString = config.get<string>('previewSampleData', '{}');
try {
const sampleData = JSON.parse(sampleDataString);
const preview = await renderTemplate(templateContent, sampleData);
panel.webview.html = getWebviewContent(templateContent, preview, sampleData);
} catch (error) {
panel.webview.html = getErrorWebviewContent(String(error));
}
}
// Validate template syntax
async function validateTemplate() {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document.languageId !== 'gotmpl') {
return;
}
const templateContent = editor.document.getText();
const diagnostics: vscode.Diagnostic[] = [];
// Check for common template errors
const errors = checkTemplateSyntax(templateContent);
for (const error of errors) {
const range = new vscode.Range(
error.line,
error.column,
error.line,
error.column + error.length
);
const diagnostic = new vscode.Diagnostic(
range,
error.message,
vscode.DiagnosticSeverity.Error
);
diagnostics.push(diagnostic);
}
// Update diagnostics
const collection = vscode.languages.createDiagnosticCollection('relspec');
collection.set(editor.document.uri, diagnostics);
if (diagnostics.length === 0) {
vscode.window.showInformationMessage('Template is valid');
}
}
// Create new template from scaffold
async function newTemplate() {
const templateType = await vscode.window.showQuickPick(
[
{ label: 'DDL Template', value: 'ddl' },
{ label: 'Constraint Template', value: 'constraint' },
{ label: 'Index Template', value: 'index' },
{ label: 'Audit Template', value: 'audit' },
{ label: 'Custom Fragment', value: 'fragment' }
],
{ placeHolder: 'Select template type' }
);
if (!templateType) {
return;
}
const templateName = await vscode.window.showInputBox({
prompt: 'Enter template name',
placeHolder: 'my_template'
});
if (!templateName) {
return;
}
const scaffold = getTemplateScaffold(templateType.value, templateName);
const config = vscode.workspace.getConfiguration('relspec');
const templatePath = config.get<string>('templatePath', 'pkg/writers/pgsql/templates');
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders) {
vscode.window.showErrorMessage('No workspace folder open');
return;
}
const filePath = path.join(workspaceFolders[0].uri.fsPath, templatePath, `${templateName}.tmpl`);
fs.writeFileSync(filePath, scaffold);
const document = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(document);
}
// List available template functions
async function listFunctions() {
const functions = getTemplateFunctions();
const panel = vscode.window.createWebviewPanel(
'functionsList',
'RelSpec Template Functions',
vscode.ViewColumn.Beside,
{}
);
panel.webview.html = getFunctionsWebviewContent(functions);
}
// Completion provider for template functions and keywords
class TemplateCompletionProvider implements vscode.CompletionItemProvider {
provideCompletionItems(
document: vscode.TextDocument,
position: vscode.Position
): vscode.CompletionItem[] {
const linePrefix = document.lineAt(position).text.substr(0, position.character);
if (!linePrefix.endsWith('{{')) {
return [];
}
const functions = getTemplateFunctions();
const completionItems: vscode.CompletionItem[] = [];
// Add function completions
for (const func of functions) {
const item = new vscode.CompletionItem(func.name, vscode.CompletionItemKind.Function);
item.detail = func.signature;
item.documentation = new vscode.MarkdownString(func.description);
item.insertText = new vscode.SnippetString(`${func.name} \${1:arg}}}`)
;
completionItems.push(item);
}
// Add keyword completions
const keywords = ['if', 'else', 'end', 'range', 'with', 'define', 'template', 'block'];
for (const keyword of keywords) {
const item = new vscode.CompletionItem(keyword, vscode.CompletionItemKind.Keyword);
completionItems.push(item);
}
return completionItems;
}
}
// Hover provider for template functions
class TemplateHoverProvider implements vscode.HoverProvider {
provideHover(
document: vscode.TextDocument,
position: vscode.Position
): vscode.Hover | undefined {
const range = document.getWordRangeAtPosition(position);
if (!range) {
return undefined;
}
const word = document.getText(range);
const functions = getTemplateFunctions();
const func = functions.find(f => f.name === word);
if (func) {
const markdown = new vscode.MarkdownString();
markdown.appendCodeblock(func.signature, 'go');
markdown.appendMarkdown('\n\n' + func.description);
markdown.appendMarkdown('\n\n**Example:**\n');
markdown.appendCodeblock(func.example, 'gotmpl');
return new vscode.Hover(markdown);
}
return undefined;
}
}
// Helper functions
function renderTemplate(template: string, data: any): Promise<string> {
// In a real implementation, this would call the Go binary
// For now, return a placeholder
return Promise.resolve(`-- Rendered SQL would appear here\n-- Template: ${template.substring(0, 50)}...`);
}
function checkTemplateSyntax(template: string): Array<{ line: number; column: number; length: number; message: string }> {
const errors: Array<{ line: number; column: number; length: number; message: string }> = [];
// Basic syntax checking
const lines = template.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Check for unclosed {{
const openCount = (line.match(/\{\{/g) || []).length;
const closeCount = (line.match(/\}\}/g) || []).length;
if (openCount > closeCount) {
errors.push({
line: i,
column: line.indexOf('{{'),
length: 2,
message: 'Unclosed template tag'
});
}
}
return errors;
}
function getTemplateScaffold(type: string, name: string): string {
const scaffolds: Record<string, string> = {
ddl: `{{/* ${name} - DDL operation template */}}
{{- define "${name}" -}}
ALTER TABLE {{.SchemaName}}.{{.TableName}}
-- Add your DDL operation here
{{- end -}}`,
constraint: `{{/* ${name} - Constraint template */}}
{{- define "${name}" -}}
ALTER TABLE {{.SchemaName}}.{{.TableName}}
ADD CONSTRAINT {{.ConstraintName}}
-- Add constraint definition here
{{- end -}}`,
index: `{{/* ${name} - Index template */}}
{{- define "${name}" -}}
CREATE {{if .Unique}}UNIQUE {{end}}INDEX IF NOT EXISTS {{.IndexName}}
ON {{.SchemaName}}.{{.TableName}}
USING {{.IndexType}} ({{join .Columns ", "}});
{{- end -}}`,
audit: `{{/* ${name} - Audit template */}}
{{- define "${name}" -}}
-- Audit configuration for {{.TableName}}
{{- end -}}`,
fragment: `{{/* ${name} - Reusable fragment */}}
{{- define "${name}" -}}
-- Add your reusable SQL fragment here
{{- end -}}`
};
return scaffolds[type] || scaffolds.fragment;
}
function getTemplateFunctions() {
return [
{
name: 'upper',
signature: 'upper(s string) string',
description: 'Convert string to uppercase',
example: '{{upper "hello"}} // HELLO'
},
{
name: 'lower',
signature: 'lower(s string) string',
description: 'Convert string to lowercase',
example: '{{lower "HELLO"}} // hello'
},
{
name: 'snake_case',
signature: 'snake_case(s string) string',
description: 'Convert string to snake_case',
example: '{{snake_case "UserId"}} // user_id'
},
{
name: 'camelCase',
signature: 'camelCase(s string) string',
description: 'Convert string to camelCase',
example: '{{camelCase "user_id"}} // userId'
},
{
name: 'quote',
signature: 'quote(s string) string',
description: 'Quote string for SQL (escapes single quotes)',
example: '{{quote "O\'Brien"}} // \'O\'\'Brien\''
},
{
name: 'safe_identifier',
signature: 'safe_identifier(s string) string',
description: 'Make string safe for SQL identifier',
example: '{{safe_identifier "User-Id"}} // user_id'
},
{
name: 'join',
signature: 'join(slice []string, sep string) string',
description: 'Join string slice with separator',
example: '{{join .Columns ", "}}'
}
];
}
function getWebviewContent(template: string, preview: string, data: any): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Template Preview</title>
<style>
body { font-family: var(--vscode-font-family); padding: 20px; }
.section { margin-bottom: 30px; }
h2 { color: var(--vscode-foreground); border-bottom: 1px solid var(--vscode-panel-border); }
pre { background: var(--vscode-editor-background); padding: 15px; border-radius: 4px; overflow-x: auto; }
.data { color: var(--vscode-editor-foreground); }
.sql { color: var(--vscode-textPreformat-foreground); }
</style>
</head>
<body>
<div class="section">
<h2>Template</h2>
<pre class="data">${escapeHtml(template)}</pre>
</div>
<div class="section">
<h2>Sample Data</h2>
<pre class="data">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
</div>
<div class="section">
<h2>Rendered SQL</h2>
<pre class="sql">${escapeHtml(preview)}</pre>
</div>
</body>
</html>`;
}
function getErrorWebviewContent(error: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Error</title>
<style>
body { font-family: var(--vscode-font-family); padding: 20px; }
.error { color: var(--vscode-errorForeground); }
</style>
</head>
<body>
<h2 class="error">Template Error</h2>
<pre>${escapeHtml(error)}</pre>
</body>
</html>`;
}
function getFunctionsWebviewContent(functions: any[]): string {
const functionsHtml = functions.map(f => `
<div class="function">
<h3>${f.name}</h3>
<code>${f.signature}</code>
<p>${f.description}</p>
<pre>${f.example}</pre>
</div>
`).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Template Functions</title>
<style>
body { font-family: var(--vscode-font-family); padding: 20px; }
.function { margin-bottom: 30px; border-bottom: 1px solid var(--vscode-panel-border); padding-bottom: 20px; }
h3 { color: var(--vscode-foreground); margin-bottom: 10px; }
code { background: var(--vscode-textCodeBlock-background); padding: 4px 8px; border-radius: 3px; }
pre { background: var(--vscode-editor-background); padding: 15px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Available Template Functions</h1>
${functionsHtml}
</body>
</html>`;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export function deactivate() {}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"lib": ["ES2020"],
"sourceMap": true,
"rootDir": "src",
"outDir": "out",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "out"]
}