piggly-nsd 2.3.4 → 2.3.5

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.
@@ -0,0 +1,98 @@
1
+ require "csv"
2
+ require "fileutils"
3
+
4
+ module Piggly
5
+ module Reporter
6
+
7
+ #
8
+ # Generates schema-level coverage summary in CSV format.
9
+ #
10
+ class SchemaCsv < Base
11
+ HEADERS = ["No", "Schema Name", "Objects Count", "Covered Objects", "Line Coverage Percent"].freeze
12
+ NO_SCHEMA = "<no schema>".freeze
13
+
14
+ def initialize(config, profile, output_path = nil)
15
+ @config = config
16
+ @profile = profile
17
+ @output_path = output_path || File.join(@config.report_root, "coverage_by_schema.csv")
18
+ @line_coverage = Compiler::LineCoverage.new(config)
19
+ end
20
+
21
+ # Generate CSV schema coverage report for all procedures
22
+ # @param procedures [Array<Dumper::ReifiedProcedure>] list of procedures
23
+ def report(procedures)
24
+ FileUtils.makedirs(File.dirname(@output_path))
25
+ aggregates = aggregate_by_schema(procedures)
26
+
27
+ CSV.open(@output_path, "wb:UTF-8") do |csv|
28
+ csv << HEADERS
29
+
30
+ aggregates.each_with_index do |row, index|
31
+ csv << [
32
+ index + 1,
33
+ row[:schema_name],
34
+ row[:objects_count],
35
+ row[:covered_objects],
36
+ format("%0.2f", row[:coverage_percent])
37
+ ]
38
+ end
39
+ end
40
+
41
+ @output_path
42
+ end
43
+
44
+ private
45
+
46
+ def aggregate_by_schema(procedures)
47
+ grouped = Hash.new do |hash, key|
48
+ hash[key] = {
49
+ schema_name: key,
50
+ objects_count: 0,
51
+ covered_objects: 0,
52
+ coverage_values: []
53
+ }
54
+ end
55
+
56
+ procedures.each do |procedure|
57
+ schema_name = schema_label(procedure)
58
+ row = grouped[schema_name]
59
+ row[:objects_count] += 1
60
+
61
+ begin
62
+ percent = line_coverage_percent(procedure)
63
+ next if percent.nil?
64
+
65
+ row[:covered_objects] += 1 if percent > 0.0
66
+ row[:coverage_values] << percent
67
+ rescue => e
68
+ $stderr.puts "Warning: Could not calculate schema CSV coverage for #{procedure.name}: #{e.message}"
69
+ end
70
+ end
71
+
72
+ grouped.keys.sort.map do |schema_name|
73
+ row = grouped[schema_name]
74
+ values = row[:coverage_values]
75
+ average = values.empty? ? 0.0 : values.inject(0.0, :+) / values.size
76
+
77
+ {
78
+ schema_name: row[:schema_name],
79
+ objects_count: row[:objects_count],
80
+ covered_objects: row[:covered_objects],
81
+ coverage_percent: average
82
+ }
83
+ end
84
+ end
85
+
86
+ def schema_label(procedure)
87
+ schema = procedure.name.schema.to_s.strip
88
+ schema.empty? ? NO_SCHEMA : schema
89
+ end
90
+
91
+ def line_coverage_percent(procedure)
92
+ coverage = @line_coverage.calculate(procedure, @profile)
93
+ @line_coverage.summary(coverage)[:percent]
94
+ end
95
+ end
96
+
97
+ end
98
+ end
@@ -52,9 +52,14 @@ module Piggly
52
52
  source_path = make_relative_path(absolute_path)
53
53
 
54
54
  io.puts " <file path=\"#{escape_xml(source_path)}\">"
55
-
55
+
56
+ source = procedure.source(@config)
57
+ max_line = Util::LineNumbers.count(source)
58
+
56
59
  # Sort lines and output coverage data
57
60
  coverage.keys.sort.each do |line|
61
+ next if line < 1 || line > max_line
62
+
58
63
  line_data = coverage[line]
59
64
  write_line_coverage(io, line, line_data)
60
65
  end
@@ -5,5 +5,6 @@ module Piggly
5
5
  autoload :Index, "piggly/reporter/index"
6
6
  autoload :Procedure, "piggly/reporter/procedure"
7
7
  autoload :Sonar, "piggly/reporter/sonar"
8
+ autoload :SchemaCsv, "piggly/reporter/schema_csv"
8
9
  end
9
10
  end
data/lib/piggly/task.rb CHANGED
@@ -118,12 +118,14 @@ module Piggly
118
118
  class ReportTask < AbstractTask
119
119
  attr_accessor :report_root, # Where to store reports (default piggly/report)
120
120
  :accumulate, # Accumulate coverage from the previous run (default false)
121
- :trace_file
121
+ :trace_file,
122
+ :schema_csv_path
122
123
 
123
124
  def initialize(name = :report)
124
125
  @accumulate = false
125
126
  @trace_file = nil
126
127
  @report_root = nil
128
+ @schema_csv_path = nil
127
129
  super(name)
128
130
  end
129
131
 
@@ -141,6 +143,7 @@ module Piggly
141
143
  opts.concat(["--trace-file", @trace_file])
142
144
  opts.concat(["--cache-root", @cache_root]) if @cache_root
143
145
  opts.concat(["--report-root", @report_root]) if @report_root
146
+ opts.concat(["--schema-csv-path", @schema_csv_path]) if @schema_csv_path
144
147
 
145
148
  case @procedures
146
149
  when String then opts.concat(["--name", @procedures])
@@ -0,0 +1,25 @@
1
+ module Piggly
2
+ module Util
3
+
4
+ module LineNumbers
5
+ module_function
6
+
7
+ def count(source)
8
+ return 0 if source.nil? || source.empty?
9
+
10
+ source.lines.count
11
+ end
12
+
13
+ # 1-based line number for a byte offset, clamped to [1, count(source)].
14
+ def at_offset(source, offset)
15
+ return 1 if source.nil? || source.empty?
16
+
17
+ offset = [[offset, 0].max, source.length].min
18
+ line = source[0...offset].count("\n") + 1
19
+ max_line = count(source)
20
+ [[line, 1].max, max_line].min
21
+ end
22
+ end
23
+
24
+ end
25
+ end
data/lib/piggly/util.rb CHANGED
@@ -5,5 +5,6 @@ module Piggly
5
5
  autoload :Cacheable, "piggly/util/cacheable"
6
6
  autoload :Enumerable, "piggly/util/enumerable"
7
7
  autoload :File, "piggly/util/file"
8
+ autoload :LineNumbers, "piggly/util/line_numbers"
8
9
  end
9
10
  end
@@ -2,9 +2,9 @@ module Piggly
2
2
  module VERSION
3
3
  MAJOR = 2
4
4
  MINOR = 3
5
- TINY = 4
5
+ TINY = 5
6
6
 
7
- RELEASE_DATE = "2026-02-12"
7
+ RELEASE_DATE = "2026-05-21"
8
8
  end
9
9
 
10
10
  class << VERSION
@@ -23,6 +23,15 @@ module Piggly
23
23
  cond.source_text.should == 'SELECT * FROM table '
24
24
  cond.should be_sql
25
25
  end
26
+
27
+ it "can loop over parenthesized query without whitespace after IN" do
28
+ node = parse(:stmtForLoop, 'FOR x IN(SELECT * FROM table) LOOP a := x; END LOOP;')
29
+ node.should be_statement
30
+
31
+ cond = node.find{|e| e.named?(:cond) }
32
+ cond.source_text.should == '(SELECT * FROM table) '
33
+ cond.should be_expression
34
+ end
26
35
  end
27
36
 
28
37
  describe "while loops" do
@@ -13,6 +13,20 @@ module Piggly
13
13
  rest.should == ''
14
14
  end
15
15
 
16
+ it "parses MERGE statements" do
17
+ node, rest = parse_some(:statement, <<-SQL.strip)
18
+ MERGE INTO users u
19
+ USING (SELECT 1 AS id) s
20
+ ON s.id = u.id
21
+ WHEN NOT MATCHED THEN
22
+ INSERT (id) VALUES (s.id);
23
+ SQL
24
+ node.should be_statement
25
+ node.count{|e| e.sql? }.should == 1
26
+ node.find{|e| e.sql? }.source_text.should =~ /\Amerge into users/mi
27
+ rest.should == ''
28
+ end
29
+
16
30
  it "must end with a semicolon" do
17
31
  expect{ parse(:statement, 'SELECT id FROM users') }.to raise_error(Piggly::Parser::Failure)
18
32
  expect{ parse_some(:statement, 'SELECT id FROM users') }.to raise_error(Piggly::Parser::Failure)
@@ -0,0 +1,72 @@
1
+ require "spec_helper"
2
+ require "csv"
3
+ require "tmpdir"
4
+ require "fileutils"
5
+
6
+ module Piggly
7
+
8
+ describe Reporter::SchemaCsv do
9
+ let(:profile) { double(:profile) }
10
+ let(:line_coverage) { double(:line_coverage) }
11
+ let(:tmpdir) { Dir.mktmpdir("piggly-schema-csv") }
12
+ let(:config) { double(:config, :report_root => tmpdir) }
13
+ let(:output_path) { File.join(tmpdir, "schema-coverage.csv") }
14
+
15
+ def procedure(schema, name)
16
+ qualified_name = Dumper::QualifiedName.new(schema, name)
17
+ double(:procedure, :name => qualified_name)
18
+ end
19
+
20
+ before do
21
+ allow(Compiler::LineCoverage).to receive(:new).with(config).and_return(line_coverage)
22
+ end
23
+
24
+ after do
25
+ FileUtils.remove_entry(tmpdir) if File.exist?(tmpdir)
26
+ end
27
+
28
+ it "writes schema coverage CSV with headers and aggregated rows" do
29
+ first_public = procedure("public", "alpha")
30
+ second_public = procedure("public", "beta")
31
+ app_proc = procedure("app", "gamma")
32
+ no_schema_proc = procedure(nil, "delta")
33
+
34
+ allow(line_coverage).to receive(:calculate).with(first_public, profile).and_return(:cov_public_1)
35
+ allow(line_coverage).to receive(:calculate).with(second_public, profile).and_return(:cov_public_2)
36
+ allow(line_coverage).to receive(:calculate).with(app_proc, profile).and_return(:cov_app)
37
+ allow(line_coverage).to receive(:calculate).with(no_schema_proc, profile).and_return(:cov_no_schema)
38
+
39
+ allow(line_coverage).to receive(:summary).with(:cov_public_1).and_return(:percent => 100.0)
40
+ allow(line_coverage).to receive(:summary).with(:cov_public_2).and_return(:percent => 0.0)
41
+ allow(line_coverage).to receive(:summary).with(:cov_app).and_return(:percent => nil)
42
+ allow(line_coverage).to receive(:summary).with(:cov_no_schema).and_return(:percent => 75.0)
43
+
44
+ reporter = Reporter::SchemaCsv.new(config, profile, output_path)
45
+ result_path = reporter.report([first_public, second_public, app_proc, no_schema_proc])
46
+
47
+ expect(result_path).to eq(output_path)
48
+ expect(File).to exist(output_path)
49
+
50
+ rows = CSV.read(output_path, :headers => true)
51
+
52
+ expect(rows.headers).to eq(["No", "Schema Name", "Objects Count", "Covered Objects", "Line Coverage Percent"])
53
+
54
+ by_schema = rows.each_with_object({}) do |row, hash|
55
+ hash[row["Schema Name"]] = row
56
+ end
57
+
58
+ expect(by_schema.fetch("public")["Objects Count"]).to eq("2")
59
+ expect(by_schema.fetch("public")["Covered Objects"]).to eq("1")
60
+ expect(by_schema.fetch("public")["Line Coverage Percent"]).to eq("50.00")
61
+
62
+ expect(by_schema.fetch("app")["Objects Count"]).to eq("1")
63
+ expect(by_schema.fetch("app")["Covered Objects"]).to eq("0")
64
+ expect(by_schema.fetch("app")["Line Coverage Percent"]).to eq("0.00")
65
+
66
+ expect(by_schema.fetch("<no schema>")["Objects Count"]).to eq("1")
67
+ expect(by_schema.fetch("<no schema>")["Covered Objects"]).to eq("1")
68
+ expect(by_schema.fetch("<no schema>")["Line Coverage Percent"]).to eq("75.00")
69
+ end
70
+ end
71
+
72
+ end
@@ -0,0 +1,59 @@
1
+ require 'spec_helper'
2
+
3
+ module Piggly::Util
4
+
5
+ describe LineNumbers do
6
+ describe ".count" do
7
+ it "returns 0 for empty source" do
8
+ expect(LineNumbers.count("")).to eq(0)
9
+ expect(LineNumbers.count(nil)).to eq(0)
10
+ end
11
+
12
+ it "counts lines without a trailing newline" do
13
+ expect(LineNumbers.count("a\nb\nc")).to eq(3)
14
+ end
15
+
16
+ it "does not over-count when source ends with a newline" do
17
+ expect(LineNumbers.count("a\nb\nc\n")).to eq(3)
18
+ end
19
+
20
+ it "counts CRLF sources consistently" do
21
+ expect(LineNumbers.count("a\r\nb\r\nc\r\n")).to eq(3)
22
+ end
23
+
24
+ it "matches String#lines for a large trailing-newline body" do
25
+ source = (1..366).map { |i| "line#{i}" }.join("\n") + "\n"
26
+ expect(LineNumbers.count(source)).to eq(366)
27
+ expect(LineNumbers.count(source)).to eq(source.lines.count)
28
+ end
29
+ end
30
+
31
+ describe ".at_offset" do
32
+ let(:source) { "a\nb\nc\n" }
33
+
34
+ it "returns 1 for empty source" do
35
+ expect(LineNumbers.at_offset("", 0)).to eq(1)
36
+ end
37
+
38
+ it "maps byte offsets to 1-based line numbers" do
39
+ expect(LineNumbers.at_offset(source, 0)).to eq(1)
40
+ expect(LineNumbers.at_offset(source, 2)).to eq(2)
41
+ expect(LineNumbers.at_offset(source, 4)).to eq(3)
42
+ end
43
+
44
+ it "clamps offsets past EOF to the last line" do
45
+ expect(LineNumbers.at_offset(source, source.length)).to eq(3)
46
+ expect(LineNumbers.at_offset(source, source.length + 10)).to eq(3)
47
+ end
48
+
49
+ it "does not report a line beyond count when offset is on trailing newline" do
50
+ source = (1..366).map { |i| "line#{i}" }.join("\n") + "\n"
51
+ max_line = LineNumbers.count(source)
52
+
53
+ expect(LineNumbers.at_offset(source, source.length - 1)).to eq(max_line)
54
+ expect(LineNumbers.at_offset(source, source.length)).to eq(max_line)
55
+ end
56
+ end
57
+ end
58
+
59
+ end
@@ -0,0 +1,30 @@
1
+ require "spec_helper"
2
+
3
+ module Piggly
4
+ describe "GET CURRENT DIAGNOSTICS" do
5
+ include GrammarHelper
6
+
7
+ it "can parse a GET CURRENT DIAGNOSTICS statement" do
8
+ body = "GET CURRENT DIAGNOSTICS l_wdc_inserted := ROW_COUNT;"
9
+
10
+ node = parse(:statement, body)
11
+ node.should be_statement
12
+ end
13
+
14
+ it "can parse a procedure with GET CURRENT DIAGNOSTICS" do
15
+ body = <<-SQL
16
+ DECLARE
17
+ l_wdc_inserted bigint;
18
+ BEGIN
19
+ INSERT INTO foo DEFAULT VALUES;
20
+ GET CURRENT DIAGNOSTICS l_wdc_inserted := ROW_COUNT;
21
+ RETURN l_wdc_inserted;
22
+ END;
23
+ SQL
24
+
25
+ node = parse(:start, body.strip.downcase)
26
+ node.count { |e| e.assignment? }.should == 0
27
+ node.count { |e| Parser::Nodes::Return === e }.should == 1
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,50 @@
1
+ require "spec_helper"
2
+
3
+ module Piggly
4
+ describe "SQL CASE in PL/pgSQL conditions" do
5
+ include GrammarHelper
6
+
7
+ it "can parse IF with CASE in a function argument" do
8
+ body = <<-SQL
9
+ BEGIN
10
+ IF 0 = public.f_test(
11
+ p_doc_type_mnemo => line_rec.doc_type_mnemo,
12
+ p_person_id => CASE
13
+ WHEN l_f_sub_acc = 1 THEN p_client_id
14
+ WHEN l_f_sub_acc = 0 THEN l_person_id
15
+ END,
16
+ p_repres_id => NULL
17
+ ) THEN
18
+ NULL;
19
+ END IF;
20
+ END;
21
+ SQL
22
+
23
+ node = parse(:start, body.strip.downcase)
24
+ node.count { |e| e.if? }.should == 1
25
+ end
26
+
27
+ it "can parse ELSIF with CASE in a function argument" do
28
+ body = <<-SQL
29
+ BEGIN
30
+ IF false THEN
31
+ NULL;
32
+ ELSIF 0 = public.f_test(
33
+ p_person_id => CASE WHEN l_flag = 1 THEN p_a ELSE p_b END
34
+ ) THEN
35
+ NULL;
36
+ END IF;
37
+ END;
38
+ SQL
39
+
40
+ node = parse(:start, body.strip.downcase)
41
+ node.count { |e| e.if? }.should == 2
42
+ end
43
+
44
+ it "does not break simple IF conditions" do
45
+ node = parse(:statement, "IF cond THEN a := 10; END IF;")
46
+ node.should be_statement
47
+ node.count { |e| e.if? }.should == 1
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,34 @@
1
+ require "spec_helper"
2
+
3
+ module Piggly
4
+ describe "standalone transaction statements" do
5
+ include GrammarHelper
6
+
7
+ it "can parse commit without trailing expression" do
8
+ node = parse(:statement, "commit;")
9
+ node.should be_statement
10
+ node.count { |e| e.sql? }.should == 1
11
+ node.find { |e| e.sql? }.source_text.should == "commit;"
12
+ end
13
+
14
+ it "can parse rollback without trailing expression" do
15
+ node = parse(:statement, "rollback;")
16
+ node.should be_statement
17
+ node.count { |e| e.sql? }.should == 1
18
+ end
19
+
20
+ it "can parse a procedure with multiple commit statements" do
21
+ body = <<-SQL
22
+ BEGIN
23
+ call i_schema.create_choice1(p_session_id => l_session_id);
24
+ commit;
25
+ call i_schema.create_choice2(l_session_id);
26
+ commit;
27
+ END;
28
+ SQL
29
+
30
+ node = parse(:start, body.strip.downcase)
31
+ node.count { |e| e.sql? }.should == 4
32
+ end
33
+ end
34
+ end
data/spec/spec_helper.rb CHANGED
@@ -26,7 +26,7 @@ module Piggly
26
26
 
27
27
  COMMENTS = ["abc defghi", "abc -- abc", "quote's", "a 'str'"]
28
28
 
29
- SQLWORDS = %w[select insert update delete drop alter commit set start]
29
+ SQLWORDS = %w[select insert update delete merge drop alter commit set start]
30
30
 
31
31
  KEYWORDS = %w[as := = alias begin by constant continue
32
32
  cursor debug declare diagnostics else elsif elseif
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: piggly-nsd
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.4
4
+ version: 2.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kvle Putnam
@@ -37,6 +37,20 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: 0.18.4
40
+ - !ruby/object:Gem::Dependency
41
+ name: csv
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '3.3'
40
54
  description: PostgreSQL PL/pgSQL stored procedure code coverage (NSD fork)
41
55
  email: putnam.kvle@gmail.com
42
56
  executables:
@@ -80,6 +94,7 @@ files:
80
94
  - lib/piggly/reporter/resources/highlight.js
81
95
  - lib/piggly/reporter/resources/piggly.css
82
96
  - lib/piggly/reporter/resources/sortable.js
97
+ - lib/piggly/reporter/schema_csv.rb
83
98
  - lib/piggly/reporter/sonar.rb
84
99
  - lib/piggly/tags.rb
85
100
  - lib/piggly/task.rb
@@ -88,6 +103,7 @@ files:
88
103
  - lib/piggly/util/cacheable.rb
89
104
  - lib/piggly/util/enumerable.rb
90
105
  - lib/piggly/util/file.rb
106
+ - lib/piggly/util/line_numbers.rb
91
107
  - lib/piggly/util/process_queue.rb
92
108
  - lib/piggly/util/thunk.rb
93
109
  - lib/piggly/version.rb
@@ -123,12 +139,14 @@ files:
123
139
  - spec/examples/reporter/html/dsl_spec.rb
124
140
  - spec/examples/reporter/html/index_spec.rb
125
141
  - spec/examples/reporter/html_spec.rb
142
+ - spec/examples/reporter/schema_csv_spec.rb
126
143
  - spec/examples/reporter_spec.rb
127
144
  - spec/examples/tags_spec.rb
128
145
  - spec/examples/task_spec.rb
129
146
  - spec/examples/util/cacheable_spec.rb
130
147
  - spec/examples/util/enumerable_spec.rb
131
148
  - spec/examples/util/file_spec.rb
149
+ - spec/examples/util/line_numbers_spec.rb
132
150
  - spec/examples/util/process_queue_spec.rb
133
151
  - spec/examples/util/thunk_spec.rb
134
152
  - spec/examples/version_spec.rb
@@ -138,9 +156,12 @@ files:
138
156
  - spec/issues/028_spec.rb
139
157
  - spec/issues/032_spec.rb
140
158
  - spec/issues/036_spec.rb
159
+ - spec/issues/037_spec.rb
160
+ - spec/issues/case_in_condition_spec.rb
161
+ - spec/issues/commit_spec.rb
141
162
  - spec/spec_helper.rb
142
163
  - spec/spec_suite.rb
143
- homepage: https://github.com/sergeiboikov/piggly
164
+ homepage: https://github.com/NSDDeveloper/piggly
144
165
  licenses:
145
166
  - BSD-2-Clause
146
167
  metadata: {}