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,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,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
|