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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module MagicQuery
6
+ module Rails
7
+ module Generators
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ desc 'Installs Magic Query configuration files'
12
+
13
+ def create_initializer
14
+ template 'initializer.rb', 'config/initializers/magic_query.rb'
15
+ end
16
+
17
+ def create_rules_file
18
+ template 'magic_query.yml', 'config/magic_query.yml'
19
+ end
20
+
21
+ def add_routes
22
+ route "mount MagicQuery::Rails::Engine => '/magic_query'"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ MagicQuery.configure do |config|
4
+ # AI Provider configuration
5
+ # Options: :openai, :claude, :gemini
6
+ config.provider = :openai
7
+
8
+ # API Key for the selected provider
9
+ config.api_key = ENV.fetch('MAGIC_QUERY_API_KEY', nil)
10
+
11
+ # Model to use (optional, will use default for provider if not set)
12
+ # config.model = 'gpt-4o-mini'
13
+
14
+ # Schema configuration
15
+ # Option 1: Load automatically from Rails db/schema.rb (default if schema_path is not set)
16
+ # The schema will be automatically loaded from db/schema.rb file
17
+ # config.schema_path = nil # Leave nil to use automatic Rails schema loading
18
+
19
+ # Option 2: Load from file (SQL or YAML)
20
+ # config.schema_path = Rails.root.join('config', 'schema.sql').to_s
21
+
22
+ # Option 3: Load from database URL (requires database-specific implementation)
23
+ # config.database_url = ENV['DATABASE_URL']
24
+
25
+ # Rules configuration
26
+ config.rules_path = Rails.root.join('config', 'magic_query.yml').to_s
27
+
28
+ # AI generation parameters
29
+ config.temperature = 0.3
30
+ config.max_tokens = 1000
31
+
32
+ # Custom base prompt (optional)
33
+ # config.base_prompt = 'Your custom prompt here'
34
+ end
@@ -0,0 +1,57 @@
1
+ # Magic Query Configuration
2
+ # This file contains rules and conventions for your database
3
+
4
+ # Naming conventions
5
+ naming_conventions:
6
+ table_prefix: ''
7
+ table_suffix: ''
8
+ column_naming: 'snake_case'
9
+
10
+ # Relationships between tables
11
+ relationships:
12
+ - 'users has_many posts'
13
+ - 'posts belongs_to users'
14
+ - 'posts has_many comments'
15
+ - 'comments belongs_to posts'
16
+
17
+ # Business rules
18
+ business_rules:
19
+ - 'Active users have status = "active"'
20
+ - 'Deleted records have deleted_at IS NOT NULL'
21
+ - 'Use soft deletes where applicable'
22
+
23
+ # Table-specific rules
24
+ tables:
25
+ users:
26
+ description: 'User accounts table'
27
+ important_columns:
28
+ - 'id (primary key)'
29
+ - 'email (unique, required)'
30
+ - 'status (active/inactive)'
31
+
32
+ posts:
33
+ description: 'Blog posts table'
34
+ important_columns:
35
+ - 'id (primary key)'
36
+ - 'user_id (foreign key to users)'
37
+ - 'published_at (timestamp)'
38
+
39
+ # Column-specific rules
40
+ columns:
41
+ email:
42
+ validation: 'Must be valid email format'
43
+
44
+ status:
45
+ allowed_values: ['active', 'inactive', 'pending']
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicQuery
4
+ module Rails
5
+ module Routes
6
+ def self.draw(router)
7
+ router.post 'magic_query/generate', to: 'magic_query#generate', as: :magic_query_generate
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(Rails)
4
+ require_relative 'rails/engine'
5
+ require_relative 'rails/controller'
6
+ require_relative 'rails/routes'
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'validator'
4
+
5
+ module MagicQuery
6
+ module Schema
7
+ class BaseLoader
8
+ def initialize(config)
9
+ @config = config
10
+ end
11
+
12
+ def load
13
+ schema = load_schema
14
+ validate_schema(schema)
15
+ schema
16
+ end
17
+
18
+ protected
19
+
20
+ def load_schema
21
+ raise NotImplementedError, 'Subclasses must implement load_schema'
22
+ end
23
+
24
+ def validate_schema(schema)
25
+ errors = Validator.validate(schema)
26
+ raise Error, "Invalid schema: #{errors.join(', ')}" unless errors.empty?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_loader'
4
+
5
+ module MagicQuery
6
+ module Schema
7
+ class DatabaseLoader < BaseLoader
8
+ def initialize(config, database_url)
9
+ super(config)
10
+ @database_url = database_url
11
+ end
12
+
13
+ def self.can_load?(database_url)
14
+ !database_url.nil? && !database_url.empty?
15
+ end
16
+
17
+ protected
18
+
19
+ def load_schema
20
+ # For generic SQL, we'll try to extract schema using a simple approach
21
+ # In a real implementation, you might want to use database-specific adapters
22
+ raise Error, 'Automatic schema extraction requires database-specific implementation'
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'base_loader'
5
+ require_relative 'parser'
6
+
7
+ module MagicQuery
8
+ module Schema
9
+ class FileLoader < BaseLoader
10
+ def initialize(config, path)
11
+ super(config)
12
+ @path = path
13
+ end
14
+
15
+ def self.can_load?(path)
16
+ path && File.exist?(path)
17
+ end
18
+
19
+ protected
20
+
21
+ def load_schema
22
+ content = File.read(@path)
23
+ if @path.end_with?('.yml', '.yaml')
24
+ load_yaml_schema(content)
25
+ else
26
+ Parser.parse(content)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def load_yaml_schema(content)
33
+ yaml_data = YAML.safe_load(content)
34
+ schema = {}
35
+ yaml_data.each do |table_name, table_info|
36
+ schema[table_name.to_s] = {
37
+ columns: table_info['columns'] || [],
38
+ constraints: table_info['constraints'] || []
39
+ }
40
+ end
41
+ schema
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_loader'
4
+ require_relative 'file_loader'
5
+ require_relative 'rails_schema_loader'
6
+ require_relative 'database_loader'
7
+
8
+ module MagicQuery
9
+ module Schema
10
+ class Loader
11
+ def self.load(config)
12
+ new(config).load
13
+ end
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ def load
20
+ loader = find_loader
21
+ loader.load
22
+ end
23
+
24
+ private
25
+
26
+ def find_loader
27
+ # Priority order:
28
+ # 1. File loader (if schema_path is configured and exists)
29
+ # 2. Rails schema loader (if Rails is available and schema.rb exists)
30
+ # 3. Database loader (if database_url is configured)
31
+ # 4. Raise error if none available
32
+
33
+ return FileLoader.new(@config, @config.schema_path) if FileLoader.can_load?(@config.schema_path)
34
+
35
+ return RailsSchemaLoader.new(@config) if RailsSchemaLoader.can_load?
36
+
37
+ return DatabaseLoader.new(@config, @config.database_url) if DatabaseLoader.can_load?(@config.database_url)
38
+
39
+ raise Error, 'Either schema_path, Rails environment, or database_url must be configured'
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicQuery
4
+ module Schema
5
+ class Parser
6
+ def self.parse(sql_schema)
7
+ new(sql_schema).parse
8
+ end
9
+
10
+ def initialize(sql_schema)
11
+ @sql_schema = sql_schema.to_s
12
+ end
13
+
14
+ def parse
15
+ tables = {}
16
+ current_table = nil
17
+
18
+ @sql_schema.lines.each do |line|
19
+ line = line.strip
20
+ next if skip_line?(line)
21
+
22
+ current_table = process_line(line, tables, current_table)
23
+ end
24
+
25
+ tables
26
+ end
27
+
28
+ def skip_line?(line)
29
+ line.empty? || line.start_with?('--')
30
+ end
31
+
32
+ def process_line(line, tables, current_table)
33
+ return handle_create_table(line, tables) if create_table?(line)
34
+ return handle_column(line, tables, current_table) if current_table && column_definition?(line)
35
+ return handle_constraint(line, tables, current_table) if current_table && constraint?(line)
36
+ return nil if end_of_table?(line)
37
+
38
+ current_table
39
+ end
40
+
41
+ def handle_create_table(line, tables)
42
+ extract_table_name(line).tap do |table_name|
43
+ tables[table_name] = { columns: [], constraints: [] }
44
+ end
45
+ end
46
+
47
+ def handle_column(line, tables, current_table)
48
+ add_column(line, tables, current_table)
49
+ current_table
50
+ end
51
+
52
+ def handle_constraint(line, tables, current_table)
53
+ add_constraint(line, tables, current_table)
54
+ current_table
55
+ end
56
+
57
+ def create_table?(line)
58
+ line.match?(/CREATE\s+TABLE/i)
59
+ end
60
+
61
+ def column_definition?(line)
62
+ line.match?(/^\w+/)
63
+ end
64
+
65
+ def constraint?(line)
66
+ line.match?(/(PRIMARY KEY|FOREIGN KEY|UNIQUE|INDEX)/i)
67
+ end
68
+
69
+ def end_of_table?(line)
70
+ line.match?(/\);?$/)
71
+ end
72
+
73
+ def add_column(line, tables, current_table)
74
+ column_info = parse_column(line)
75
+ tables[current_table][:columns] << column_info if column_info
76
+ end
77
+
78
+ def add_constraint(line, tables, current_table)
79
+ tables[current_table][:constraints] << line
80
+ end
81
+
82
+ private
83
+
84
+ def extract_table_name(line)
85
+ match = line.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:`|")?(\w+)(?:`|")?/i)
86
+ match ? match[1] : nil
87
+ end
88
+
89
+ def parse_column(line)
90
+ # Simple column parser - extracts name and type
91
+ match = line.match(/^(\w+)\s+(\w+(?:\([^)]+\))?)/i)
92
+ return nil unless match
93
+
94
+ {
95
+ name: match[1],
96
+ type: match[2],
97
+ definition: line
98
+ }
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_loader'
4
+
5
+ module MagicQuery
6
+ module Schema
7
+ class RailsSchemaLoader < BaseLoader
8
+ def self.can_load?
9
+ return false unless defined?(Rails)
10
+
11
+ schema_path = Rails.root.join('db', 'schema.rb')
12
+ File.exist?(schema_path)
13
+ rescue StandardError
14
+ false
15
+ end
16
+
17
+ protected
18
+
19
+ def load_schema
20
+ schema_path = Rails.root.join('db', 'schema.rb')
21
+ raise Error, "Rails schema file not found at #{schema_path}" unless File.exist?(schema_path)
22
+
23
+ content = File.read(schema_path)
24
+ parse_rails_schema(content)
25
+ end
26
+
27
+ private
28
+
29
+ def parse_rails_schema(content)
30
+ schema = {}
31
+ state = { current_table: nil, table_columns: [], table_constraints: [] }
32
+
33
+ content.lines.each do |line|
34
+ line = line.strip
35
+ next if skip_schema_line?(line)
36
+
37
+ process_schema_line(line, schema, state)
38
+ end
39
+
40
+ save_current_table(schema, state) if state[:current_table] && !state[:table_columns].empty?
41
+ filter_system_tables(schema)
42
+ end
43
+
44
+ def skip_schema_line?(line)
45
+ line.empty? || line.start_with?('#') || line.start_with?('ActiveRecord::Schema')
46
+ end
47
+
48
+ def process_schema_line(line, schema, state)
49
+ return process_create_table(line, schema, state) if create_table_line?(line)
50
+ return process_column(line, state) if state[:current_table] && column_line?(line)
51
+ return process_add_index(line, schema, state) if add_index_line?(line)
52
+ return process_add_foreign_key(line, schema, state) if add_foreign_key_line?(line)
53
+
54
+ save_and_reset_table(schema, state) if end_of_table_line?(line, state)
55
+ end
56
+
57
+ def create_table_line?(line)
58
+ line.match?(/create_table\s+["'](\w+)["']/)
59
+ end
60
+
61
+ def column_line?(line)
62
+ line.match?(/^t\.(\w+)\s+["'](\w+)["']/)
63
+ end
64
+
65
+ def add_index_line?(line)
66
+ line.match?(/add_index\s+["'](\w+)["']/)
67
+ end
68
+
69
+ def add_foreign_key_line?(line)
70
+ line.match?(/add_foreign_key\s+["'](\w+)["']/)
71
+ end
72
+
73
+ def end_of_table_line?(line, state)
74
+ line == 'end' && state[:current_table]
75
+ end
76
+
77
+ def process_create_table(line, schema, state)
78
+ save_current_table(schema, state) if state[:current_table]
79
+
80
+ match = line.match(/create_table\s+["'](\w+)["']/)
81
+ state[:current_table] = match[1]
82
+ state[:table_columns] = []
83
+ state[:table_constraints] = []
84
+
85
+ state[:table_constraints] << 'FORCE: cascade' if line.match?(/force:\s*:cascade/)
86
+ end
87
+
88
+ def process_column(line, state)
89
+ match = line.match(/^t\.(\w+)\s+["'](\w+)["']/)
90
+ column_type = match[1]
91
+ column_name = match[2]
92
+
93
+ column_def = build_column_definition(line, column_name, column_type)
94
+ state[:table_columns] << column_def
95
+ end
96
+
97
+ def build_column_definition(line, column_name, column_type)
98
+ null_option = line.match(/null:\s*(true|false)/)
99
+ default_option = line.match(/default:\s*([^,}]+)/)
100
+ limit_option = line.match(/limit:\s*(\d+)/)
101
+
102
+ sql_type = map_rails_type_to_sql(column_type, limit_option&.[](1))
103
+ definition = build_column_definition_string(column_name, sql_type, null_option, default_option)
104
+
105
+ {
106
+ name: column_name,
107
+ type: sql_type,
108
+ definition: definition
109
+ }
110
+ end
111
+
112
+ def build_column_definition_string(column_name, sql_type, null_option, default_option)
113
+ definition = "#{column_name} #{sql_type}"
114
+ definition += ' NOT NULL' if null_option && null_option[1] == 'false'
115
+ definition += " DEFAULT #{extract_default_value(default_option&.[](1))}" if default_option
116
+ definition
117
+ end
118
+
119
+ def process_add_index(line, schema, state)
120
+ match = line.match(/add_index\s+["'](\w+)["']/)
121
+ table_name = match[1]
122
+ columns_match = line.match(/\[([^\]]+)\]/)
123
+
124
+ return unless columns_match
125
+
126
+ constraint = build_index_constraint(line, table_name, columns_match)
127
+ add_constraint_to_table(schema, state, table_name, constraint)
128
+ end
129
+
130
+ def build_index_constraint(line, table_name, columns_match)
131
+ columns = columns_match[1].split(',').map(&:strip).map { |c| c.gsub(/["']/, '') }
132
+ name_match = line.match(/name:\s*["']([^"']+)["']/)
133
+ unique_match = line.match(/unique:\s*(true)/)
134
+
135
+ index_name = name_match ? name_match[1] : "index_#{table_name}_on_#{columns.join('_and_')}"
136
+ constraint = unique_match ? "UNIQUE INDEX #{index_name}" : "INDEX #{index_name}"
137
+ "#{constraint} (#{columns.join(', ')})"
138
+ end
139
+
140
+ def process_add_foreign_key(line, schema, state)
141
+ match = line.match(/add_foreign_key\s+["'](\w+)["'],\s*["'](\w+)["']/)
142
+ return unless match
143
+
144
+ from_table = match[1]
145
+ to_table = match[2]
146
+ column = extract_foreign_key_column(line, to_table)
147
+
148
+ constraint = "FOREIGN KEY (#{column}) REFERENCES #{to_table}(id)"
149
+ add_constraint_to_table(schema, state, from_table, constraint)
150
+ end
151
+
152
+ def extract_foreign_key_column(line, to_table)
153
+ column_match = line.match(/column:\s*["'](\w+)["']/)
154
+ return column_match[1] if column_match
155
+
156
+ to_table.end_with?('s') ? "#{to_table[0..-2]}_id" : "#{to_table}_id"
157
+ end
158
+
159
+ def add_constraint_to_table(schema, state, table_name, constraint)
160
+ if schema[table_name]
161
+ schema[table_name][:constraints] << constraint
162
+ elsif table_name == state[:current_table]
163
+ state[:table_constraints] << constraint
164
+ end
165
+ end
166
+
167
+ def save_and_reset_table(schema, state)
168
+ save_current_table(schema, state)
169
+ state[:current_table] = nil
170
+ state[:table_columns] = []
171
+ state[:table_constraints] = []
172
+ end
173
+
174
+ def save_current_table(schema, state)
175
+ return unless state[:current_table]
176
+
177
+ schema[state[:current_table]] = {
178
+ columns: state[:table_columns],
179
+ constraints: state[:table_constraints]
180
+ }
181
+ end
182
+
183
+ def filter_system_tables(schema)
184
+ schema.reject! { |k, _| %w[schema_migrations ar_internal_metadata].include?(k) }
185
+ schema
186
+ end
187
+
188
+ def map_rails_type_to_sql(rails_type, limit = nil)
189
+ type_map = build_type_map(limit)
190
+ type_map[rails_type] || rails_type.upcase
191
+ end
192
+
193
+ def build_type_map(limit)
194
+ base_types.merge('string' => string_type(limit))
195
+ end
196
+
197
+ def base_types
198
+ numeric_types.merge(text_types).merge(date_types).merge(other_types)
199
+ end
200
+
201
+ def numeric_types
202
+ {
203
+ 'integer' => 'INTEGER',
204
+ 'bigint' => 'BIGINT',
205
+ 'float' => 'FLOAT',
206
+ 'decimal' => 'DECIMAL'
207
+ }
208
+ end
209
+
210
+ def text_types
211
+ {
212
+ 'text' => 'TEXT',
213
+ 'binary' => 'BLOB'
214
+ }
215
+ end
216
+
217
+ def date_types
218
+ {
219
+ 'datetime' => 'DATETIME',
220
+ 'timestamp' => 'TIMESTAMP',
221
+ 'time' => 'TIME',
222
+ 'date' => 'DATE'
223
+ }
224
+ end
225
+
226
+ def other_types
227
+ {
228
+ 'boolean' => 'BOOLEAN',
229
+ 'json' => 'JSON',
230
+ 'jsonb' => 'JSONB'
231
+ }
232
+ end
233
+
234
+ def string_type(limit)
235
+ limit ? "VARCHAR(#{limit})" : 'VARCHAR(255)'
236
+ end
237
+
238
+ def extract_default_value(default_str)
239
+ return 'NULL' if default_str.nil?
240
+
241
+ default_str = default_str.strip
242
+ # Remove quotes if present
243
+ default_str = default_str.gsub(/^["']|["']$/, '')
244
+ # Check if it's a number
245
+ return default_str if default_str.match?(/^\d+$/)
246
+ # Check if it's a boolean
247
+ return default_str if %w[true false].include?(default_str)
248
+
249
+ # Otherwise return as string
250
+ "'#{default_str}'"
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicQuery
4
+ module Schema
5
+ class Validator
6
+ def self.validate(schema)
7
+ new(schema).validate
8
+ end
9
+
10
+ def initialize(schema)
11
+ @schema = schema
12
+ end
13
+
14
+ def validate
15
+ errors = []
16
+
17
+ unless @schema.is_a?(Hash)
18
+ errors << 'Schema must be a Hash'
19
+ return errors
20
+ end
21
+
22
+ @schema.each do |table_name, table_info|
23
+ errors.concat(validate_table(table_name, table_info))
24
+ end
25
+
26
+ errors
27
+ end
28
+
29
+ def validate_table(table_name, table_info)
30
+ errors = []
31
+ unless table_info.is_a?(Hash)
32
+ errors << "Table #{table_name}: must be a Hash"
33
+ return errors
34
+ end
35
+
36
+ errors << "Table #{table_name}: columns must be an Array" unless table_info[:columns].is_a?(Array)
37
+ errors
38
+ end
39
+
40
+ def valid?
41
+ validate.empty?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MagicQuery
4
+ VERSION = '0.1.1'
5
+ end