Skip to content

Commit 93c72c2

Browse files
committed
Add possibility to generate SQL with GPT prompt
1 parent 427b721 commit 93c72c2

File tree

17 files changed

+440
-239
lines changed

17 files changed

+440
-239
lines changed

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ See original documentation and all information about the project on https://gith
77

88
Why this fork? Here we will document the differences from the original version.
99

10-
* [You can customize the default time ranges.](https://github.com/ankane/blazer/pull/313)
10+
### [You can customize the default time ranges.](https://github.com/ankane/blazer/pull/313)
1111

1212
Example:
1313
```yaml
@@ -17,8 +17,9 @@ timepicker_ranges:
1717
- ["Last 30 Days", 29, 0]
1818
```
1919
20-
* [You can display the graphs in a Grid layout.](https://github.com/ankane/blazer/pull/284)
21-
* You can change the Javascript library with `chart_library: URL` option.
20+
### [You can display the graphs in a Grid layout.](https://github.com/ankane/blazer/pull/284)
21+
22+
### You can change the Javascript library with `chart_library: URL` option.
2223
By default [Chart.js](https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.bundle.min.js) is used.
2324

2425
Example:
@@ -29,6 +30,16 @@ Example:
2930
chart_library: 'https://www.gstatic.com/charts/loader.js'
3031
```
3132

32-
* It uses Bootstrap 5.
33+
### Uses Bootstrap 5
34+
35+
Nothing more to mention here. It uses Bootstrap 5 instead of 3.
36+
37+
### You can generate your SQL using OpenAI API.
38+
39+
Include `gem "ruby-openai"` in your project and add an `OPENAI_API_KEY` environment variable to be able to generate SQL queries using OpenAI API.
40+
OpenAI (or any other LLM you will be using) never accesses the database data, but the schema file yes.
41+
If you have comments on the columns, they will also be available.
42+
3343

3444
You can [check all differences here](https://github.com/ankane/blazer/compare/master...renuo:blazer:master).
45+

app/assets/javascripts/blazer/application.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
//= require ./vue.global.prod
2121
//= require ./routes
2222
//= require ./queries
23+
//= require ./prompts
24+
//= require ./query-form
2325
//= require ./fuzzysearch
2426
//= require ./textFit
2527

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
var pendingPrompts = []
2+
var runningPrompts = []
3+
var maxPrompts = 3
4+
5+
function runPrompt(data, success, error) {
6+
var xhr = $.ajax({
7+
url: Routes.run_prompts_path(),
8+
method: "POST",
9+
data: data,
10+
dataType: "html"
11+
}).done( function (d) {
12+
success(d)
13+
}).fail( function(jqXHR, textStatus, errorThrown) {
14+
var message = (typeof errorThrown === "string") ? errorThrown : errorThrown.message
15+
error(message)
16+
});
17+
}
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
const extractTableNameFromEditor = function(content, pos) {
2+
const lines = content.split('\n');
3+
const currentRow = lines[pos.row];
4+
const previousTokens = currentRow.slice(pos.column - 2, pos.column);
5+
const columnSeparatorPosition = previousTokens.indexOf('.');
6+
if (columnSeparatorPosition === -1) {
7+
return null;
8+
}
9+
const absolutTableNamePosition = ((pos.column) - previousTokens.length) + columnSeparatorPosition;
10+
11+
let tableName = "";
12+
for (index = absolutTableNamePosition - 1; index > 0; index--) {
13+
if (currentRow[index] === ' ') {
14+
break;
15+
} else {
16+
tableName += currentRow[index];
17+
}
18+
}
19+
return tableName.split('').reverse().join('');
20+
}
21+
22+
function initializeQueryForm(variableParams, previewStatement, tableNames) {
23+
24+
let editor;
25+
26+
var app = Vue.createApp({
27+
data: function() {
28+
return {
29+
running: false,
30+
results: "",
31+
error: false,
32+
dataSource: "",
33+
selectize: null,
34+
editorHeight: "180px"
35+
}
36+
},
37+
computed: {
38+
schemaPath: function() {
39+
return Routes.schema_queries_path({data_source: this.dataSource})
40+
},
41+
docsPath: function() {
42+
return Routes.docs_queries_path({data_source: this.dataSource})
43+
}
44+
},
45+
methods: {
46+
run: function(e) {
47+
this.running = true
48+
this.results = ""
49+
this.error = false
50+
cancelAllQueries()
51+
52+
var data = {statement: this.getSQL(), data_source: $("#query_data_source").val(), variables: variableParams}
53+
54+
var _this = this
55+
56+
runQuery(data, function (data) {
57+
_this.running = false
58+
_this.showResults(data)
59+
60+
errorLine = _this.getErrorLine()
61+
if (errorLine) {
62+
editor.getSession().addGutterDecoration(errorLine - 1, "error")
63+
editor.scrollToLine(errorLine, true, true, function () {})
64+
editor.gotoLine(errorLine, 0, true)
65+
editor.focus()
66+
}
67+
}, function (data) {
68+
_this.running = false
69+
_this.error = true
70+
_this.showResults(data)
71+
})
72+
},
73+
cancel: function(e) {
74+
this.running = false
75+
cancelAllQueries()
76+
},
77+
runPrompt: function(e) {
78+
this.running = true
79+
80+
var data = {prompt: this.getPrompt(), data_source: $("#query_data_source").val()}
81+
var _this = this
82+
83+
runPrompt(data, function (data) {
84+
_this.running = false
85+
_this.showPromptResults(data)
86+
}, function (data) {
87+
_this.running = false
88+
_this.error = true
89+
_this.showPromptResults(data)
90+
})
91+
},
92+
cancelPrompt: function(e) {
93+
this.running = false
94+
},
95+
updateDataSource: function(dataSource) {
96+
this.dataSource = dataSource
97+
var selectize = this.selectize
98+
selectize.clearOptions()
99+
100+
if (this.tablesXhr) {
101+
this.tablesXhr.abort()
102+
}
103+
104+
this.tablesXhr = $.getJSON(Routes.tables_queries_path({data_source: this.dataSource}), function(data) {
105+
var newOptions = []
106+
for (var i = 0; i < data.length; i++) {
107+
var table = data[i]
108+
if (typeof table === "object") {
109+
newOptions.push({text: table.table, value: table.value})
110+
} else {
111+
newOptions.push({text: table, value: table})
112+
}
113+
}
114+
selectize.clearOptions()
115+
selectize.addOption(newOptions)
116+
selectize.refreshOptions(false)
117+
})
118+
},
119+
showEditor: function() {
120+
var _this = this
121+
122+
editor = ace.edit("editor")
123+
editor.setTheme("ace/theme/twilight")
124+
editor.getSession().setMode("ace/mode/sql")
125+
editor.setOptions({
126+
enableBasicAutocompletion: true,
127+
enableSnippets: false,
128+
enableLiveAutocompletion: true,
129+
highlightActiveLine: false,
130+
fontSize: 12,
131+
minLines: 10
132+
});
133+
134+
editor.completers.push({
135+
getCompletions: function(editor, session, pos, prefix, callback) {
136+
callback(null, tableNames);
137+
}
138+
});
139+
140+
editor.completers.push({
141+
getCompletions: function(editor, session, pos, prefix, callback) {
142+
const tableName = extractTableNameFromEditor(editor.getValue(), pos);
143+
if (tableName !== null) {
144+
for (index = 0; index < tableNames.length; index++) {
145+
const entry = tableNames[index];
146+
if (entry.value === tableName) {
147+
callback(null, entry.columns);
148+
break;
149+
}
150+
}
151+
}
152+
}
153+
});
154+
155+
editor.renderer.setShowGutter(true)
156+
editor.renderer.setPrintMarginColumn(false)
157+
editor.renderer.setPadding(10)
158+
editor.getSession().setUseWrapMode(true)
159+
editor.commands.addCommand({
160+
name: "run",
161+
bindKey: {win: "Ctrl-Enter", mac: "Command-Enter"},
162+
exec: function(editor) {
163+
_this.run()
164+
},
165+
readOnly: false // false if this command should not apply in readOnly mode
166+
})
167+
// fix command+L
168+
editor.commands.removeCommands(["gotoline", "find"])
169+
170+
this.editor = editor
171+
172+
editor.getSession().on("change", function () {
173+
$("#query_statement").val(editor.getValue())
174+
_this.adjustHeight()
175+
})
176+
this.adjustHeight()
177+
editor.focus()
178+
},
179+
adjustHeight: function() {
180+
// https://stackoverflow.com/questions/11584061/
181+
var editor = this.editor
182+
var lines = editor.getSession().getScreenLength()
183+
if (lines < 9) {
184+
lines = 9
185+
}
186+
187+
this.editorHeight = ((lines + 1) * 16).toString() + "px"
188+
189+
Vue.nextTick(function () {
190+
editor.resize()
191+
})
192+
},
193+
getPrompt: function() {
194+
return document.getElementById("prompt-editor").value;
195+
},
196+
getSQL: function() {
197+
var selectedText = editor.getSelectedText()
198+
var text = selectedText.length < 10 ? editor.getValue() : selectedText
199+
return text.replace(/\n/g, "\r\n")
200+
},
201+
getErrorLine: function() {
202+
var editor = this.editor
203+
var errorLine = this.results.substring(0, 100).includes("alert-danger") && /LINE (\d+)/g.exec(this.results)
204+
205+
if (errorLine) {
206+
errorLine = parseInt(errorLine[1], 10)
207+
if (editor.getSelectedText().length >= 10) {
208+
errorLine += editor.getSelectionRange().start.row
209+
}
210+
return errorLine
211+
}
212+
},
213+
showResults(data) {
214+
// can't do it the Vue way due to script tags in results
215+
// this.results = data
216+
217+
Vue.nextTick(function () {
218+
$("#results-html").html(data)
219+
})
220+
},
221+
showPromptResults(data) {
222+
// can't do it the Vue way due to script tags in results
223+
// this.results = data
224+
225+
Vue.nextTick(function () {
226+
editor.setValue(data)
227+
})
228+
}
229+
},
230+
mounted: function() {
231+
var _this = this
232+
233+
var $select = $("#table_names").selectize({})
234+
var selectize = $select[0].selectize
235+
selectize.on("change", function(val) {
236+
editor.setValue(previewStatement[_this.dataSource].replace("{table}", val), 1)
237+
_this.run()
238+
selectize.clear(true)
239+
selectize.blur()
240+
})
241+
this.selectize = selectize
242+
243+
this.updateDataSource($("#query_data_source").val())
244+
245+
var $dsSelect = $("#query_data_source").selectize({})
246+
var dsSelectize = $dsSelect[0].selectize
247+
dsSelectize.on("change", function(val) {
248+
_this.updateDataSource(val)
249+
dsSelectize.blur()
250+
})
251+
252+
this.showEditor()
253+
}
254+
})
255+
app.config.compilerOptions.whitespace = "preserve"
256+
app.mount("#app")
257+
}

app/assets/javascripts/blazer/routes.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ var Routes = {
55
cancel_queries_path: function() {
66
return rootPath + "queries/cancel"
77
},
8+
run_prompts_path: function() {
9+
return rootPath + "prompts/run"
10+
},
811
schema_queries_path: function(params) {
912
return rootPath + "queries/schema?" + $.param(params)
1013
},

app/assets/stylesheets/blazer/application.scss

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,6 @@ h2 {
222222
font-size: 20px;
223223
}
224224

225-
.schema-table {
226-
max-width: 500px;
227-
}
228225
.selectize-dropdown .create {
229226
padding: 5px 8px;
230227
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module Blazer
2+
class PromptsController < BaseController
3+
def run
4+
@query = Query.find_by(id: params[:query_id]) if params[:query_id]
5+
6+
# use query data source when present
7+
data_source = @query.data_source if @query && @query.data_source
8+
data_source ||= params[:data_source]
9+
@data_source = Blazer.data_sources[data_source]
10+
11+
@prompt = params[:prompt]
12+
13+
generated_sql = RunPromptJob.perform_now(data_source, @prompt)
14+
render plain: generated_sql
15+
end
16+
end
17+
end

app/controllers/blazer/queries_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ def render_forbidden
369369
end
370370

371371
def query_params
372-
params.require(:query).permit(:name, :description, :statement, :data_source)
372+
params.require(:query).permit(:name, :description, :statement, :data_source, :gpt_prompt)
373373
end
374374

375375
def blazer_params
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module Blazer
2+
module QueriesHelper
3+
4+
def gpt?(query)
5+
defined?(OpenAI) && (params[:gpt].present? || query.gpt_prompt.present?)
6+
end
7+
end
8+
end

0 commit comments

Comments
 (0)