ffast 0.1.9 → 0.2.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +104 -1
- data/.sourcelevel.yml +2 -0
- data/.travis.yml +1 -1
- data/Fastfile +59 -3
- data/README.md +228 -130
- data/bin/console +5 -0
- data/docs/experiments.md +2 -0
- data/docs/git.md +115 -0
- data/docs/ideas.md +0 -10
- data/docs/index.md +2 -0
- data/docs/sql-support.md +253 -0
- data/docs/videos.md +5 -1
- data/docs/walkthrough.md +135 -0
- data/fast.gemspec +24 -4
- data/lib/fast/cli.rb +102 -24
- data/lib/fast/experiment.rb +1 -2
- data/lib/fast/git.rb +101 -0
- data/lib/fast/shortcut.rb +13 -11
- data/lib/fast/sql/rewriter.rb +69 -0
- data/lib/fast/sql.rb +167 -0
- data/lib/fast/version.rb +1 -1
- data/lib/fast.rb +114 -17
- data/mkdocs.yml +10 -1
- metadata +66 -15
data/lib/fast/cli.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require 'fast'
|
4
4
|
require 'fast/version'
|
5
|
+
require 'fast/sql'
|
5
6
|
require 'coderay'
|
6
7
|
require 'optparse'
|
7
8
|
require 'ostruct'
|
@@ -16,16 +17,44 @@ module Fast
|
|
16
17
|
# Useful for printing code with syntax highlight.
|
17
18
|
# @param show_sexp [Boolean] prints node expression instead of code
|
18
19
|
# @param colorize [Boolean] skips `CodeRay` processing when false.
|
19
|
-
def highlight(node, show_sexp: false, colorize: true)
|
20
|
+
def highlight(node, show_sexp: false, colorize: true, sql: false)
|
20
21
|
output =
|
21
22
|
if node.respond_to?(:loc) && !show_sexp
|
22
|
-
node.
|
23
|
+
wrap_source_range(node).source
|
23
24
|
else
|
24
25
|
node
|
25
26
|
end
|
26
27
|
return output unless colorize
|
27
28
|
|
28
|
-
CodeRay.scan(output, :ruby).term
|
29
|
+
CodeRay.scan(output, sql ? :sql : :ruby).term
|
30
|
+
end
|
31
|
+
|
32
|
+
# Fixes initial spaces to print the line since the beginning
|
33
|
+
# and fixes end of the expression including heredoc strings.
|
34
|
+
def wrap_source_range(node)
|
35
|
+
expression = node.loc.expression
|
36
|
+
Parser::Source::Range.new(
|
37
|
+
expression.source_buffer,
|
38
|
+
first_position_from_expression(node),
|
39
|
+
last_position_from_expression(node) || expression.end_pos
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
# If a method call contains a heredoc, it should print the STR around it too.
|
44
|
+
def last_position_from_expression(node)
|
45
|
+
internal_heredoc = node.each_descendant(:str).select { |n| n.loc.respond_to?(:heredoc_end) }
|
46
|
+
internal_heredoc.map { |n| n.loc.heredoc_end.end_pos }.max if internal_heredoc.any?
|
47
|
+
end
|
48
|
+
|
49
|
+
# If a node is the first on it's line, print since the beginning of the line
|
50
|
+
# to show the proper whitespaces for identing the next lines of the code.
|
51
|
+
def first_position_from_expression(node)
|
52
|
+
expression = node.loc.expression
|
53
|
+
if node.parent && node.parent.loc.expression.line != expression.line
|
54
|
+
expression.begin_pos - expression.column
|
55
|
+
else
|
56
|
+
expression.begin_pos
|
57
|
+
end
|
29
58
|
end
|
30
59
|
|
31
60
|
# Combines {.highlight} with files printing file name in the head with the
|
@@ -35,13 +64,19 @@ module Fast
|
|
35
64
|
# @param file [String] Show the file name and result line before content
|
36
65
|
# @param headless [Boolean] Skip printing the file name and line before content
|
37
66
|
# @example
|
38
|
-
# Fast.
|
39
|
-
def report(result, show_sexp: false, file: nil, headless: false, colorize: true)
|
67
|
+
# Fast.report(result, file: 'file.rb')
|
68
|
+
def report(result, show_link: false, show_permalink: false, show_sexp: false, file: nil, headless: false, bodyless: false, colorize: true) # rubocop:disable Metrics/ParameterLists
|
40
69
|
if file
|
41
70
|
line = result.loc.expression.line if result.is_a?(Parser::AST::Node)
|
42
|
-
|
71
|
+
if show_link
|
72
|
+
puts(result.link)
|
73
|
+
elsif show_permalink
|
74
|
+
puts(result.permalink)
|
75
|
+
elsif !headless
|
76
|
+
puts(highlight("# #{file}:#{line}", colorize: colorize))
|
77
|
+
end
|
43
78
|
end
|
44
|
-
puts
|
79
|
+
puts(highlight(result, show_sexp: show_sexp, colorize: colorize)) unless bodyless
|
45
80
|
end
|
46
81
|
|
47
82
|
# Command Line Interface for Fast
|
@@ -56,6 +91,9 @@ module Fast
|
|
56
91
|
option_parser.parse! args
|
57
92
|
|
58
93
|
@files = [*@files].reject { |arg| arg.start_with?('-') }
|
94
|
+
@sql ||= @files.any? && @files.all? { |file| file.end_with?('.sql') }
|
95
|
+
|
96
|
+
require 'fast/sql' if @sql
|
59
97
|
end
|
60
98
|
|
61
99
|
def option_parser # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
@@ -69,10 +107,24 @@ module Fast
|
|
69
107
|
@show_sexp = true
|
70
108
|
end
|
71
109
|
|
110
|
+
opts.on('--link', 'Print link to repository URL') do
|
111
|
+
require 'fast/git'
|
112
|
+
@show_link = true
|
113
|
+
end
|
114
|
+
|
115
|
+
opts.on('--permalink', 'Print permalink to repository URL') do
|
116
|
+
require 'fast/git'
|
117
|
+
@show_permalink = true
|
118
|
+
end
|
119
|
+
|
72
120
|
opts.on('-p', '--parallel', 'Paralelize search') do
|
73
121
|
@parallel = true
|
74
122
|
end
|
75
123
|
|
124
|
+
opts.on("--sql", "Use SQL instead of Ruby") do
|
125
|
+
@sql = true
|
126
|
+
end
|
127
|
+
|
76
128
|
opts.on('--captures', 'Print only captures of the patterns and skip node results') do
|
77
129
|
@captures = true
|
78
130
|
end
|
@@ -81,29 +133,27 @@ module Fast
|
|
81
133
|
@headless = true
|
82
134
|
end
|
83
135
|
|
136
|
+
opts.on('--bodyless', 'Print results without the code details') do
|
137
|
+
@bodyless = true
|
138
|
+
end
|
139
|
+
|
84
140
|
opts.on('--pry', 'Jump into a pry session with results') do
|
85
141
|
@pry = true
|
86
142
|
require 'pry'
|
87
143
|
end
|
88
144
|
|
89
|
-
opts.on('-c', '--code', 'Create a pattern from code example') do
|
90
|
-
if @pattern
|
91
|
-
@from_code = true
|
92
|
-
@pattern = Fast.ast(@pattern).to_sexp
|
93
|
-
debug 'Expression from AST:', @pattern
|
94
|
-
end
|
95
|
-
end
|
96
|
-
|
97
145
|
opts.on('-s', '--similar', 'Search for similar code.') do
|
98
146
|
@similar = true
|
99
|
-
@pattern = Fast.expression_from(Fast.ast(@pattern))
|
100
|
-
debug "Looking for code similar to #{@pattern}"
|
101
147
|
end
|
102
148
|
|
103
149
|
opts.on('--no-color', 'Disable color output') do
|
104
150
|
@colorize = false
|
105
151
|
end
|
106
152
|
|
153
|
+
opts.on('--from-code', 'From code') do
|
154
|
+
@from_code = true
|
155
|
+
end
|
156
|
+
|
107
157
|
opts.on_tail('--version', 'Show version') do
|
108
158
|
puts Fast::VERSION
|
109
159
|
exit
|
@@ -116,12 +166,13 @@ module Fast
|
|
116
166
|
end
|
117
167
|
|
118
168
|
def replace_args_with_shortcut(args)
|
119
|
-
shortcut = find_shortcut args.first[1
|
169
|
+
shortcut = find_shortcut args.first[1..]
|
170
|
+
|
120
171
|
if shortcut.single_run_with_block?
|
121
172
|
shortcut.run
|
122
173
|
exit
|
123
174
|
else
|
124
|
-
args.one? ? shortcut.args : shortcut.merge_args(args[1
|
175
|
+
args.one? ? shortcut.args : shortcut.merge_args(args[1..])
|
125
176
|
end
|
126
177
|
end
|
127
178
|
|
@@ -137,6 +188,25 @@ module Fast
|
|
137
188
|
|
138
189
|
if @help || @files.empty? && @pattern.nil?
|
139
190
|
puts option_parser.help
|
191
|
+
return
|
192
|
+
end
|
193
|
+
|
194
|
+
if @similar
|
195
|
+
ast = Fast.public_send( @sql ? :parse_sql : :ast, @pattern)
|
196
|
+
@pattern = Fast.expression_from(ast)
|
197
|
+
debug "Search similar to #{@pattern}"
|
198
|
+
elsif @from_code
|
199
|
+
ast = Fast.public_send( @sql ? :parse_sql : :ast, @pattern)
|
200
|
+
@pattern = ast.to_sexp
|
201
|
+
if @sql
|
202
|
+
@pattern.gsub!(/\b-\b/,'_')
|
203
|
+
end
|
204
|
+
debug "Search from code to #{@pattern}"
|
205
|
+
end
|
206
|
+
|
207
|
+
if @files.empty?
|
208
|
+
ast ||= Fast.public_send( @sql ? :parse_sql : :ast, @pattern)
|
209
|
+
puts Fast.highlight(ast, show_sexp: @show_sexp, colorize: @colorize, sql: @sql)
|
140
210
|
else
|
141
211
|
search
|
142
212
|
end
|
@@ -167,7 +237,7 @@ module Fast
|
|
167
237
|
# @yieldparam [String, Array] with file and respective search results
|
168
238
|
def execute_search(&on_result)
|
169
239
|
Fast.public_send(search_method_name,
|
170
|
-
|
240
|
+
@pattern,
|
171
241
|
@files,
|
172
242
|
parallel: parallel?,
|
173
243
|
on_result: on_result)
|
@@ -195,18 +265,26 @@ module Fast
|
|
195
265
|
# Report results using the actual options binded from command line.
|
196
266
|
# @see Fast.report
|
197
267
|
def report(file, result)
|
198
|
-
Fast.report(result,
|
268
|
+
Fast.report(result,
|
269
|
+
file: file,
|
270
|
+
show_link: @show_link,
|
271
|
+
show_permalink: @show_permalink,
|
272
|
+
show_sexp: @show_sexp,
|
273
|
+
headless: @headless,
|
274
|
+
bodyless: @bodyless,
|
275
|
+
colorize: @colorize)
|
199
276
|
end
|
200
277
|
|
201
278
|
# Find shortcut by name. Preloads all `Fastfiles` before start.
|
202
279
|
# @param name [String]
|
203
280
|
# @return [Fast::Shortcut]
|
204
281
|
def find_shortcut(name)
|
205
|
-
|
206
|
-
|
282
|
+
unless defined? Fast::Shortcut
|
283
|
+
require 'fast/shortcut'
|
284
|
+
Fast.load_fast_files!
|
285
|
+
end
|
207
286
|
|
208
287
|
shortcut = Fast.shortcuts[name] || Fast.shortcuts[name.to_sym]
|
209
|
-
|
210
288
|
shortcut || exit_shortcut_not_found(name)
|
211
289
|
end
|
212
290
|
|
data/lib/fast/experiment.rb
CHANGED
@@ -290,7 +290,7 @@ module Fast
|
|
290
290
|
Fast.search(experiment.expression, @ast) || []
|
291
291
|
end
|
292
292
|
|
293
|
-
# rubocop:disable Metrics/
|
293
|
+
# rubocop:disable Metrics/MethodLength
|
294
294
|
#
|
295
295
|
# Execute partial replacements generating new file with the
|
296
296
|
# content replaced.
|
@@ -312,7 +312,6 @@ module Fast
|
|
312
312
|
new_content
|
313
313
|
end
|
314
314
|
|
315
|
-
# rubocop:enable Metrics/AbcSize
|
316
315
|
# rubocop:enable Metrics/MethodLength
|
317
316
|
|
318
317
|
# Write new file name depending on the combination
|
data/lib/fast/git.rb
ADDED
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Git plugin for Fast::Node.
|
4
|
+
# It allows to easily access metadata from current file.
|
5
|
+
module Fast
|
6
|
+
# This is not required by default, so to use it, you should require it first.
|
7
|
+
#
|
8
|
+
# @example
|
9
|
+
# require 'fast/git'
|
10
|
+
# Fast.ast_from_file('lib/fast.rb').git_log.first.author.name # => "Jonatas Davi Paganini"
|
11
|
+
class Node < Astrolabe::Node
|
12
|
+
# @return [Git::Base] from current directory
|
13
|
+
def git
|
14
|
+
require 'git' unless defined? Git
|
15
|
+
Git.open('.')
|
16
|
+
end
|
17
|
+
|
18
|
+
# @return [Git::Object::Blob] from current #buffer_name
|
19
|
+
def git_blob
|
20
|
+
return unless from_file?
|
21
|
+
|
22
|
+
git.gblob(buffer_name)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Git::Log] from the current #git_blob
|
26
|
+
# buffer-name
|
27
|
+
def git_log
|
28
|
+
git_blob.log
|
29
|
+
end
|
30
|
+
|
31
|
+
# @return [Git::Object::Commit]
|
32
|
+
def last_commit
|
33
|
+
git_log.first
|
34
|
+
end
|
35
|
+
|
36
|
+
# @return [String] with last commit SHA
|
37
|
+
def sha
|
38
|
+
last_commit.sha
|
39
|
+
end
|
40
|
+
|
41
|
+
# @return [String] with remote URL
|
42
|
+
def remote_url
|
43
|
+
git.remote.url
|
44
|
+
end
|
45
|
+
|
46
|
+
# Given #remote_url is "git@github.com:namespace/project.git"
|
47
|
+
# Or #remote_url is "https://github.com/namespace/project.git"
|
48
|
+
# @return [String] "https://github.com/namespace/project"
|
49
|
+
def project_url
|
50
|
+
return remote_url.gsub(/\.git$/, '') if remote_url.start_with?('https')
|
51
|
+
|
52
|
+
remote_url
|
53
|
+
.gsub('git@', 'https://')
|
54
|
+
.gsub(/:(\w)/, '/\\1')
|
55
|
+
.gsub(/\.git$/, '')
|
56
|
+
end
|
57
|
+
|
58
|
+
def file
|
59
|
+
buffer_name.gsub("#{Dir.pwd}/", '')
|
60
|
+
end
|
61
|
+
|
62
|
+
# @return
|
63
|
+
def line_range
|
64
|
+
lines.map { |l| "L#{l}" }.join('-')
|
65
|
+
end
|
66
|
+
|
67
|
+
# @return [Array] with lines range
|
68
|
+
def lines
|
69
|
+
exp = loc.expression
|
70
|
+
first_line = exp.first_line
|
71
|
+
last_line = exp.last_line
|
72
|
+
[first_line, last_line].uniq
|
73
|
+
end
|
74
|
+
|
75
|
+
# @return [Integer] lines of code from current block
|
76
|
+
def lines_of_code
|
77
|
+
lines.last - lines.first + 1
|
78
|
+
end
|
79
|
+
|
80
|
+
# @return [String] a markdown link with #md_link_description and #link
|
81
|
+
def md_link(text = md_link_description)
|
82
|
+
"[#{text}](#{link})"
|
83
|
+
end
|
84
|
+
|
85
|
+
# @return [String] with the source cutting arguments from method calls to be
|
86
|
+
# able to create a markdown link without parens.
|
87
|
+
def md_link_description
|
88
|
+
source[/([^\r\(]+)\(/, 1] || source
|
89
|
+
end
|
90
|
+
|
91
|
+
# @return [String] with formatted repositorym link
|
92
|
+
def link
|
93
|
+
"#{project_url}/blob/master/#{buffer_name}##{line_range}"
|
94
|
+
end
|
95
|
+
|
96
|
+
# @return [String] with permanent link to the actual commit
|
97
|
+
def permalink
|
98
|
+
"#{project_url}/blob/#{sha}/#{buffer_name}##{line_range}"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
data/lib/fast/shortcut.rb
CHANGED
@@ -7,7 +7,12 @@ 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
|
+
].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
|
@@ -29,7 +34,7 @@ module Fast
|
|
29
34
|
def fast_files
|
30
35
|
@fast_files ||= LOOKUP_FAST_FILES_DIRECTORIES.compact
|
31
36
|
.map { |dir| File.join(dir, 'Fastfile') }
|
32
|
-
.select(&File.method(:
|
37
|
+
.select(&File.method(:exist?))
|
33
38
|
end
|
34
39
|
|
35
40
|
# Loads `Fastfiles` from {.fast_files} list
|
@@ -54,19 +59,16 @@ module Fast
|
|
54
59
|
@block && @args.nil?
|
55
60
|
end
|
56
61
|
|
57
|
-
def options
|
58
|
-
@args.select { |arg| arg.start_with? '-' }
|
59
|
-
end
|
60
|
-
|
61
|
-
def params
|
62
|
-
@args - options
|
63
|
-
end
|
64
|
-
|
65
62
|
# Merge extra arguments from input returning a new arguments array keeping
|
66
63
|
# the options from previous alias and replacing the files with the
|
67
64
|
# @param [Array] extra_args
|
68
65
|
def merge_args(extra_args)
|
69
|
-
|
66
|
+
all_args = (@args + extra_args).uniq
|
67
|
+
options = all_args.select { |arg| arg.start_with? '-' }
|
68
|
+
files = extra_args.select(&File.method(:exist?))
|
69
|
+
command = (@args - options - files).first
|
70
|
+
|
71
|
+
[command, *options, *files]
|
70
72
|
end
|
71
73
|
|
72
74
|
# If the shortcut was defined with a single block and no extra arguments, it
|
@@ -0,0 +1,69 @@
|
|
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
|
+
sql_rewriter_for(pattern, ast, &replacement).rewrite!
|
8
|
+
end
|
9
|
+
|
10
|
+
# @return [Fast::SQL::Rewriter]
|
11
|
+
# @see Fast::Rewriter
|
12
|
+
def sql_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
|
+
# @return [Array<Symbol>] with all types that matches
|
45
|
+
def types
|
46
|
+
ast.type
|
47
|
+
end
|
48
|
+
|
49
|
+
# Generate methods for all affected types.
|
50
|
+
# Note the strategy is different from parent class, it if matches the root node, it executes otherwise it search pattern on
|
51
|
+
# all matching elements.
|
52
|
+
# @see Fast.replace
|
53
|
+
def replace_on(*types)
|
54
|
+
types.map do |type|
|
55
|
+
self.instance_exec do
|
56
|
+
self.class.define_method :"on_#{ast.type}" do |node|
|
57
|
+
# SQL nodes are not being automatically invoked by the rewriter,
|
58
|
+
# so we need to match the root node and invoke on matching inner elements.
|
59
|
+
node.search(search).each_with_index do |node, i|
|
60
|
+
@match_index += 1
|
61
|
+
execute_replacement(node, nil)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
data/lib/fast/sql.rb
ADDED
@@ -0,0 +1,167 @@
|
|
1
|
+
require 'pg_query'
|
2
|
+
require_relative 'sql/rewriter'
|
3
|
+
|
4
|
+
module Fast
|
5
|
+
|
6
|
+
module_function
|
7
|
+
|
8
|
+
# Shortcut to parse a sql file
|
9
|
+
# @example Fast.parse_sql_file('spec/fixtures/sql/select.sql')
|
10
|
+
# @return [Fast::Node] the AST representation of the sql statements from a file
|
11
|
+
def parse_sql_file(file)
|
12
|
+
SQL.parse_file(file)
|
13
|
+
end
|
14
|
+
|
15
|
+
# @return [Fast::SQLRewriter] which can be used to rewrite the SQL
|
16
|
+
# @see Fast::SQLRewriter
|
17
|
+
def sql_rewriter_for(pattern, ast, &replacement)
|
18
|
+
SQL.rewriter_for(pattern, ast, &replacement)
|
19
|
+
end
|
20
|
+
|
21
|
+
# @return string with the sql content updated in case the pattern matches.
|
22
|
+
# @see Fast::SQLRewriter
|
23
|
+
# @example
|
24
|
+
# Fast.replace_sql('ival', Fast.parse_sql('select 1'), &->(node){ replace(node.location.expression, '2') }) # => "select 2"
|
25
|
+
def replace_sql(pattern, ast, &replacement)
|
26
|
+
SQL.replace(pattern, ast, &replacement)
|
27
|
+
end
|
28
|
+
|
29
|
+
# @return string with the sql content updated in case the pattern matches.
|
30
|
+
def replace_sql_file(pattern, file, &replacement)
|
31
|
+
SQL.replace_file(pattern, file, &replacement)
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [Fast::Node] the AST representation of the sql statement
|
35
|
+
# @example
|
36
|
+
# ast = Fast.parse_sql("select 'hello AST'")
|
37
|
+
# => s(:select_stmt,
|
38
|
+
# s(:target_list,
|
39
|
+
# s(:res_target,
|
40
|
+
# s(:val,
|
41
|
+
# s(:a_const,
|
42
|
+
# s(:val,
|
43
|
+
# s(:string,
|
44
|
+
# s(:str, "hello AST"))))))))
|
45
|
+
# `s` represents a Fast::Node which is a subclass of Parser::AST::Node and
|
46
|
+
# has additional methods to access the tokens and location of the node.
|
47
|
+
# ast.search(:string).first.location.expression
|
48
|
+
# => #<Parser::Source::Range (sql) 7...18>
|
49
|
+
def parse_sql(statement, buffer_name: "(sql)")
|
50
|
+
SQL.parse(statement, buffer_name: buffer_name)
|
51
|
+
end
|
52
|
+
|
53
|
+
# This module contains methods to parse SQL statements and rewrite them.
|
54
|
+
# It uses PGQuery to parse the SQL statements.
|
55
|
+
# It uses Parser to rewrite the SQL statements.
|
56
|
+
# It uses Parser::Source::Map to map the AST nodes to the SQL tokens.
|
57
|
+
#
|
58
|
+
# @example
|
59
|
+
# Fast::SQL.parse("select 1")
|
60
|
+
# => s(:select_stmt, s(:target_list, ...
|
61
|
+
# @see Fast::SQL::Node
|
62
|
+
module SQL
|
63
|
+
# The SQL source buffer is a subclass of Parser::Source::Buffer
|
64
|
+
# which contains the tokens of the SQL statement.
|
65
|
+
# When you call `ast.location.expression` it will return a range
|
66
|
+
# which is mapped to the tokens.
|
67
|
+
# @example
|
68
|
+
# ast = Fast::SQL.parse("select 1")
|
69
|
+
# ast.location.expression # => #<Parser::Source::Range (sql) 0...9>
|
70
|
+
# ast.location.expression.source_buffer.tokens
|
71
|
+
# => [
|
72
|
+
# <PgQuery::ScanToken: start: 0, end: 6, token: :SELECT, keyword_kind: :RESERVED_KEYWORD>,
|
73
|
+
# <PgQuery::ScanToken: start: 7, end: 8, token: :ICONST, keyword_kind: :NO_KEYWORD>]
|
74
|
+
# @see Fast::SQL::Node
|
75
|
+
class SourceBuffer < Parser::Source::Buffer
|
76
|
+
def tokens
|
77
|
+
@tokens ||= PgQuery.scan(source).first.tokens
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# The SQL node is an AST node with additional tokenization info
|
82
|
+
class Node < Fast::Node
|
83
|
+
|
84
|
+
def first(pattern)
|
85
|
+
search(pattern).first
|
86
|
+
end
|
87
|
+
|
88
|
+
def replace(pattern, with=nil, &replacement)
|
89
|
+
replacement ||= -> (n) { replace(n.loc.expression, with) }
|
90
|
+
if root?
|
91
|
+
SQL.replace(pattern, self, &replacement)
|
92
|
+
else
|
93
|
+
parent.replace(pattern, &replacement)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
def token
|
98
|
+
tokens.find{|e|e.start == location.begin}
|
99
|
+
end
|
100
|
+
|
101
|
+
def tokens
|
102
|
+
location.expression.source_buffer.tokens
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
module_function
|
107
|
+
|
108
|
+
# Parses SQL statements Using PGQuery
|
109
|
+
# @see sql_to_h
|
110
|
+
def parse(statement, buffer_name: "(sql)")
|
111
|
+
return [] if statement.nil?
|
112
|
+
source_buffer = SQL::SourceBuffer.new(buffer_name, source: statement)
|
113
|
+
tree = PgQuery.parse(statement).tree
|
114
|
+
stmts = tree.stmts.map do |stmt|
|
115
|
+
v = clean_structure(stmt.stmt.to_h)
|
116
|
+
inner_stmt = statement[stmt.stmt_location, stmt.stmt_len]
|
117
|
+
first, *, last = source_buffer.tokens
|
118
|
+
from = stmt.stmt_location
|
119
|
+
to = from.zero? ? last.end : from + stmt.stmt_len
|
120
|
+
expression = Parser::Source::Range.new(source_buffer, from, to)
|
121
|
+
source_map = Parser::Source::Map.new(expression)
|
122
|
+
sql_tree_to_ast(v, source_buffer: source_buffer, source_map: source_map)
|
123
|
+
end.flatten
|
124
|
+
stmts.one? ? stmts.first : stmts
|
125
|
+
end
|
126
|
+
|
127
|
+
# Clean up the hash structure returned by PgQuery
|
128
|
+
# @arg [Hash] hash the hash representation of the sql statement
|
129
|
+
# @return [Hash] the hash representation of the sql statement
|
130
|
+
def clean_structure(stmt)
|
131
|
+
res_hash = stmt.map do |key, value|
|
132
|
+
value = clean_structure(value) if value.is_a?(Hash)
|
133
|
+
value = value.map(&Fast::SQL.method(:clean_structure)) if value.is_a?(Array)
|
134
|
+
value = nil if [{}, [], "", :SETOP_NONE, :LIMIT_OPTION_DEFAULT, false].include?(value)
|
135
|
+
key = key.to_s.tr('-','_').to_sym
|
136
|
+
[key, value]
|
137
|
+
end
|
138
|
+
res_hash.to_h.compact
|
139
|
+
end
|
140
|
+
|
141
|
+
# Transform a sql tree into an AST.
|
142
|
+
# Populates the location of the AST nodes with the source map.
|
143
|
+
# @arg [Hash] obj the hash representation of the sql statement
|
144
|
+
# @return [Array] the AST representation of the sql statement
|
145
|
+
def sql_tree_to_ast(obj, source_buffer: nil, source_map: nil)
|
146
|
+
recursive = -> (e) { sql_tree_to_ast(e, source_buffer: source_buffer, source_map: source_map.dup) }
|
147
|
+
case obj
|
148
|
+
when Array
|
149
|
+
obj.map(&recursive).flatten.compact
|
150
|
+
when Hash
|
151
|
+
if (start = obj.delete(:location))
|
152
|
+
if (token = source_buffer.tokens.find{|e|e.start == start})
|
153
|
+
expression = Parser::Source::Range.new(source_buffer, token.start, token.end)
|
154
|
+
source_map = Parser::Source::Map.new(expression)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
obj.map do |key, value|
|
158
|
+
children = [*recursive.call(value)]
|
159
|
+
Node.new(key, children, location: source_map)
|
160
|
+
end.compact
|
161
|
+
else
|
162
|
+
obj
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
data/lib/fast/version.rb
CHANGED