piggly 1.2.0

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 (59) hide show
  1. data/README.markdown +84 -0
  2. data/Rakefile +19 -0
  3. data/bin/piggly +245 -0
  4. data/lib/piggly/compiler/cache.rb +151 -0
  5. data/lib/piggly/compiler/pretty.rb +67 -0
  6. data/lib/piggly/compiler/queue.rb +46 -0
  7. data/lib/piggly/compiler/tags.rb +244 -0
  8. data/lib/piggly/compiler/trace.rb +91 -0
  9. data/lib/piggly/compiler.rb +5 -0
  10. data/lib/piggly/config.rb +43 -0
  11. data/lib/piggly/filecache.rb +40 -0
  12. data/lib/piggly/installer.rb +95 -0
  13. data/lib/piggly/parser/grammar.tt +747 -0
  14. data/lib/piggly/parser/nodes.rb +319 -0
  15. data/lib/piggly/parser/parser.rb +11783 -0
  16. data/lib/piggly/parser/traversal.rb +48 -0
  17. data/lib/piggly/parser/treetop_ruby19_patch.rb +17 -0
  18. data/lib/piggly/parser.rb +67 -0
  19. data/lib/piggly/profile.rb +87 -0
  20. data/lib/piggly/reporter/html.rb +207 -0
  21. data/lib/piggly/reporter/piggly.css +187 -0
  22. data/lib/piggly/reporter/sortable.js +493 -0
  23. data/lib/piggly/reporter.rb +21 -0
  24. data/lib/piggly/task.rb +64 -0
  25. data/lib/piggly/util.rb +28 -0
  26. data/lib/piggly/version.rb +15 -0
  27. data/lib/piggly.rb +18 -0
  28. data/spec/compiler/cache_spec.rb +9 -0
  29. data/spec/compiler/pretty_spec.rb +9 -0
  30. data/spec/compiler/queue_spec.rb +3 -0
  31. data/spec/compiler/rewrite_spec.rb +3 -0
  32. data/spec/compiler/tags_spec.rb +285 -0
  33. data/spec/compiler/trace_spec.rb +173 -0
  34. data/spec/config_spec.rb +58 -0
  35. data/spec/filecache_spec.rb +70 -0
  36. data/spec/fixtures/snippets.sql +158 -0
  37. data/spec/grammar/expression_spec.rb +302 -0
  38. data/spec/grammar/statements/assignment_spec.rb +70 -0
  39. data/spec/grammar/statements/exception_spec.rb +52 -0
  40. data/spec/grammar/statements/if_spec.rb +178 -0
  41. data/spec/grammar/statements/loop_spec.rb +41 -0
  42. data/spec/grammar/statements/sql_spec.rb +71 -0
  43. data/spec/grammar/tokens/comment_spec.rb +58 -0
  44. data/spec/grammar/tokens/datatype_spec.rb +52 -0
  45. data/spec/grammar/tokens/identifier_spec.rb +58 -0
  46. data/spec/grammar/tokens/keyword_spec.rb +44 -0
  47. data/spec/grammar/tokens/label_spec.rb +40 -0
  48. data/spec/grammar/tokens/literal_spec.rb +30 -0
  49. data/spec/grammar/tokens/lval_spec.rb +50 -0
  50. data/spec/grammar/tokens/number_spec.rb +34 -0
  51. data/spec/grammar/tokens/sqlkeywords_spec.rb +45 -0
  52. data/spec/grammar/tokens/string_spec.rb +54 -0
  53. data/spec/grammar/tokens/whitespace_spec.rb +40 -0
  54. data/spec/parser_spec.rb +8 -0
  55. data/spec/profile_spec.rb +5 -0
  56. data/spec/reporter/html_spec.rb +0 -0
  57. data/spec/spec_helper.rb +61 -0
  58. data/spec/spec_suite.rb +5 -0
  59. metadata +121 -0
@@ -0,0 +1,244 @@
1
+ module Piggly
2
+
3
+ #
4
+ # Coverage is tracked by attaching these compiler-generated tags to various nodes in a stored
5
+ # procedure's parse tree. These tags each have a unique string identifier which is printed by
6
+ # various parts of the recompiled stored procedure, and the output is then recognized by
7
+ # Profile.notice_processor, which calls #ping on the tag corresponding to the printed string.
8
+ #
9
+ # After test execution is complete, each AST is walked and Tag values attached to NodeClass
10
+ # values are used to produce the coverage report
11
+ #
12
+ class Tag
13
+ PATTERN = /[0-9a-f]{16}/
14
+
15
+ attr_accessor :id
16
+
17
+ def initialize(prefix = nil, id = nil)
18
+ @id = Digest::MD5.hexdigest(prefix.to_s + (id || object_id).to_s).slice(0, 16)
19
+ end
20
+
21
+ alias to_s id
22
+
23
+ # Defined here in case ActiveSupport hasn't defined it on Object
24
+ def tap
25
+ yield self
26
+ self
27
+ end
28
+ end
29
+
30
+ class EvaluationTag < Tag
31
+ def initialize(*args)
32
+ clear
33
+ super
34
+ end
35
+
36
+ def type
37
+ :block
38
+ end
39
+
40
+ def ping(value)
41
+ @ran = true
42
+ end
43
+
44
+ def style
45
+ "c#{@ran ? '1' : '0'}"
46
+ end
47
+
48
+ def to_f
49
+ @ran ? 100.0 : 0.0
50
+ end
51
+
52
+ def complete?
53
+ @ran
54
+ end
55
+
56
+ def description
57
+ @ran ? 'full coverage' : 'never evaluated'
58
+ end
59
+
60
+ # Resets code coverage
61
+ def clear
62
+ @ran = false
63
+ end
64
+ end
65
+
66
+ #
67
+ # Sequence of statements
68
+ #
69
+ class BlockTag < EvaluationTag
70
+ end
71
+
72
+ #
73
+ # Procedure calls, raise exception, exits, returns
74
+ #
75
+ class UnconditionalBranchTag < EvaluationTag
76
+ # aggregate this coverage data with conditional branches
77
+ def type
78
+ :branch
79
+ end
80
+ end
81
+
82
+ class LoopConditionTag < Tag
83
+ STATES = { # never terminates normally (so @pass must be false)
84
+ 0b0000 => 'condition was never evaluated',
85
+ 0b0001 => 'condition never evaluates false (terminates early). loop always iterates more than once',
86
+ 0b0010 => 'condition never evaluates false (terminates early). loop always iterates only once',
87
+ 0b0011 => 'condition never evaluates false (terminates early)',
88
+ # terminates normally (one of @pass, @once, @twice must be true)
89
+ 0b1001 => 'loop always iterates more than once',
90
+ 0b1010 => 'loop always iterates only once',
91
+ 0b1011 => 'loop never passes through',
92
+ 0b1100 => 'loop always passes through',
93
+ 0b1101 => 'loop never iterates only once',
94
+ 0b1110 => 'loop never iterates more than once',
95
+ 0b1111 => 'full coverage' }
96
+
97
+ attr_reader :pass, :once, :twice, :ends, :count
98
+
99
+ def initialize(*args)
100
+ clear
101
+ super
102
+ end
103
+
104
+ def type
105
+ :loop
106
+ end
107
+
108
+ def ping(value)
109
+ case value
110
+ when 't'
111
+ # loop iterated
112
+ @count += 1
113
+ else
114
+ # loop terminated
115
+ case @count
116
+ when 0; @pass = true
117
+ when 1; @once = true
118
+ else; @twice = true
119
+ end
120
+ @count = 0
121
+
122
+ # this isn't accurate. there needs to be a signal at the end
123
+ # of the loop body to indicate it was reached. otherwise its
124
+ # possible each iteration restarts early with 'continue'
125
+ @ends = true
126
+ end
127
+ end
128
+
129
+ def style
130
+ "l#{[@pass, @once, @twice, @ends].map{|b| b ? 1 : 0}}"
131
+ end
132
+
133
+ def to_f
134
+ # value space:
135
+ # (1,2,X) - loop iterated at least twice and terminated normally
136
+ # (1,X) - loop iterated only once and terminated normally
137
+ # (0,X) - loop never iterated and terminated normally (pass-thru)
138
+ # () - loop condition was never executed
139
+ #
140
+ # these combinations are ignored, because adding tests for them will probably not reveal bugs
141
+ # (1,2) - loop iterated at least twice but terminated early
142
+ # (1) - loop iterated only once but terminated early
143
+ 100 * ([@pass, @once, @twice, @ends].count{|x| x } / 4.0)
144
+ end
145
+
146
+ def complete?
147
+ @pass and @once and @twice and @ends
148
+ end
149
+
150
+ def description
151
+ # weird hack so ForCollectionTag uses its separate constant
152
+ self.class::STATES.fetch(n = state, "unknown tag state: #{n}")
153
+ end
154
+
155
+ # Returns state represented as a 4-bit integer
156
+ def state
157
+ [@ends,@pass,@once,@twice].reverse.inject([0,0]){|(k,n), bit| [k + 1, n | (bit ? 1 : 0) << k] }.last
158
+ end
159
+
160
+ def clear
161
+ @pass = false
162
+ @once = false
163
+ @twice = false
164
+ @ends = false
165
+ @count = 0
166
+ end
167
+ end
168
+
169
+ class ForCollectionTag < LoopConditionTag
170
+ STATES = LoopConditionTag::STATES.merge \
171
+ 0b0001 => 'loop always iterates more than once and always terminates early.',
172
+ 0b0010 => 'loop always iterates only once and always terminates early.',
173
+ 0b0011 => 'loop always terminates early',
174
+ 0b0100 => 'loop always passes through'
175
+
176
+ def ping(value)
177
+ case value
178
+ when 't'
179
+ # start of iteration
180
+ @count += 1
181
+ when '@'
182
+ # end of iteration
183
+ @ends = true
184
+ when 'f'
185
+ # loop exit
186
+ case @count
187
+ when 0; @pass = true
188
+ when 1; @once = true
189
+ else; @twice = true
190
+ end
191
+ @count = 0
192
+ end
193
+ end
194
+ end
195
+
196
+ class BranchConditionTag < Tag
197
+ attr_reader :true, :false
198
+
199
+ def initialize(*args)
200
+ clear
201
+ super
202
+ end
203
+
204
+ def type
205
+ :branch
206
+ end
207
+
208
+ def ping(value)
209
+ case value
210
+ when 't'; @true = true
211
+ when 'f'; @false = true
212
+ end
213
+ end
214
+
215
+ def style
216
+ "b#{@true ? 1 : 0}#{@false ? 1 : 0 }"
217
+ end
218
+
219
+ def to_f
220
+ (@true and @false) ? 100.0 : (@true or @false) ? 50.0 : 0.0
221
+ end
222
+
223
+ def complete?
224
+ @true and @false
225
+ end
226
+
227
+ def description
228
+ if @true and @false
229
+ 'full coverage'
230
+ elsif @true
231
+ 'never evaluates false'
232
+ elsif @false
233
+ 'never evaluates true'
234
+ else
235
+ 'never evaluated'
236
+ end
237
+ end
238
+
239
+ def clear
240
+ @true, @false = false
241
+ end
242
+ end
243
+
244
+ end
@@ -0,0 +1,91 @@
1
+ module Piggly
2
+
3
+ #
4
+ # Walks the parse tree, attaching Tag values and rewriting source code to ping them.
5
+ #
6
+ class TraceCompiler
7
+ include FileCache
8
+ include CompilerCache
9
+
10
+ attr_accessor :nodes
11
+
12
+ def self.compile(tree, args)
13
+ new(args.fetch(:path)).send(:compile, tree)
14
+ end
15
+
16
+ def self.compiler_path
17
+ __FILE__
18
+ end
19
+
20
+ def initialize(path)
21
+ # create unique prefix for each file, prepended to each node's tag
22
+ @prefix = File.expand_path(path)
23
+ @tags = []
24
+ end
25
+
26
+ #
27
+ # Destructively modifies +tree+ (by attaching tags) and returns the tree
28
+ # along with the modified source code, and the list of tags. The tag list
29
+ # is passed along to Profile to compute coverage information. The tree is
30
+ # passed to PrettyCompiler
31
+ #
32
+ def compile(tree)
33
+ puts "Compiling #{@prefix}"
34
+ return 'code.sql' => traverse(tree),
35
+ 'tree' => tree,
36
+ 'tags' => @tags,
37
+ 'prefix' => @prefix
38
+ end
39
+
40
+ def traverse(node)
41
+ if node.terminal? or node.expression?
42
+ node.source_text
43
+ else
44
+ if node.respond_to?(:condStub) and node.respond_to?(:cond)
45
+ # preserve opening parenthesis and whitespace before injecting code. this way
46
+ # IF(test) becomes IF(piggly_cond(TAG, test)) instead of IFpiggly_cond(TAG, (test))
47
+ pre, cond = node.cond.expr.text_value.match(/\A(\(?[\t\n\r ]*)(.+)\z/m).captures
48
+ node.cond.source_text = ""
49
+
50
+ @tags << node.cond.tag(@prefix)
51
+
52
+ node.condStub.source_text = "#{pre}piggly_cond($PIGGLY$#{node.cond.tag_id}$PIGGLY$, #{cond})"
53
+ node.condStub.source_text << traverse(node.cond.tail) # preserve trailing whitespace
54
+ end
55
+
56
+ if node.respond_to?(:bodyStub)
57
+ if node.respond_to?(:exitStub) and node.respond_to?(:cond)
58
+ @tags << node.body.tag(@prefix)
59
+ @tags << node.cond.tag(@prefix)
60
+
61
+ # a hack to simulate a loop conditional statement in ForLoop. signal condition was true
62
+ # when body is executed. when exit stub is reached, signal condition was false
63
+ node.bodyStub.source_text = "perform piggly_cond($PIGGLY$#{node.cond.tag_id}$PIGGLY$, true);#{node.indent(:bodySpace)}"
64
+ node.bodyStub.source_text << "perform piggly_branch($PIGGLY$#{node.body.tag_id}$PIGGLY$);#{node.indent(:bodySpace)}"
65
+
66
+ if node.respond_to?(:doneStub)
67
+ # signal the end of an iteration was reached
68
+ node.doneStub.source_text = "#{node.indent(:bodySpace)}perform piggly_signal($PIGGLY$#{node.cond.tag_id}$PIGGLY$, $PIGGLY$@$PIGGLY$);"
69
+ node.doneStub.source_text << node.body.indent
70
+ end
71
+
72
+ # signal the loop terminated
73
+ node.exitStub.source_text = "\n#{node.indent}perform piggly_cond($PIGGLY$#{node.cond.tag_id}$PIGGLY$, false);"
74
+ elsif node.respond_to?(:body)
75
+ # no condition:
76
+ # BEGIN ... END;
77
+ # LOOP ... END;
78
+ # ... ELSE ... END;
79
+ # CONTINUE label;
80
+ # EXIT label;
81
+ @tags << node.body.tag(@prefix)
82
+ node.bodyStub.source_text = "perform piggly_branch($PIGGLY$#{node.body.tag_id}$PIGGLY$);#{node.indent(:bodySpace)}"
83
+ end
84
+ end
85
+
86
+ # traverse children (in which we just injected code)
87
+ node.elements.map{|e| traverse(e) }.join
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ require File.join(File.dirname(__FILE__), *%w[compiler cache])
2
+ require File.join(File.dirname(__FILE__), *%w[compiler tags])
3
+ require File.join(File.dirname(__FILE__), *%w[compiler trace])
4
+ require File.join(File.dirname(__FILE__), *%w[compiler pretty])
5
+ require File.join(File.dirname(__FILE__), *%w[compiler queue])
@@ -0,0 +1,43 @@
1
+ module Piggly
2
+ class Config
3
+
4
+ def self.config_accessor(hash)
5
+ hash.keys.each do |name|
6
+ self.class.send(:define_method, name) do
7
+ instance_variable_get("@#{name}") || hash[name]
8
+ end
9
+ self.class.send(:define_method, "#{name}=") do |value|
10
+ instance_variable_set("@#{name}", value)
11
+ end
12
+ end
13
+ end
14
+
15
+ config_accessor :cache_root => File.expand_path(File.join(Dir.pwd, 'piggly', 'cache')),
16
+ :report_root => File.expand_path(File.join(Dir.pwd, 'piggly', 'reports')),
17
+ :piggly_root => PIGGLY_ROOT,
18
+ :trace_prefix => 'PIGGLY',
19
+ :aggregate => false
20
+
21
+ def self.path(root, file=nil)
22
+ if file
23
+ file[%r{^\.\.|^\/|^(?:[A-Z]:)?/}i] ?
24
+ file : # ../path, /path, or D:\path that isn't relative to root
25
+ File.join(root, file)
26
+ else
27
+ root
28
+ end
29
+ end
30
+
31
+ def self.mkpath(root, file=nil)
32
+ if file.nil?
33
+ FileUtils.makedirs(root)
34
+ root
35
+ else
36
+ path = path(root, file)
37
+ FileUtils.makedirs(File.dirname(path))
38
+ path
39
+ end
40
+ end
41
+
42
+ end
43
+ end
@@ -0,0 +1,40 @@
1
+ class File
2
+
3
+ # True if target file is older (by mtime) than any source file
4
+ def self.stale?(target, *sources)
5
+ if exists?(target)
6
+ oldest = mtime(target)
7
+ sources.any?{|x| mtime(x) > oldest }
8
+ else
9
+ true
10
+ end
11
+ end
12
+
13
+ end
14
+
15
+ module Piggly
16
+ module FileCache
17
+ def self.included(subclass)
18
+ subclass.extend(ClassMethods)
19
+ end
20
+
21
+ module ClassMethods
22
+
23
+ # Maps source path to cache path, like /home/user/foo.sql => piggly/cache/#{MD5('/home/user')}/#{BaseClass}/foo.sql
24
+ def cache_path(file)
25
+ # up to the last capitalized word of the class name
26
+ subdir = name[/^(?:.+::)?(.+?)([A-Z][^A-Z]+)?$/, 1]
27
+ root = File.join(Config.cache_root, subdir)
28
+
29
+ # md5 the full path to prevent collisions
30
+ full = File.expand_path(file)
31
+ base = File.basename(full)
32
+ hash = Digest::MD5.hexdigest(File.dirname(full))
33
+
34
+ Config.mkpath(File.join(Config.cache_root, hash, subdir), base)
35
+ end
36
+
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,95 @@
1
+ module Piggly
2
+ class Installer
3
+
4
+ # Compiles the procedures in +file+ with instrumentation and installs them
5
+ def self.trace_proc(file)
6
+ # recompile with instrumentation if needed
7
+ cache = Piggly::TraceCompiler.cache(file)
8
+
9
+ # install instrumented code
10
+ connection.exec cache['code.sql']
11
+
12
+ # map tag messages to tag objects
13
+ Profile.add(file, cache['tags'], cache)
14
+ end
15
+
16
+ # Reinstalls the original stored procedures in +file+
17
+ def self.untrace_proc(file)
18
+ connection.exec File.read(file)
19
+ end
20
+
21
+ # Installs necessary instrumentation support
22
+ def self.install_trace
23
+ # record trace messages
24
+ connection.set_notice_processor(&Profile.notice_processor)
25
+
26
+ # install tracing functions
27
+ connection.exec <<-SQL
28
+ -- signals that a conditional expression was executed
29
+ CREATE OR REPLACE FUNCTION piggly_cond(message varchar, value boolean)
30
+ RETURNS boolean AS $$
31
+ BEGIN
32
+ IF value THEN
33
+ RAISE WARNING '#{Config.trace_prefix} % t', message;
34
+ ELSE
35
+ RAISE WARNING '#{Config.trace_prefix} % f', message;
36
+ END IF;
37
+ RETURN value;
38
+ END $$ LANGUAGE 'plpgsql' VOLATILE;
39
+ SQL
40
+
41
+ connection.exec <<-SQL
42
+ -- generic signal
43
+ CREATE OR REPLACE FUNCTION piggly_signal(message varchar, signal varchar)
44
+ RETURNS void AS $$
45
+ BEGIN
46
+ RAISE WARNING '#{Config.trace_prefix} % %', message, signal;
47
+ END $$ LANGUAGE 'plpgsql' VOLATILE;
48
+ SQL
49
+
50
+ connection.exec <<-SQL
51
+ -- signals that a (sub)expression was executed. handles '' and NULL value
52
+ CREATE OR REPLACE FUNCTION piggly_expr(message varchar, value varchar)
53
+ RETURNS varchar AS $$
54
+ BEGIN
55
+ RAISE WARNING '#{Config.trace_prefix} %', message;
56
+ RETURN value;
57
+ END $$ LANGUAGE 'plpgsql' VOLATILE;
58
+ SQL
59
+
60
+ connection.exec <<-SQL
61
+ -- signals that a (sub)expression was executed. handles all other types
62
+ CREATE OR REPLACE FUNCTION piggly_expr(message varchar, value anyelement)
63
+ RETURNS anyelement AS $$
64
+ BEGIN
65
+ RAISE WARNING '#{Config.trace_prefix} %', message;
66
+ RETURN value;
67
+ END $$ LANGUAGE 'plpgsql' VOLATILE;
68
+ SQL
69
+
70
+ connection.exec <<-SQL
71
+ -- signals that a branch was taken
72
+ CREATE OR REPLACE FUNCTION piggly_branch(message varchar)
73
+ RETURNS void AS $$
74
+ BEGIN
75
+ RAISE WARNING '#{Config.trace_prefix} %', message;
76
+ END $$ LANGUAGE 'plpgsql' VOLATILE;
77
+ SQL
78
+ end
79
+
80
+ # Uninstalls instrumentation support
81
+ def self.uninstall_trace
82
+ connection.set_notice_processor
83
+ connection.exec "DROP FUNCTION IF EXISTS piggly_cond(varchar, boolean);"
84
+ connection.exec "DROP FUNCTION IF EXISTS piggly_expr(varchar, varchar);"
85
+ connection.exec "DROP FUNCTION IF EXISTS piggly_expr(varchar, anyelement);"
86
+ connection.exec "DROP FUNCTION IF EXISTS piggly_branch(varchar);"
87
+ end
88
+
89
+ # Returns the active PGConn
90
+ def self.connection
91
+ ActiveRecord::Base.connection.raw_connection
92
+ end
93
+
94
+ end
95
+ end