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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +55 -0
  3. data/CHANGELOG.md +32 -0
  4. data/LICENSE +201 -0
  5. data/README.md +295 -0
  6. data/lib/magic_query/config/rules_loader.rb +49 -0
  7. data/lib/magic_query/configuration.rb +34 -0
  8. data/lib/magic_query/initializer.rb +11 -0
  9. data/lib/magic_query/prompt/base_prompt.txt +23 -0
  10. data/lib/magic_query/prompt/builder.rb +34 -0
  11. data/lib/magic_query/prompt/templates.rb +121 -0
  12. data/lib/magic_query/providers/base.rb +55 -0
  13. data/lib/magic_query/providers/claude.rb +60 -0
  14. data/lib/magic_query/providers/gemini.rb +75 -0
  15. data/lib/magic_query/providers/openai.rb +59 -0
  16. data/lib/magic_query/query/parser.rb +44 -0
  17. data/lib/magic_query/query/validator.rb +46 -0
  18. data/lib/magic_query/query_generator.rb +80 -0
  19. data/lib/magic_query/rails/controller.rb +77 -0
  20. data/lib/magic_query/rails/engine.rb +20 -0
  21. data/lib/magic_query/rails/generators/install_generator.rb +27 -0
  22. data/lib/magic_query/rails/generators/templates/initializer.rb +34 -0
  23. data/lib/magic_query/rails/generators/templates/magic_query.yml +57 -0
  24. data/lib/magic_query/rails/routes.rb +11 -0
  25. data/lib/magic_query/rails.rb +7 -0
  26. data/lib/magic_query/schema/base_loader.rb +30 -0
  27. data/lib/magic_query/schema/database_loader.rb +26 -0
  28. data/lib/magic_query/schema/file_loader.rb +45 -0
  29. data/lib/magic_query/schema/loader.rb +43 -0
  30. data/lib/magic_query/schema/parser.rb +102 -0
  31. data/lib/magic_query/schema/rails_schema_loader.rb +254 -0
  32. data/lib/magic_query/schema/validator.rb +45 -0
  33. data/lib/magic_query/version.rb +5 -0
  34. data/lib/magic_query.rb +25 -0
  35. 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