magic_query 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rubocop.yml +55 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE +201 -0
- data/README.md +295 -0
- data/lib/magic_query/config/rules_loader.rb +49 -0
- data/lib/magic_query/configuration.rb +34 -0
- data/lib/magic_query/initializer.rb +11 -0
- data/lib/magic_query/prompt/base_prompt.txt +23 -0
- data/lib/magic_query/prompt/builder.rb +34 -0
- data/lib/magic_query/prompt/templates.rb +121 -0
- data/lib/magic_query/providers/base.rb +55 -0
- data/lib/magic_query/providers/claude.rb +60 -0
- data/lib/magic_query/providers/gemini.rb +75 -0
- data/lib/magic_query/providers/openai.rb +59 -0
- data/lib/magic_query/query/parser.rb +44 -0
- data/lib/magic_query/query/validator.rb +46 -0
- data/lib/magic_query/query_generator.rb +80 -0
- data/lib/magic_query/rails/controller.rb +77 -0
- data/lib/magic_query/rails/engine.rb +20 -0
- data/lib/magic_query/rails/generators/install_generator.rb +27 -0
- data/lib/magic_query/rails/generators/templates/initializer.rb +34 -0
- data/lib/magic_query/rails/generators/templates/magic_query.yml +57 -0
- data/lib/magic_query/rails/routes.rb +11 -0
- data/lib/magic_query/rails.rb +7 -0
- data/lib/magic_query/schema/base_loader.rb +30 -0
- data/lib/magic_query/schema/database_loader.rb +26 -0
- data/lib/magic_query/schema/file_loader.rb +45 -0
- data/lib/magic_query/schema/loader.rb +43 -0
- data/lib/magic_query/schema/parser.rb +102 -0
- data/lib/magic_query/schema/rails_schema_loader.rb +254 -0
- data/lib/magic_query/schema/validator.rb +45 -0
- data/lib/magic_query/version.rb +5 -0
- data/lib/magic_query.rb +25 -0
- metadata +193 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
You are an expert SQL developer. Your task is to generate valid SQL SELECT queries based on natural language requests.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
- Generate ONLY valid SQL SELECT statements
|
|
5
|
+
- Do not include any explanations or comments in the SQL output
|
|
6
|
+
- Use proper SQL syntax
|
|
7
|
+
- Include appropriate JOINs when needed
|
|
8
|
+
- Use WHERE clauses for filtering
|
|
9
|
+
- Consider performance when writing queries
|
|
10
|
+
|
|
11
|
+
Return only the SQL query, nothing else.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'templates'
|
|
4
|
+
|
|
5
|
+
module MagicQuery
|
|
6
|
+
module Prompt
|
|
7
|
+
class Builder
|
|
8
|
+
def initialize(config, schema, rules)
|
|
9
|
+
@config = config
|
|
10
|
+
@schema = schema
|
|
11
|
+
@rules = rules
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build(user_input)
|
|
15
|
+
prompt_parts = []
|
|
16
|
+
|
|
17
|
+
# Base prompt
|
|
18
|
+
base_prompt = @config.base_prompt || Templates.base_prompt
|
|
19
|
+
prompt_parts << base_prompt
|
|
20
|
+
|
|
21
|
+
# Schema
|
|
22
|
+
prompt_parts << Templates.format_schema(@schema) if @schema && !@schema.empty?
|
|
23
|
+
|
|
24
|
+
# Rules
|
|
25
|
+
prompt_parts << Templates.format_rules(@rules) if @rules && !@rules.empty?
|
|
26
|
+
|
|
27
|
+
# User input
|
|
28
|
+
prompt_parts << "User Request: #{user_input}"
|
|
29
|
+
|
|
30
|
+
prompt_parts.join("\n\n")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MagicQuery
|
|
4
|
+
module Prompt
|
|
5
|
+
module Templates
|
|
6
|
+
def self.base_prompt
|
|
7
|
+
File.read(File.join(__dir__, 'base_prompt.txt'))
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.format_schema(schema)
|
|
11
|
+
return '' if schema.nil? || schema.empty?
|
|
12
|
+
|
|
13
|
+
schema_text = "Database Schema:\n\n"
|
|
14
|
+
schema.each do |table_name, table_info|
|
|
15
|
+
schema_text += format_table(table_name, table_info)
|
|
16
|
+
end
|
|
17
|
+
schema_text
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.format_rules(rules)
|
|
21
|
+
return '' if rules.nil? || rules.empty?
|
|
22
|
+
|
|
23
|
+
rules_text = "Database Rules and Conventions:\n\n"
|
|
24
|
+
rules_text += format_naming_conventions(rules[:naming_conventions])
|
|
25
|
+
rules_text += format_relationships(rules[:relationships])
|
|
26
|
+
rules_text += format_business_rules(rules[:business_rules])
|
|
27
|
+
rules_text += format_table_rules(rules[:tables])
|
|
28
|
+
rules_text
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Private helper methods
|
|
32
|
+
|
|
33
|
+
def self.format_table(table_name, table_info)
|
|
34
|
+
text = "Table: #{table_name}\n"
|
|
35
|
+
text += format_columns(table_info[:columns])
|
|
36
|
+
text += format_constraints(table_info[:constraints]) if constraints?(table_info[:constraints])
|
|
37
|
+
"#{text}\n"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.format_columns(columns)
|
|
41
|
+
return " Columns:\n" if columns.nil? || columns.empty?
|
|
42
|
+
|
|
43
|
+
text = " Columns:\n"
|
|
44
|
+
columns.each do |column|
|
|
45
|
+
text += format_column(column)
|
|
46
|
+
end
|
|
47
|
+
text
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.format_column(column)
|
|
51
|
+
if column.is_a?(Hash)
|
|
52
|
+
" - #{column[:name]}: #{column[:type]}\n"
|
|
53
|
+
else
|
|
54
|
+
" - #{column}\n"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.format_constraints(constraints)
|
|
59
|
+
return '' if constraints.nil? || constraints.empty?
|
|
60
|
+
|
|
61
|
+
text = " Constraints:\n"
|
|
62
|
+
constraints.each do |constraint|
|
|
63
|
+
text += " - #{constraint}\n"
|
|
64
|
+
end
|
|
65
|
+
text
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.constraints?(constraints)
|
|
69
|
+
constraints && !constraints.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.format_naming_conventions(naming_conventions)
|
|
73
|
+
return '' if naming_conventions.nil? || naming_conventions.empty?
|
|
74
|
+
|
|
75
|
+
text = "Naming Conventions:\n"
|
|
76
|
+
naming_conventions.each do |key, value|
|
|
77
|
+
text += " - #{key}: #{value}\n"
|
|
78
|
+
end
|
|
79
|
+
text += "\n"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.format_relationships(relationships)
|
|
83
|
+
return '' if relationships.nil? || relationships.empty?
|
|
84
|
+
|
|
85
|
+
text = "Relationships:\n"
|
|
86
|
+
relationships.each do |rel|
|
|
87
|
+
text += " - #{rel}\n"
|
|
88
|
+
end
|
|
89
|
+
text += "\n"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.format_business_rules(business_rules)
|
|
93
|
+
return '' if business_rules.nil? || business_rules.empty?
|
|
94
|
+
|
|
95
|
+
text = "Business Rules:\n"
|
|
96
|
+
business_rules.each do |rule|
|
|
97
|
+
text += " - #{rule}\n"
|
|
98
|
+
end
|
|
99
|
+
text += "\n"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.format_table_rules(tables)
|
|
103
|
+
return '' if tables.nil? || tables.empty?
|
|
104
|
+
|
|
105
|
+
text = "Table-specific Rules:\n"
|
|
106
|
+
tables.each do |table_name, table_rules|
|
|
107
|
+
text += " #{table_name}:\n"
|
|
108
|
+
table_rules.each do |key, value|
|
|
109
|
+
text += " - #{key}: #{value}\n"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
text += "\n"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private_class_method :format_table, :format_columns, :format_column,
|
|
116
|
+
:format_constraints, :constraints?,
|
|
117
|
+
:format_naming_conventions, :format_relationships,
|
|
118
|
+
:format_business_rules, :format_table_rules
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module MagicQuery
|
|
6
|
+
module Providers
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :api_key, :model, :temperature, :max_tokens
|
|
9
|
+
|
|
10
|
+
def initialize(api_key:, model: nil, temperature: 0.3, max_tokens: 1000)
|
|
11
|
+
@api_key = api_key
|
|
12
|
+
@model = model || default_model
|
|
13
|
+
@temperature = temperature
|
|
14
|
+
@max_tokens = max_tokens
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(prompt)
|
|
18
|
+
raise NotImplementedError, 'Subclasses must implement #generate'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
protected
|
|
22
|
+
|
|
23
|
+
def default_model
|
|
24
|
+
raise NotImplementedError, 'Subclasses must implement #default_model'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def base_prompt
|
|
28
|
+
@base_prompt ||= begin
|
|
29
|
+
prompt_file = File.join(__dir__, '..', 'prompt', 'base_prompt.txt')
|
|
30
|
+
File.read(prompt_file).strip
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_params(prompt)
|
|
35
|
+
raise NotImplementedError, 'Subclasses must implement #generate_params'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate_headers
|
|
39
|
+
raise NotImplementedError, 'Subclasses must implement #generate_headers'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def generate_body(prompt)
|
|
43
|
+
raise NotImplementedError, 'Subclasses must implement #generate_body'
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def http_client
|
|
47
|
+
@http_client ||= Faraday.new do |conn|
|
|
48
|
+
conn.request :json
|
|
49
|
+
conn.response :json
|
|
50
|
+
conn.adapter Faraday.default_adapter
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module MagicQuery
|
|
7
|
+
module Providers
|
|
8
|
+
class Claude < Base
|
|
9
|
+
API_URL = 'https://api.anthropic.com/v1/messages'
|
|
10
|
+
|
|
11
|
+
def generate(prompt)
|
|
12
|
+
response = http_client.post(API_URL) do |req|
|
|
13
|
+
generate_headers.each { |key, value| req.headers[key] = value }
|
|
14
|
+
req.body = generate_body(prompt)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
handle_response(response)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
protected
|
|
21
|
+
|
|
22
|
+
def default_model
|
|
23
|
+
'claude-3-5-sonnet-20241022'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate_params(_prompt)
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate_headers
|
|
31
|
+
{
|
|
32
|
+
'x-api-key' => api_key,
|
|
33
|
+
'anthropic-version' => '2023-06-01',
|
|
34
|
+
'Content-Type' => 'application/json'
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def generate_body(prompt)
|
|
39
|
+
{
|
|
40
|
+
model: model,
|
|
41
|
+
max_tokens: max_tokens,
|
|
42
|
+
temperature: temperature,
|
|
43
|
+
system: base_prompt,
|
|
44
|
+
messages: [
|
|
45
|
+
{ role: 'user', content: prompt }
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def handle_response(response)
|
|
53
|
+
raise Error, "Claude API error: #{response.status} - #{response.body}" unless response.status == 200
|
|
54
|
+
|
|
55
|
+
body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
|
|
56
|
+
body.dig('content', 0, 'text')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module MagicQuery
|
|
7
|
+
module Providers
|
|
8
|
+
class Gemini < Base
|
|
9
|
+
API_URL_TEMPLATE = 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent'
|
|
10
|
+
|
|
11
|
+
def generate(prompt)
|
|
12
|
+
url = format(API_URL_TEMPLATE, model)
|
|
13
|
+
response = make_request(url, prompt)
|
|
14
|
+
handle_response(response)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def make_request(url, prompt)
|
|
18
|
+
http_client.post(url) do |req|
|
|
19
|
+
set_request_params(req, prompt)
|
|
20
|
+
apply_request_headers(req)
|
|
21
|
+
req.body = generate_body(prompt)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def set_request_params(req, prompt)
|
|
26
|
+
generate_params(prompt).each { |key, value| req.params[key] = value }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def apply_request_headers(req)
|
|
30
|
+
generate_headers.each { |key, value| req.headers[key] = value }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
protected
|
|
34
|
+
|
|
35
|
+
def default_model
|
|
36
|
+
'gemini-1.5-flash-latest'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def generate_params(_prompt)
|
|
40
|
+
{ 'key' => api_key }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def generate_headers
|
|
44
|
+
{
|
|
45
|
+
'Content-Type' => 'application/json'
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def generate_body(prompt) # rubocop:disable Metrics/MethodLength
|
|
50
|
+
{
|
|
51
|
+
contents: [
|
|
52
|
+
{
|
|
53
|
+
parts: [
|
|
54
|
+
{ text: "#{base_prompt}\n\n#{prompt}" }
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
generationConfig: {
|
|
59
|
+
temperature: temperature,
|
|
60
|
+
maxOutputTokens: max_tokens
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def handle_response(response)
|
|
68
|
+
raise Error, "Gemini API error: #{response.status} - #{response.body}" unless response.status == 200
|
|
69
|
+
|
|
70
|
+
body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
|
|
71
|
+
body.dig('candidates', 0, 'content', 'parts', 0, 'text')
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module MagicQuery
|
|
7
|
+
module Providers
|
|
8
|
+
class OpenAI < Base
|
|
9
|
+
API_URL = 'https://api.openai.com/v1/chat/completions'
|
|
10
|
+
|
|
11
|
+
def generate(prompt)
|
|
12
|
+
response = http_client.post(API_URL) do |req|
|
|
13
|
+
generate_headers.each { |key, value| req.headers[key] = value }
|
|
14
|
+
req.body = generate_body(prompt)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
handle_response(response)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
protected
|
|
21
|
+
|
|
22
|
+
def default_model
|
|
23
|
+
'gpt-4o-2024-08-06'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def generate_params(_prompt)
|
|
27
|
+
{}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def generate_headers
|
|
31
|
+
{
|
|
32
|
+
'Authorization' => "Bearer #{api_key}",
|
|
33
|
+
'Content-Type' => 'application/json'
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def generate_body(prompt)
|
|
38
|
+
{
|
|
39
|
+
model: model,
|
|
40
|
+
messages: [
|
|
41
|
+
{ role: 'system', content: base_prompt },
|
|
42
|
+
{ role: 'user', content: prompt }
|
|
43
|
+
],
|
|
44
|
+
temperature: temperature,
|
|
45
|
+
max_tokens: max_tokens
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def handle_response(response)
|
|
52
|
+
raise Error, "OpenAI API error: #{response.status} - #{response.body}" unless response.status == 200
|
|
53
|
+
|
|
54
|
+
body = response.body.is_a?(String) ? JSON.parse(response.body) : response.body
|
|
55
|
+
body.dig('choices', 0, 'message', 'content')
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MagicQuery
|
|
4
|
+
module Query
|
|
5
|
+
class Parser
|
|
6
|
+
def self.extract_sql(response_text)
|
|
7
|
+
new(response_text).extract
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(response_text)
|
|
11
|
+
@response_text = response_text.to_s.strip
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def extract
|
|
15
|
+
# Try to extract SQL from markdown code blocks first
|
|
16
|
+
sql = extract_from_markdown || extract_direct_sql || @response_text
|
|
17
|
+
|
|
18
|
+
# Clean up the SQL
|
|
19
|
+
clean_sql(sql)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def extract_from_markdown
|
|
25
|
+
# Match SQL in markdown code blocks
|
|
26
|
+
match = @response_text.match(/```(?:sql)?\s*\n?(.*?)```/m)
|
|
27
|
+
match ? match[1].strip : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def extract_direct_sql
|
|
31
|
+
# Try to find SELECT statement
|
|
32
|
+
match = @response_text.match(/(SELECT\s+.*?(?:;|$))/mi)
|
|
33
|
+
match ? match[1].strip : nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def clean_sql(sql)
|
|
37
|
+
# Remove trailing semicolons and extra whitespace
|
|
38
|
+
sql = sql.strip
|
|
39
|
+
sql = sql.chomp(';') if sql.end_with?(';')
|
|
40
|
+
sql.strip
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MagicQuery
|
|
4
|
+
module Query
|
|
5
|
+
class Validator
|
|
6
|
+
def self.validate(sql)
|
|
7
|
+
new(sql).validate
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.valid?(sql)
|
|
11
|
+
new(sql).valid?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(sql)
|
|
15
|
+
@sql = sql.to_s.strip
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def validate
|
|
19
|
+
errors = []
|
|
20
|
+
|
|
21
|
+
if @sql.empty?
|
|
22
|
+
errors << 'SQL query is empty'
|
|
23
|
+
return errors
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
errors << 'Query must start with SELECT' unless @sql.match?(/^\s*SELECT/i)
|
|
27
|
+
errors.concat(check_dangerous_keywords)
|
|
28
|
+
|
|
29
|
+
errors
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def check_dangerous_keywords
|
|
33
|
+
errors = []
|
|
34
|
+
dangerous_keywords = %w[DROP DELETE UPDATE INSERT ALTER CREATE TRUNCATE]
|
|
35
|
+
dangerous_keywords.each do |keyword|
|
|
36
|
+
errors << "Query contains dangerous keyword: #{keyword}" if @sql.match?(/\b#{keyword}\b/i)
|
|
37
|
+
end
|
|
38
|
+
errors
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def valid?
|
|
42
|
+
validate.empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'schema/loader'
|
|
4
|
+
require_relative 'config/rules_loader'
|
|
5
|
+
require_relative 'prompt/builder'
|
|
6
|
+
require_relative 'query/parser'
|
|
7
|
+
require_relative 'query/validator'
|
|
8
|
+
|
|
9
|
+
module MagicQuery
|
|
10
|
+
class QueryGenerator
|
|
11
|
+
def initialize(config = nil)
|
|
12
|
+
@config = config || MagicQuery.configuration
|
|
13
|
+
@schema = nil
|
|
14
|
+
@rules = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate(user_input)
|
|
18
|
+
validate_user_input(user_input)
|
|
19
|
+
ensure_api_key_configured
|
|
20
|
+
load_dependencies
|
|
21
|
+
|
|
22
|
+
prompt = build_prompt(user_input)
|
|
23
|
+
response = generate_ai_response(prompt)
|
|
24
|
+
extract_and_validate_sql(response)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def validate_user_input(user_input)
|
|
30
|
+
raise Error, 'User input cannot be empty' if user_input.nil? || user_input.strip.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ensure_api_key_configured
|
|
34
|
+
raise Error, 'API key not configured' if @config.api_key.nil? || @config.api_key.strip.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_dependencies
|
|
38
|
+
load_schema unless @schema
|
|
39
|
+
load_rules unless @rules
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build_prompt(user_input)
|
|
43
|
+
prompt_builder = Prompt::Builder.new(@config, @schema, @rules)
|
|
44
|
+
prompt_builder.build(user_input)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def generate_ai_response(prompt)
|
|
48
|
+
provider = create_provider
|
|
49
|
+
provider.generate(prompt)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create_provider
|
|
53
|
+
provider_class = @config.provider_class
|
|
54
|
+
provider_class.new(
|
|
55
|
+
api_key: @config.api_key,
|
|
56
|
+
model: @config.model,
|
|
57
|
+
temperature: @config.temperature,
|
|
58
|
+
max_tokens: @config.max_tokens
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def extract_and_validate_sql(response)
|
|
63
|
+
sql = Query::Parser.extract_sql(response)
|
|
64
|
+
validator = Query::Validator.new(sql)
|
|
65
|
+
validation_errors = validator.validate
|
|
66
|
+
raise Error, "Invalid SQL generated: #{validation_errors.join(', ')}" unless validation_errors.empty?
|
|
67
|
+
|
|
68
|
+
sql
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def load_schema
|
|
72
|
+
# Loader will automatically detect the best source (file, Rails, or database)
|
|
73
|
+
@schema = Schema::Loader.load(@config)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def load_rules
|
|
77
|
+
@rules = Config::RulesLoader.load(@config.rules_path) if @config.rules_path
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MagicQuery
|
|
4
|
+
module Rails
|
|
5
|
+
# Base controller for Magic Query endpoints.
|
|
6
|
+
# Provides a RESTful endpoint for generating SQL queries from natural language.
|
|
7
|
+
#
|
|
8
|
+
# For detailed documentation, examples, and customization options, see CONTROLLER.md
|
|
9
|
+
class MagicQueryController < ActionController::Base
|
|
10
|
+
before_action :set_generator
|
|
11
|
+
before_action :set_magic_query_params
|
|
12
|
+
|
|
13
|
+
def generate
|
|
14
|
+
if @user_input.blank?
|
|
15
|
+
render json: { error: 'Query parameter is required' }, status: :bad_request
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
generate_sql_with_error_handling
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
protected
|
|
23
|
+
|
|
24
|
+
# Generates SQL from user input with error handling.
|
|
25
|
+
# Override this method to customize error handling behavior.
|
|
26
|
+
#
|
|
27
|
+
# @example Custom error handling
|
|
28
|
+
# def generate_sql_with_error_handling
|
|
29
|
+
# sql = @generator.generate(@user_input)
|
|
30
|
+
# render json: { sql: sql, query: @user_input }
|
|
31
|
+
# rescue MagicQuery::Error => e
|
|
32
|
+
# # Custom error handling
|
|
33
|
+
# render json: { error: e.message }, status: :unprocessable_entity
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @return [void]
|
|
37
|
+
def generate_sql_with_error_handling
|
|
38
|
+
sql = @generator.generate(@user_input)
|
|
39
|
+
render json: { sql: sql, query: @user_input }
|
|
40
|
+
rescue MagicQuery::Error => e
|
|
41
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
render json: { error: "An error occurred: #{e.message}" }, status: :internal_server_error
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sets the user input from request parameters.
|
|
47
|
+
# Override this method to customize how input parameters are read.
|
|
48
|
+
#
|
|
49
|
+
# By default, it reads from `params[:input]` or falls back to `params[:query]`.
|
|
50
|
+
#
|
|
51
|
+
# @example Custom parameter source
|
|
52
|
+
# def set_magic_query_params
|
|
53
|
+
# @user_input = params[:custom_input] || params[:text]
|
|
54
|
+
# end
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def set_magic_query_params
|
|
58
|
+
@user_input = params[:input] || params[:query]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sets up the query generator instance.
|
|
62
|
+
# Override this method to customize generator configuration.
|
|
63
|
+
#
|
|
64
|
+
# @example Custom configuration
|
|
65
|
+
# def set_generator
|
|
66
|
+
# custom_config = MagicQuery::Configuration.new
|
|
67
|
+
# custom_config.provider = :custom_provider
|
|
68
|
+
# @generator = MagicQuery::QueryGenerator.new(custom_config)
|
|
69
|
+
# end
|
|
70
|
+
#
|
|
71
|
+
# @return [void]
|
|
72
|
+
def set_generator
|
|
73
|
+
@generator = MagicQuery::QueryGenerator.new(MagicQuery.configuration)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MagicQuery
|
|
4
|
+
module Rails
|
|
5
|
+
class Engine < ::Rails::Engine
|
|
6
|
+
isolate_namespace MagicQuery
|
|
7
|
+
|
|
8
|
+
config.generators do |g|
|
|
9
|
+
g.test_framework :rspec
|
|
10
|
+
g.fixture_replacement :factory_bot, dir: 'spec/factories'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
initializer 'magic_query.routes' do
|
|
14
|
+
Rails.application.routes.draw do
|
|
15
|
+
MagicQuery::Rails::Routes.draw(self)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|