Skip to content

Commit 71b60fc

Browse files
committed
feat(new tool): SQL Parameters Generator
Fix CorentinTh#988
1 parent 9f5792d commit 71b60fc

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

src/tools/sql-parameters/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Database } from '@vicons/tabler';
2+
import { defineTool } from '../tool';
3+
4+
export const tool = defineTool({
5+
name: 'SQL Parameters Generator',
6+
path: '/sql-parameters',
7+
description: 'Prepare SQL statement from a given parametrized SQL query and parameters',
8+
keywords: ['sql', 'select', 'insert', 'parameters'],
9+
component: () => import('./sql-parameters.vue'),
10+
icon: Database,
11+
createdAt: new Date('2025-08-15'),
12+
category: 'Development',
13+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { detectTypeAndEscape, generateSQLFromTemplate } from './sql-parameters.service';
3+
4+
describe('detectTypeAndEscape', () => {
5+
it('should return NULL for null-like values', () => {
6+
expect(detectTypeAndEscape(null)).toBe('NULL');
7+
expect(detectTypeAndEscape(undefined)).toBe('NULL');
8+
expect(detectTypeAndEscape('null')).toBe('NULL');
9+
});
10+
11+
it('should detect and return numbers', () => {
12+
expect(detectTypeAndEscape('42')).toBe(42);
13+
expect(detectTypeAndEscape('3.14')).toBe(3.14);
14+
});
15+
16+
it('should escape and wrap strings in quotes', () => {
17+
expect(detectTypeAndEscape('O\'Reilly')).toBe('\'O\'\'Reilly\'');
18+
expect(detectTypeAndEscape('hello')).toBe('\'hello\'');
19+
});
20+
});
21+
22+
describe('generateSQLFromTemplate', () => {
23+
it('should replace named parameters correctly', () => {
24+
const template = 'SELECT * FROM users WHERE name = :name AND age = @age';
25+
const params = [
26+
{ key: 'name', value: 'O\'Reilly' },
27+
{ key: 'age', value: '42' },
28+
];
29+
const { sql, error } = generateSQLFromTemplate(template, params);
30+
expect(sql).toBe('SELECT * FROM users WHERE name = \'O\'\'Reilly\' AND age = 42');
31+
expect(error).toBeNull();
32+
});
33+
34+
it('should replace positional parameters correctly', () => {
35+
const template = 'INSERT INTO products VALUES (?, ?, ?)';
36+
const params = [
37+
{ key: '0', value: 'Book' },
38+
{ key: '1', value: '19.99' },
39+
{ key: '2', value: 'null' },
40+
];
41+
const { sql, error } = generateSQLFromTemplate(template, params);
42+
expect(sql).toBe('INSERT INTO products VALUES (\'Book\', 19.99, NULL)');
43+
expect(error).toBeNull();
44+
});
45+
46+
it('should handle escaped question marks', () => {
47+
const template = 'SELECT * FROM faq WHERE question LIKE \'\\?\' OR answer = ?';
48+
const params = [{ key: '0', value: '42' }];
49+
const { sql, error } = generateSQLFromTemplate(template, params);
50+
expect(sql).toBe('SELECT * FROM faq WHERE question LIKE \'?\' OR answer = 42');
51+
expect(error).toBeNull();
52+
});
53+
54+
it('should return error for missing named parameter', () => {
55+
const template = 'SELECT * FROM users WHERE name = :name';
56+
const params: Array<{ key: string; value: string }> = [];
57+
const { sql, error } = generateSQLFromTemplate(template, params);
58+
expect(sql).toBe('');
59+
expect(error).toContain('Missing value for named parameter');
60+
});
61+
62+
it('should return error for missing positional parameter', () => {
63+
const template = 'SELECT * FROM items WHERE id = ? AND price = ?';
64+
const params = [{ key: '0', value: '1' }];
65+
const { sql, error } = generateSQLFromTemplate(template, params);
66+
expect(sql).toBe('');
67+
expect(error).toContain('Missing value for positional parameter');
68+
});
69+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export function detectTypeAndEscape(val: string | null | undefined) {
2+
if (val === null || val === undefined || val.toLowerCase?.() === 'null') {
3+
return 'NULL';
4+
}
5+
if (!Number.isNaN(Number(val)) && val.trim() !== '') {
6+
return val.includes('.') ? Number.parseFloat(val) : Number.parseInt(val);
7+
}
8+
return `'${val.replace(/'/g, '\'\'')}'`;
9+
}
10+
11+
export function generateSQLFromTemplate(template: string, parameters: Array<{ key: string; value: string }>) {
12+
const paramMap = new Map();
13+
const indexedParams: Array<{ key: string; value: string }> = [];
14+
let keyIndex = 0;
15+
parameters.forEach(({ key, value }) => {
16+
if (key !== '' && !/^\d+$/.test(key)) {
17+
paramMap.set(key, detectTypeAndEscape(value));
18+
}
19+
else {
20+
const indexKey = (keyIndex++).toString();
21+
indexedParams.push({ key: indexKey, value });
22+
}
23+
});
24+
25+
let sql = template;
26+
let error = null;
27+
28+
// Replace named parameters
29+
sql = sql.replace(/(:\w+|@\w+)/g, (match) => {
30+
const key = match.slice(1);
31+
if (!paramMap.has(key)) {
32+
error = `Missing value for named parameter "${match}"`;
33+
return match;
34+
}
35+
return paramMap.get(key);
36+
});
37+
38+
// Replace positional parameters
39+
let positionalIndex = 0;
40+
const positionalParams = indexedParams.filter(p => /^\d+$/.test(p.key));
41+
42+
sql = sql.replace(/(^|[^\\])\?/g, (_, prefix) => {
43+
const param = positionalParams[positionalIndex++];
44+
if (!param) {
45+
error = `Missing value for positional parameter at index ${positionalIndex - 1}`;
46+
return `${prefix}?`;
47+
}
48+
return `${prefix}${detectTypeAndEscape(param.value)}`;
49+
});
50+
51+
// Unescape escaped question marks
52+
sql = sql.replace(/\\\?/g, '?');
53+
54+
return { sql: error ? '' : sql, error };
55+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<script setup lang="ts">
2+
import { generateSQLFromTemplate } from './sql-parameters.service';
3+
import { useQueryParamOrStorage } from '@/composable/queryParams';
4+
5+
const sqlTemplate = useQueryParamOrStorage({ name: 'sql', storageName: 'sql-params:sql', defaultValue: '' });
6+
const parameters = ref([{ key: '', value: '' }]);
7+
8+
const generatedSQL = ref('');
9+
const errorMessage = ref('');
10+
11+
function generateSQL() {
12+
const { sql, error } = generateSQLFromTemplate(
13+
sqlTemplate.value,
14+
parameters.value,
15+
);
16+
17+
generatedSQL.value = sql;
18+
errorMessage.value = error || '';
19+
}
20+
</script>
21+
22+
<template>
23+
<NCard title="SQL Generator">
24+
<NForm label-placement="top">
25+
<NFormItem label="Parameterized SQL statement:">
26+
<NInput
27+
v-model:value="sqlTemplate"
28+
type="textarea"
29+
placeholder="e.g. SELECT * FROM users WHERE name = :name AND age > ? AND type = @type"
30+
:autosize="{ minRows: 3 }"
31+
/>
32+
</NFormItem>
33+
34+
<n-card title="Parameters">
35+
<n-p>For positional parameters, leave Name empty. For named parameters, enter name without '?' or '@' prefix</n-p>
36+
<NDynamicInput
37+
v-model:value="parameters"
38+
:min="1"
39+
preset="pair"
40+
show-sort-button
41+
key-placeholder="Name (e.g. name or type)"
42+
value-placeholder="Value (e.g. Alice or 42 or 1.3)"
43+
/>
44+
</n-card>
45+
46+
<n-space justify="center">
47+
<NButton type="primary" @click="generateSQL">
48+
Generate SQL
49+
</NButton>
50+
</n-space>
51+
52+
<NFormItem v-if="generatedSQL" label="Generated SQL:" mt-2>
53+
<textarea-copyable :value="generatedSQL" />
54+
</NFormItem>
55+
56+
<NAlert v-if="errorMessage" type="error" mt-2 @close="errorMessage = ''">
57+
{{ errorMessage }}
58+
</NAlert>
59+
</NForm>
60+
</NCard>
61+
</template>

0 commit comments

Comments
 (0)