ffast 0.2.0 → 0.2.3
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 +4 -4
- data/.github/workflows/release.yml +27 -0
- data/.github/workflows/ruby.yml +34 -0
- data/.gitignore +2 -0
- data/Fastfile +146 -3
- data/README.md +244 -132
- data/bin/console +6 -1
- data/bin/fast-experiment +3 -0
- data/bin/fast-mcp +7 -0
- data/fast.gemspec +24 -7
- data/lib/fast/cli.rb +129 -38
- data/lib/fast/experiment.rb +19 -2
- data/lib/fast/git.rb +1 -1
- data/lib/fast/mcp_server.rb +317 -0
- data/lib/fast/node.rb +258 -0
- data/lib/fast/prism_adapter.rb +310 -0
- data/lib/fast/rewriter.rb +64 -10
- data/lib/fast/scan.rb +203 -0
- data/lib/fast/shortcut.rb +23 -6
- data/lib/fast/source.rb +116 -0
- data/lib/fast/source_rewriter.rb +153 -0
- data/lib/fast/sql/rewriter.rb +98 -0
- data/lib/fast/sql.rb +165 -0
- data/lib/fast/summary.rb +435 -0
- data/lib/fast/version.rb +1 -1
- data/lib/fast.rb +165 -79
- data/mkdocs.yml +27 -3
- data/requirements-docs.txt +3 -0
- metadata +48 -62
- data/docs/command_line.md +0 -238
- data/docs/editors-integration.md +0 -46
- data/docs/experiments.md +0 -153
- data/docs/ideas.md +0 -80
- data/docs/index.md +0 -402
- data/docs/pry-integration.md +0 -27
- data/docs/research.md +0 -93
- data/docs/shortcuts.md +0 -323
- data/docs/similarity_tutorial.md +0 -176
- data/docs/syntax.md +0 -395
- data/docs/videos.md +0 -16
- data/examples/build_stubbed_and_let_it_be_experiment.rb +0 -51
- data/examples/experimental_replacement.rb +0 -46
- data/examples/find_usage.rb +0 -26
- data/examples/let_it_be_experiment.rb +0 -11
- data/examples/method_complexity.rb +0 -37
- data/examples/search_duplicated.rb +0 -15
- data/examples/similarity_research.rb +0 -58
- data/examples/simple_rewriter.rb +0 -6
- data/experiments/let_it_be_experiment.rb +0 -9
- data/experiments/remove_useless_hook.rb +0 -9
- data/experiments/replace_create_with_build_stubbed.rb +0 -10
data/lib/fast/shortcut.rb
CHANGED
|
@@ -7,14 +7,18 @@ module Fast
|
|
|
7
7
|
# 1. Current directory that the command is being runned
|
|
8
8
|
# 2. Home folder
|
|
9
9
|
# 3. Using the `FAST_FILE_DIR` variable to set an extra folder
|
|
10
|
-
LOOKUP_FAST_FILES_DIRECTORIES = [
|
|
10
|
+
LOOKUP_FAST_FILES_DIRECTORIES = [
|
|
11
|
+
Dir.pwd,
|
|
12
|
+
ENV['HOME'],
|
|
13
|
+
ENV['FAST_FILE_DIR'],
|
|
14
|
+
File.join(File.dirname(__FILE__), '..', '..')
|
|
15
|
+
].reverse.compact.map(&File.method(:expand_path)).uniq.freeze
|
|
11
16
|
|
|
12
17
|
# Store predefined searches with default paths through shortcuts.
|
|
13
18
|
# define your Fastfile in you root folder or
|
|
14
19
|
# @example Shortcut for finding validations in rails models
|
|
15
20
|
# Fast.shortcut(:validations, "(send nil {validate validates})", "app/models")
|
|
16
21
|
def shortcut(identifier, *args, &block)
|
|
17
|
-
puts "identifier #{identifier.inspect} will be override" if shortcuts.key?(identifier)
|
|
18
22
|
shortcuts[identifier] = Shortcut.new(*args, &block)
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -29,12 +33,25 @@ module Fast
|
|
|
29
33
|
def fast_files
|
|
30
34
|
@fast_files ||= LOOKUP_FAST_FILES_DIRECTORIES.compact
|
|
31
35
|
.map { |dir| File.join(dir, 'Fastfile') }
|
|
32
|
-
.select(&File.method(:
|
|
36
|
+
.select(&File.method(:exist?))
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# Loads `Fastfiles` from {.fast_files} list
|
|
36
40
|
def load_fast_files!
|
|
37
|
-
|
|
41
|
+
@loaded_fast_files ||= []
|
|
42
|
+
fast_files.each do |file|
|
|
43
|
+
next if @loaded_fast_files.include?(file)
|
|
44
|
+
|
|
45
|
+
load file
|
|
46
|
+
@loaded_fast_files << file
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_markdown_for_terminal(line)
|
|
51
|
+
require 'tty-markdown'
|
|
52
|
+
TTY::Markdown.parse(line)
|
|
53
|
+
rescue LoadError
|
|
54
|
+
line
|
|
38
55
|
end
|
|
39
56
|
end
|
|
40
57
|
|
|
@@ -60,7 +77,7 @@ module Fast
|
|
|
60
77
|
def merge_args(extra_args)
|
|
61
78
|
all_args = (@args + extra_args).uniq
|
|
62
79
|
options = all_args.select { |arg| arg.start_with? '-' }
|
|
63
|
-
files = extra_args.select(&File.method(:
|
|
80
|
+
files = extra_args.select(&File.method(:exist?))
|
|
64
81
|
command = (@args - options - files).first
|
|
65
82
|
|
|
66
83
|
[command, *options, *files]
|
|
@@ -73,7 +90,7 @@ module Fast
|
|
|
73
90
|
# Use ARGV to catch regular arguments from command line if the block is
|
|
74
91
|
# given.
|
|
75
92
|
#
|
|
76
|
-
# @return [Hash<String, Array<
|
|
93
|
+
# @return [Hash<String, Array<Fast::Node>>] with file => search results.
|
|
77
94
|
def run
|
|
78
95
|
Fast.instance_exec(&@block) if single_run_with_block?
|
|
79
96
|
end
|
data/lib/fast/source.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fast
|
|
4
|
+
module Source
|
|
5
|
+
class Buffer
|
|
6
|
+
attr_accessor :source
|
|
7
|
+
attr_reader :name
|
|
8
|
+
|
|
9
|
+
def initialize(name, source: nil)
|
|
10
|
+
@name = name
|
|
11
|
+
@source = source
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def source_range(begin_pos = 0, end_pos = source.to_s.length)
|
|
15
|
+
Fast::Source.range(self, begin_pos, end_pos)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Range
|
|
20
|
+
attr_reader :begin_pos, :end_pos, :source_buffer
|
|
21
|
+
|
|
22
|
+
def initialize(source_buffer, begin_pos, end_pos)
|
|
23
|
+
@source_buffer = source_buffer
|
|
24
|
+
@begin_pos = begin_pos
|
|
25
|
+
@end_pos = end_pos
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def begin
|
|
29
|
+
self.class.new(source_buffer, begin_pos, begin_pos)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def end
|
|
33
|
+
self.class.new(source_buffer, end_pos, end_pos)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def source
|
|
37
|
+
source_buffer.source.to_s[begin_pos...end_pos]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def line
|
|
41
|
+
first_line
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def first_line
|
|
45
|
+
source_buffer.source.to_s[0...begin_pos].count("\n") + 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def last_line
|
|
49
|
+
source_buffer.source.to_s[0...end_pos].count("\n") + 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def column
|
|
53
|
+
last_newline = source_buffer.source.to_s.rindex("\n", begin_pos - 1)
|
|
54
|
+
begin_pos - (last_newline ? last_newline + 1 : 0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_range
|
|
58
|
+
begin_pos...end_pos
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def join(other)
|
|
62
|
+
self.class.new(source_buffer, [begin_pos, other.begin_pos].min, [end_pos, other.end_pos].max)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def adjust(begin_pos: 0, end_pos: 0)
|
|
66
|
+
self.class.new(source_buffer, self.begin_pos + begin_pos, self.end_pos + end_pos)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class Map
|
|
71
|
+
attr_accessor :expression, :node
|
|
72
|
+
|
|
73
|
+
def initialize(expression)
|
|
74
|
+
@expression = expression
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def begin
|
|
78
|
+
expression.begin_pos
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def end
|
|
82
|
+
expression.end_pos
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def with_expression(new_expression)
|
|
86
|
+
duplicate_with(expression: new_expression)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def with_operator(operator)
|
|
90
|
+
duplicate_with(operator: operator)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def duplicate_with(overrides = {})
|
|
96
|
+
copy = dup
|
|
97
|
+
overrides.each { |name, value| copy.instance_variable_set(:"@#{name}", value) }
|
|
98
|
+
copy
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
module_function
|
|
103
|
+
|
|
104
|
+
def buffer(name, source: nil, buffer_class: Fast::Source::Buffer)
|
|
105
|
+
buffer_class.new(name, source: source)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def range(buffer, start_pos, end_pos)
|
|
109
|
+
Fast::Source::Range.new(buffer, start_pos, end_pos)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def map(expression)
|
|
113
|
+
Fast::Source::Map.new(expression)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'source'
|
|
4
|
+
|
|
5
|
+
module Fast
|
|
6
|
+
class SourceRewriter
|
|
7
|
+
Edit = Struct.new(:kind, :begin_pos, :end_pos, :content, :order, keyword_init: true)
|
|
8
|
+
ClobberingError = Class.new(StandardError)
|
|
9
|
+
|
|
10
|
+
attr_reader :source_buffer
|
|
11
|
+
|
|
12
|
+
def initialize(source_buffer)
|
|
13
|
+
@source_buffer = source_buffer
|
|
14
|
+
@edits = []
|
|
15
|
+
@order = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def replace(range, content)
|
|
19
|
+
add_edit(:replace, range, content.to_s)
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def remove(range)
|
|
24
|
+
replace(range, '')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def wrap(range, before, after)
|
|
28
|
+
insert_before(range, before) unless before.nil?
|
|
29
|
+
insert_after(range, after) unless after.nil?
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def insert_before(range, content)
|
|
34
|
+
add_edit(:insert_before, range.begin, content.to_s)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def insert_after(range, content)
|
|
39
|
+
add_edit(:insert_after, range.end, content.to_s)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def process
|
|
44
|
+
source = source_buffer.source.to_s
|
|
45
|
+
normalized_replacements = normalize_replacements
|
|
46
|
+
return source if normalized_replacements.empty? && insertions.empty?
|
|
47
|
+
|
|
48
|
+
before_insertions = build_insertions(:insert_before)
|
|
49
|
+
after_insertions = build_insertions(:insert_after)
|
|
50
|
+
|
|
51
|
+
result = +''
|
|
52
|
+
cursor = 0
|
|
53
|
+
|
|
54
|
+
normalized_replacements.each do |replacement|
|
|
55
|
+
result << emit_unreplaced_segment(source, cursor, replacement.begin_pos, before_insertions, after_insertions)
|
|
56
|
+
result << before_insertions.fetch(replacement.begin_pos, '')
|
|
57
|
+
result << replacement.content
|
|
58
|
+
result << after_insertions.fetch(replacement.end_pos, '')
|
|
59
|
+
cursor = replacement.end_pos
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
result << emit_unreplaced_segment(source, cursor, source.length, before_insertions, after_insertions)
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
attr_reader :edits
|
|
69
|
+
|
|
70
|
+
def add_edit(kind, range, content)
|
|
71
|
+
edits << Edit.new(
|
|
72
|
+
kind: kind,
|
|
73
|
+
begin_pos: range.begin_pos,
|
|
74
|
+
end_pos: range.end_pos,
|
|
75
|
+
content: content,
|
|
76
|
+
order: next_order
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def next_order
|
|
81
|
+
@order += 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def insertions
|
|
85
|
+
edits.select { |edit| insertion?(edit) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def replacements
|
|
89
|
+
edits.reject { |edit| insertion?(edit) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def insertion?(edit)
|
|
93
|
+
edit.kind == :insert_before || edit.kind == :insert_after
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_replacements
|
|
97
|
+
replacements
|
|
98
|
+
.sort_by { |edit| [edit.begin_pos, edit.end_pos, edit.order] }
|
|
99
|
+
.each_with_object([]) do |edit, normalized|
|
|
100
|
+
previous = normalized.last
|
|
101
|
+
if previous && overlaps?(previous, edit)
|
|
102
|
+
if deletion?(previous) && deletion?(edit)
|
|
103
|
+
previous.end_pos = [previous.end_pos, edit.end_pos].max
|
|
104
|
+
elsif same_range?(previous, edit)
|
|
105
|
+
previous.content = edit.content
|
|
106
|
+
previous.order = edit.order
|
|
107
|
+
else
|
|
108
|
+
raise ClobberingError, "Overlapping rewrite on #{edit.begin_pos}...#{edit.end_pos}"
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
normalized << edit.dup
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def overlaps?(left, right)
|
|
117
|
+
left.begin_pos < right.end_pos && right.begin_pos < left.end_pos
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def same_range?(left, right)
|
|
121
|
+
left.begin_pos == right.begin_pos && left.end_pos == right.end_pos
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def deletion?(edit)
|
|
125
|
+
edit.content.empty?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_insertions(kind)
|
|
129
|
+
edits
|
|
130
|
+
.select { |edit| edit.kind == kind }
|
|
131
|
+
.group_by(&:begin_pos)
|
|
132
|
+
.transform_values do |position_edits|
|
|
133
|
+
position_edits
|
|
134
|
+
.sort_by(&:order)
|
|
135
|
+
.map(&:content)
|
|
136
|
+
.join
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def emit_unreplaced_segment(source, from, to, before_insertions, after_insertions)
|
|
141
|
+
segment = +''
|
|
142
|
+
cursor = from
|
|
143
|
+
|
|
144
|
+
while cursor < to
|
|
145
|
+
segment << before_insertions.fetch(cursor, '')
|
|
146
|
+
segment << source[cursor]
|
|
147
|
+
cursor += 1
|
|
148
|
+
segment << after_insertions.fetch(cursor, '')
|
|
149
|
+
end
|
|
150
|
+
segment
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module Fast
|
|
2
|
+
module SQL
|
|
3
|
+
class << self
|
|
4
|
+
# @see Fast::SQLRewriter
|
|
5
|
+
# @return string with the content updated in case the pattern matches.
|
|
6
|
+
def replace(pattern, ast, &replacement)
|
|
7
|
+
rewriter_for(pattern, ast, &replacement).rewrite!
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# @return [Fast::SQL::Rewriter]
|
|
11
|
+
# @see Fast::Rewriter
|
|
12
|
+
def rewriter_for(pattern, ast, &replacement)
|
|
13
|
+
rewriter = Rewriter.new
|
|
14
|
+
rewriter.ast = ast
|
|
15
|
+
rewriter.search = pattern
|
|
16
|
+
rewriter.replacement = replacement
|
|
17
|
+
rewriter
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return Fast::SQL::Node with the parsed content
|
|
21
|
+
def parse_file(file)
|
|
22
|
+
parse(IO.read(file), buffer_name: file)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Replace a SQL file with the given pattern.
|
|
26
|
+
# Use a replacement code block to change the content.
|
|
27
|
+
# @return nil in case does not update the file
|
|
28
|
+
# @return true in case the file is updated
|
|
29
|
+
# @see Fast::SQL::Rewriter
|
|
30
|
+
def replace_file(pattern, file, &replacement)
|
|
31
|
+
original = IO.read(file)
|
|
32
|
+
ast = parse_file(file)
|
|
33
|
+
content = replace(pattern, ast, &replacement)
|
|
34
|
+
if content != original
|
|
35
|
+
File.open(file, 'w+') { |f| f.print content }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Extends fast rewriter to support SQL
|
|
41
|
+
# @see Fast::Rewriter
|
|
42
|
+
class Rewriter < Fast::Rewriter
|
|
43
|
+
|
|
44
|
+
def rewrite!
|
|
45
|
+
replace_on(*types)
|
|
46
|
+
case ast
|
|
47
|
+
when Array
|
|
48
|
+
rewrite(buffer, ast.first)
|
|
49
|
+
else
|
|
50
|
+
rewrite(buffer, ast)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def source
|
|
55
|
+
super ||
|
|
56
|
+
begin
|
|
57
|
+
case ast
|
|
58
|
+
when Array
|
|
59
|
+
ast.first
|
|
60
|
+
else
|
|
61
|
+
ast
|
|
62
|
+
end.location.expression.source_buffer.source
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
# @return [Array<Symbol>] with all types that matches
|
|
66
|
+
def types
|
|
67
|
+
case ast
|
|
68
|
+
when Array
|
|
69
|
+
ast.map(&:type)
|
|
70
|
+
when NilClass
|
|
71
|
+
[]
|
|
72
|
+
else
|
|
73
|
+
ast.type
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Generate methods for all affected types.
|
|
78
|
+
# Note the strategy is different from parent class,
|
|
79
|
+
# it will not stop on first match, but will execute the replacement on
|
|
80
|
+
# all matching elements.
|
|
81
|
+
# @see Fast.replace
|
|
82
|
+
def replace_on(*types)
|
|
83
|
+
types.map do |type|
|
|
84
|
+
self.instance_exec do
|
|
85
|
+
self.class.define_method :"on_#{type}" do |node|
|
|
86
|
+
# SQL nodes are not being automatically invoked by the rewriter,
|
|
87
|
+
# so we need to match the root node and invoke on matching inner elements.
|
|
88
|
+
Fast.search(search, ast).each_with_index do |node, i|
|
|
89
|
+
@match_index += 1
|
|
90
|
+
execute_replacement(node, i)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/fast/sql.rb
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require 'pg_query'
|
|
2
|
+
require_relative '../fast/source'
|
|
3
|
+
require_relative 'sql/rewriter'
|
|
4
|
+
|
|
5
|
+
module Fast
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Shortcut to parse a sql file
|
|
10
|
+
# @example Fast.parse_sql_file('spec/fixtures/sql/select.sql')
|
|
11
|
+
# @return [Fast::Node] the AST representation of the sql statements from a file
|
|
12
|
+
def parse_sql_file(file)
|
|
13
|
+
SQL.parse_file(file)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Fast::SQLRewriter] which can be used to rewrite the SQL
|
|
17
|
+
# @see Fast::SQLRewriter
|
|
18
|
+
def sql_rewriter_for(pattern, ast, &replacement)
|
|
19
|
+
SQL.rewriter_for(pattern, ast, &replacement)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return string with the sql content updated in case the pattern matches.
|
|
23
|
+
# @see Fast::SQLRewriter
|
|
24
|
+
# @example
|
|
25
|
+
# Fast.replace_sql('ival', Fast.parse_sql('select 1'), &->(node){ replace(node.location.expression, '2') }) # => "select 2"
|
|
26
|
+
def replace_sql(pattern, ast, &replacement)
|
|
27
|
+
SQL.replace(pattern, ast, &replacement)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return string with the sql content updated in case the pattern matches.
|
|
31
|
+
def replace_sql_file(pattern, file, &replacement)
|
|
32
|
+
SQL.replace_file(pattern, file, &replacement)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Fast::Node] the AST representation of the sql statement
|
|
36
|
+
# @example
|
|
37
|
+
# ast = Fast.parse_sql("select 'hello AST'")
|
|
38
|
+
# => s(:select_stmt,
|
|
39
|
+
# s(:target_list,
|
|
40
|
+
# s(:res_target,
|
|
41
|
+
# s(:val,
|
|
42
|
+
# s(:a_const,
|
|
43
|
+
# s(:val,
|
|
44
|
+
# s(:string,
|
|
45
|
+
# s(:str, "hello AST"))))))))
|
|
46
|
+
# `s` represents a Fast::Node with additional methods to access the tokens
|
|
47
|
+
# and location of the node.
|
|
48
|
+
# ast.search(:string).first.location.expression
|
|
49
|
+
# => #<Fast::Source::Range (sql) 7...18>
|
|
50
|
+
def parse_sql(statement, buffer_name: "(sql)")
|
|
51
|
+
SQL.parse(statement, buffer_name: buffer_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# This module contains methods to parse SQL statements and rewrite them.
|
|
55
|
+
# It uses PGQuery to parse the SQL statements.
|
|
56
|
+
# It uses Parser to rewrite the SQL statements.
|
|
57
|
+
# It uses Fast::Source::Map to map the AST nodes to the SQL tokens.
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# Fast::SQL.parse("select 1")
|
|
61
|
+
# => s(:select_stmt, s(:target_list, ...
|
|
62
|
+
# @see Fast::SQL::Node
|
|
63
|
+
module SQL
|
|
64
|
+
# The SQL source buffer is a subclass of Fast::Source::Buffer
|
|
65
|
+
# which contains the tokens of the SQL statement.
|
|
66
|
+
# When you call `ast.location.expression` it will return a range
|
|
67
|
+
# which is mapped to the tokens.
|
|
68
|
+
# @example
|
|
69
|
+
# ast = Fast::SQL.parse("select 1")
|
|
70
|
+
# ast.location.expression # => #<Fast::Source::Range (sql) 0...9>
|
|
71
|
+
# ast.location.expression.source_buffer.tokens
|
|
72
|
+
# => [
|
|
73
|
+
# <PgQuery::ScanToken: start: 0, end: 6, token: :SELECT, keyword_kind: :RESERVED_KEYWORD>,
|
|
74
|
+
# <PgQuery::ScanToken: start: 7, end: 8, token: :ICONST, keyword_kind: :NO_KEYWORD>]
|
|
75
|
+
# @see Fast::SQL::Node
|
|
76
|
+
class SourceBuffer < Fast::Source::Buffer
|
|
77
|
+
def tokens
|
|
78
|
+
@tokens ||= PgQuery.scan(source).first.tokens
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The SQL node is an AST node with additional tokenization info
|
|
83
|
+
class Node < Fast::Node
|
|
84
|
+
|
|
85
|
+
def first(pattern)
|
|
86
|
+
search(pattern).first
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def replace(pattern, with=nil, &replacement)
|
|
90
|
+
replacement ||= -> (n) { replace(n.loc.expression, with) }
|
|
91
|
+
if root?
|
|
92
|
+
SQL.replace(pattern, self, &replacement)
|
|
93
|
+
else
|
|
94
|
+
parent.replace(pattern, &replacement)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def token
|
|
99
|
+
tokens.find{|e|e.start == location.begin}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def tokens
|
|
103
|
+
location.expression.source_buffer.tokens
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
module_function
|
|
108
|
+
|
|
109
|
+
# Parses SQL statements Using PGQuery
|
|
110
|
+
# @see sql_to_h
|
|
111
|
+
def parse(statement, buffer_name: "(sql)")
|
|
112
|
+
return [] if statement.nil?
|
|
113
|
+
source_buffer = SQL::SourceBuffer.new(buffer_name, source: statement)
|
|
114
|
+
tree = PgQuery.parse(statement).tree
|
|
115
|
+
first, *, last = source_buffer.tokens
|
|
116
|
+
stmts = tree.stmts.map do |stmt|
|
|
117
|
+
from = stmt.stmt_location
|
|
118
|
+
to = stmt.stmt_len.zero? ? last.end : from + stmt.stmt_len
|
|
119
|
+
expression = Fast::Source.range(source_buffer, from, to)
|
|
120
|
+
source_map = Fast::Source.map(expression)
|
|
121
|
+
sql_tree_to_ast(clean_structure(stmt.stmt.to_h), source_buffer: source_buffer, source_map: source_map)
|
|
122
|
+
end.flatten
|
|
123
|
+
stmts.one? ? stmts.first : stmts
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Clean up the hash structure returned by PgQuery
|
|
127
|
+
# @arg [Hash] hash the hash representation of the sql statement
|
|
128
|
+
# @return [Hash] the hash representation of the sql statement
|
|
129
|
+
def clean_structure(stmt)
|
|
130
|
+
res_hash = stmt.map do |key, value|
|
|
131
|
+
value = clean_structure(value) if value.is_a?(Hash)
|
|
132
|
+
value = value.map(&Fast::SQL.method(:clean_structure)) if value.is_a?(Array)
|
|
133
|
+
value = nil if [{}, [], "", :SETOP_NONE, :LIMIT_OPTION_DEFAULT, false].include?(value)
|
|
134
|
+
key = key.to_s.tr('-','_').to_sym
|
|
135
|
+
[key, value]
|
|
136
|
+
end
|
|
137
|
+
res_hash.to_h.compact
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Transform a sql tree into an AST.
|
|
141
|
+
# Populates the location of the AST nodes with the source map.
|
|
142
|
+
# @arg [Hash] obj the hash representation of the sql statement
|
|
143
|
+
# @return [Array] the AST representation of the sql statement
|
|
144
|
+
def sql_tree_to_ast(obj, source_buffer: nil, source_map: nil)
|
|
145
|
+
recursive = -> (e) { sql_tree_to_ast(e, source_buffer: source_buffer, source_map: source_map.dup) }
|
|
146
|
+
case obj
|
|
147
|
+
when Array
|
|
148
|
+
obj.map(&recursive).flatten.compact
|
|
149
|
+
when Hash
|
|
150
|
+
if (start = obj.delete(:location))
|
|
151
|
+
if (token = source_buffer.tokens.find{|e|e.start == start})
|
|
152
|
+
expression = Fast::Source.range(source_buffer, token.start, token.end)
|
|
153
|
+
source_map = Fast::Source.map(expression)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
obj.map do |key, value|
|
|
157
|
+
children = [*recursive.call(value)]
|
|
158
|
+
Node.new(key, children, location: source_map)
|
|
159
|
+
end.compact
|
|
160
|
+
else
|
|
161
|
+
obj
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|