export_kit 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +242 -0
- data/README.pl.md +113 -0
- data/lib/export_kit/active_record.rb +16 -0
- data/lib/export_kit/artifact.rb +22 -0
- data/lib/export_kit/document.rb +165 -0
- data/lib/export_kit/format_registry.rb +15 -0
- data/lib/export_kit/model_rows.rb +96 -0
- data/lib/export_kit/model_schema.rb +96 -0
- data/lib/export_kit/model_serializer.rb +265 -0
- data/lib/export_kit/output.rb +58 -0
- data/lib/export_kit/render.rb +187 -0
- data/lib/export_kit/renderers/base.rb +94 -0
- data/lib/export_kit/renderers/csv_archive.rb +108 -0
- data/lib/export_kit/renderers/json.rb +69 -0
- data/lib/export_kit/renderers/pdf.rb +127 -0
- data/lib/export_kit/result.rb +68 -0
- data/lib/export_kit/table.rb +41 -0
- data/lib/export_kit/validate_artifact.rb +296 -0
- data/lib/export_kit/version.rb +5 -0
- data/lib/export_kit.rb +27 -0
- metadata +134 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
module Renderers
|
|
5
|
+
class TooLarge < StandardError; end
|
|
6
|
+
|
|
7
|
+
class InvalidDocument < StandardError
|
|
8
|
+
attr_reader :error_details
|
|
9
|
+
|
|
10
|
+
def initialize(message = "invalid export data", error_details: {})
|
|
11
|
+
super(message)
|
|
12
|
+
@error_details = Result.failure(:invalid, error_details:).error_details
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def at(**details)
|
|
16
|
+
self.class.new(error_details: details.merge(error_details))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Shared validation for every renderer: rows must match their declared columns and contain
|
|
21
|
+
# only format-neutral scalar values (or arrays of scalars).
|
|
22
|
+
class Base
|
|
23
|
+
SCALARS = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
|
|
24
|
+
|
|
25
|
+
def self.validate_row!(record_set, row)
|
|
26
|
+
column = invalid_column(record_set, row)
|
|
27
|
+
valid = row.is_a?(Hash) && row.keys == record_set.columns && row.values.all? { plain?(it) }
|
|
28
|
+
return if valid
|
|
29
|
+
|
|
30
|
+
raise InvalidDocument.new(error_details: { record_set: record_set.name, column: })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.invalid_column(record_set, row)
|
|
34
|
+
return unless row.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
record_set.columns.find { !row.key?(it) || !plain?(row[it]) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.plain?(value)
|
|
40
|
+
return value.all? { scalar?(it) } if value.is_a?(Array)
|
|
41
|
+
|
|
42
|
+
scalar?(value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.scalar?(value)
|
|
46
|
+
return value.finite? if value.is_a?(Float)
|
|
47
|
+
return valid_string?(value) if value.is_a?(String)
|
|
48
|
+
|
|
49
|
+
SCALARS.any? { value.is_a?(it) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.valid_string?(value)
|
|
53
|
+
value.encode(Encoding::UTF_8).valid_encoding?
|
|
54
|
+
rescue EncodingError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render(document:, output:, max_bytes:)
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def each_row(record_set)
|
|
65
|
+
index = 0
|
|
66
|
+
record_set.each_row do |row|
|
|
67
|
+
index += 1
|
|
68
|
+
self.class.validate_row!(record_set, row)
|
|
69
|
+
|
|
70
|
+
yield row
|
|
71
|
+
end
|
|
72
|
+
rescue InvalidDocument => e
|
|
73
|
+
raise e.at(record_set: record_set.name, row: index)
|
|
74
|
+
rescue JSON::GeneratorError, EncodingError
|
|
75
|
+
raise InvalidDocument.new(error_details: { record_set: record_set.name, row: index })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def manifest_entry(section, counts)
|
|
79
|
+
{
|
|
80
|
+
"category" => section.key,
|
|
81
|
+
"label" => section.label,
|
|
82
|
+
"description" => section.description,
|
|
83
|
+
**section.metadata,
|
|
84
|
+
"record_sets" => counts,
|
|
85
|
+
"count" => counts.values.sum
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ensure_size!(io, max_bytes)
|
|
90
|
+
raise TooLarge if io.size > max_bytes
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "zip"
|
|
6
|
+
|
|
7
|
+
module ExportKit
|
|
8
|
+
module Renderers
|
|
9
|
+
# A multi-table document cannot be represented faithfully by one rectangular CSV. This
|
|
10
|
+
# renderer writes one UTF-8 CSV per non-empty record set plus a deterministic manifest.csv.
|
|
11
|
+
class CsvArchive < Base
|
|
12
|
+
MANIFEST_COLUMNS = %w(kind section record_set file count metadata).freeze
|
|
13
|
+
FORMULA_PREFIX = /\A[=+\-@]/
|
|
14
|
+
|
|
15
|
+
def self.csv_value(value)
|
|
16
|
+
rendered = value.is_a?(Array) ? JSON.generate(value) : value
|
|
17
|
+
rendered.is_a?(String) && rendered.match?(FORMULA_PREFIX) ? "'#{rendered}" : rendered
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render(document:, output:, max_bytes:)
|
|
21
|
+
manifest = nil
|
|
22
|
+
Zip::OutputStream.write_buffer(output) do |archive|
|
|
23
|
+
bounded = Output.new(io: archive, max_bytes:)
|
|
24
|
+
manifest, files = write_record_sets(document, archive, bounded)
|
|
25
|
+
write_manifest(document, manifest, files, archive, bounded)
|
|
26
|
+
end
|
|
27
|
+
ensure_size!(output, max_bytes)
|
|
28
|
+
manifest
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def write_record_sets(document, archive, bounded)
|
|
34
|
+
state = { files: {} }
|
|
35
|
+
manifest = document.sections.map { write_section(it, archive, bounded, state) }
|
|
36
|
+
[manifest, state.fetch(:files)]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def write_section(section, archive, bounded, state)
|
|
40
|
+
counts = section.record_sets.to_h do |record_set|
|
|
41
|
+
count, filename = write_record_set(section, record_set, archive, bounded)
|
|
42
|
+
|
|
43
|
+
state.fetch(:files)[[section.key, record_set.name]] = filename
|
|
44
|
+
[record_set.name, count]
|
|
45
|
+
end
|
|
46
|
+
manifest_entry(section, counts)
|
|
47
|
+
rescue InvalidDocument => e
|
|
48
|
+
raise e.at(section: section.key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def write_record_set(section, record_set, archive, bounded)
|
|
52
|
+
csv = CSV.new(bounded)
|
|
53
|
+
count = 0
|
|
54
|
+
each_row(record_set) do |row|
|
|
55
|
+
start_record_set(archive, csv, section, record_set) if count.zero?
|
|
56
|
+
csv << record_set.columns.map { self.class.csv_value(row.fetch(it)) }
|
|
57
|
+
count += 1
|
|
58
|
+
end
|
|
59
|
+
[count, record_set_filename(section, record_set, count)]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def start_record_set(archive, csv, section, record_set)
|
|
63
|
+
archive.put_next_entry(record_set_filename(section, record_set, 1))
|
|
64
|
+
csv << record_set.headers.map { self.class.csv_value(it) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def record_set_filename(section, record_set, count)
|
|
68
|
+
"data/#{section.key}/#{record_set.name}.csv" if count.positive?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def write_manifest(document, manifest, files, archive, bounded)
|
|
72
|
+
archive.put_next_entry("manifest.csv")
|
|
73
|
+
csv = CSV.new(bounded)
|
|
74
|
+
csv << MANIFEST_COLUMNS
|
|
75
|
+
write_document_manifest(csv, document)
|
|
76
|
+
document.sections.zip(manifest).each do |section, entry|
|
|
77
|
+
write_section_manifest(csv, section, entry, files)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write_document_manifest(csv, document)
|
|
82
|
+
metadata = {
|
|
83
|
+
"format" => document.identifier,
|
|
84
|
+
"schema_version" => document.schema_version,
|
|
85
|
+
"title" => document.title, **document.metadata
|
|
86
|
+
}
|
|
87
|
+
metadata.each { |key, value| csv << ["document", nil, nil, nil, nil, JSON.generate(key => value)] }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_section_manifest(csv, section, entry, files)
|
|
91
|
+
csv << ["section", section.key, nil, nil, entry.fetch("count"), JSON.generate(section.metadata)]
|
|
92
|
+
section.record_sets.each { write_record_set_manifest(csv, section, it, entry, files) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def write_record_set_manifest(csv, section, record_set, entry, files)
|
|
96
|
+
csv << [
|
|
97
|
+
"record_set",
|
|
98
|
+
section.key,
|
|
99
|
+
record_set.name,
|
|
100
|
+
files[[section.key, record_set.name]],
|
|
101
|
+
entry.fetch("record_sets").fetch(record_set.name),
|
|
102
|
+
JSON.generate("columns" => record_set.columns,
|
|
103
|
+
"headers" => record_set.headers.map { self.class.csv_value(it) })
|
|
104
|
+
]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module ExportKit
|
|
6
|
+
module Renderers
|
|
7
|
+
# Canonical, structured representation of an export document.
|
|
8
|
+
class Json < Base
|
|
9
|
+
def render(document:, output:, max_bytes:)
|
|
10
|
+
data_file = Tempfile.new(%w(export-kit-data .json), binmode: true)
|
|
11
|
+
manifest = write_data(document, Output.new(io: data_file, max_bytes:), max_bytes)
|
|
12
|
+
assemble(document, manifest, data_file, output, max_bytes)
|
|
13
|
+
manifest
|
|
14
|
+
ensure
|
|
15
|
+
data_file&.close!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def write_data(document, data_file, max_bytes)
|
|
21
|
+
data_file.write("{")
|
|
22
|
+
manifest = document.sections.each_with_index.map do |section, section_index|
|
|
23
|
+
data_file.write(",") if section_index.positive?
|
|
24
|
+
data_file.write("#{JSON.generate(section.key)}:")
|
|
25
|
+
counts = write_section(section, data_file, max_bytes)
|
|
26
|
+
manifest_entry(section, counts)
|
|
27
|
+
end
|
|
28
|
+
data_file.write("}")
|
|
29
|
+
manifest
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def write_section(section, data_file, max_bytes)
|
|
33
|
+
data_file.write("{")
|
|
34
|
+
counts = section.record_sets.each_with_index.to_h do |record_set, set_index|
|
|
35
|
+
data_file.write(",") if set_index.positive?
|
|
36
|
+
data_file.write("#{JSON.generate(record_set.name)}:")
|
|
37
|
+
[record_set.name, write_rows(record_set, data_file, max_bytes)]
|
|
38
|
+
end
|
|
39
|
+
data_file.write("}")
|
|
40
|
+
counts
|
|
41
|
+
rescue InvalidDocument => e
|
|
42
|
+
raise e.at(section: section.key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_rows(record_set, data_file, max_bytes)
|
|
46
|
+
data_file.write("[")
|
|
47
|
+
count = 0
|
|
48
|
+
each_row(record_set) do |row|
|
|
49
|
+
data_file.write(",") if count.positive?
|
|
50
|
+
data_file.write(JSON.generate(row))
|
|
51
|
+
count += 1
|
|
52
|
+
ensure_size!(data_file, max_bytes)
|
|
53
|
+
end
|
|
54
|
+
data_file.write("]")
|
|
55
|
+
count
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def assemble(document, manifest, data_file, output, max_bytes)
|
|
59
|
+
header = JSON.generate(document.envelope(manifest))
|
|
60
|
+
output.write(header.delete_suffix("}"), ",\"data\":")
|
|
61
|
+
data_file.rewind
|
|
62
|
+
output.copy_from(data_file)
|
|
63
|
+
output.write("}")
|
|
64
|
+
output.flush
|
|
65
|
+
ensure_size!(output, max_bytes)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prawn"
|
|
4
|
+
|
|
5
|
+
module ExportKit
|
|
6
|
+
module Renderers
|
|
7
|
+
# Human-readable presentation of the same logical document. All source values are plain text;
|
|
8
|
+
# Prawn never interprets them as HTML or inline markup.
|
|
9
|
+
class Pdf < Base
|
|
10
|
+
def initialize(fonts: nil)
|
|
11
|
+
super()
|
|
12
|
+
@fonts = fonts
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render(document:, output:, max_bytes:)
|
|
16
|
+
manifest = render_pdf(document, output)
|
|
17
|
+
ensure_size!(output, max_bytes)
|
|
18
|
+
manifest
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :fonts
|
|
24
|
+
|
|
25
|
+
def render_pdf(document, output)
|
|
26
|
+
pdf = Prawn::Document.new(page_size: "A4", margin: 42)
|
|
27
|
+
configure_font(pdf)
|
|
28
|
+
render_header(pdf, document)
|
|
29
|
+
manifest = document.sections.map { render_page(pdf, it) }
|
|
30
|
+
render_manifest(pdf, manifest)
|
|
31
|
+
render_page_numbers(pdf)
|
|
32
|
+
pdf.render(output)
|
|
33
|
+
manifest
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def render_page(pdf, section)
|
|
37
|
+
pdf.start_new_page unless pdf.page_number == 1 && pdf.cursor > 300
|
|
38
|
+
manifest_entry(section, render_section(pdf, section))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configure_font(pdf)
|
|
42
|
+
normal = fonts && fonts[:normal]
|
|
43
|
+
bold = fonts && fonts[:bold]
|
|
44
|
+
raise InvalidDocument, "Unicode PDF font is unavailable" unless normal && File.file?(normal)
|
|
45
|
+
|
|
46
|
+
pdf.font_families.update("ExportSans" => { normal:, bold: bold || normal })
|
|
47
|
+
pdf.font("ExportSans")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_header(pdf, document)
|
|
51
|
+
pdf.text printable(document.title), size: 20, style: :bold
|
|
52
|
+
pdf.move_down 12
|
|
53
|
+
pdf.text "Format: #{printable(document.identifier)} · schema: #{document.schema_version}", size: 9
|
|
54
|
+
document.metadata.each { |key, value| render_field(pdf, key, value, size: 8) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def render_section(pdf, section)
|
|
58
|
+
render_section_header(pdf, section)
|
|
59
|
+
section.record_sets.to_h do |record_set|
|
|
60
|
+
[record_set.name, render_record_set(pdf, record_set)]
|
|
61
|
+
end
|
|
62
|
+
rescue InvalidDocument => e
|
|
63
|
+
raise e.at(section: section.key)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_section_header(pdf, section)
|
|
67
|
+
pdf.text printable(section.label), size: 16, style: :bold
|
|
68
|
+
pdf.text printable(section.description), size: 9 unless section.description.empty?
|
|
69
|
+
pdf.move_down 10
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_record_set(pdf, record_set)
|
|
73
|
+
pdf.text printable(record_set.name), size: 12, style: :bold
|
|
74
|
+
count = 0
|
|
75
|
+
each_row(record_set) do |row|
|
|
76
|
+
count += 1
|
|
77
|
+
render_record(pdf, record_set, row, count)
|
|
78
|
+
end
|
|
79
|
+
pdf.text "—", size: 9 if count.zero?
|
|
80
|
+
pdf.move_down 12
|
|
81
|
+
count
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_record(pdf, record_set, row, count)
|
|
85
|
+
pdf.start_new_page if pdf.cursor < record_height(pdf, record_set, row, count)
|
|
86
|
+
pdf.move_down 6
|
|
87
|
+
pdf.text "##{count}", size: 9, style: :bold
|
|
88
|
+
row.each { |key, value| render_field(pdf, record_set.column_labels.fetch(key), value) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def record_height(pdf, record_set, row, count)
|
|
92
|
+
heading_height = pdf.height_of("##{count}", size: 9, style: :bold)
|
|
93
|
+
fields_height = row.sum do |key, value|
|
|
94
|
+
pdf.height_of(field_text(record_set.column_labels.fetch(key), value), size: 8, leading: 1)
|
|
95
|
+
end
|
|
96
|
+
6 + heading_height + fields_height
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render_field(pdf, key, value, size: 8)
|
|
100
|
+
pdf.text field_text(key, value), size:, leading: 1
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def field_text(key, value)
|
|
104
|
+
rendered = value.is_a?(Array) ? JSON.generate(value) : value.to_s
|
|
105
|
+
"#{printable(key)}: #{printable(rendered)}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def render_manifest(pdf, manifest)
|
|
109
|
+
pdf.start_new_page
|
|
110
|
+
pdf.text "Manifest", size: 16, style: :bold
|
|
111
|
+
pdf.move_down 8
|
|
112
|
+
manifest.each do |entry|
|
|
113
|
+
pdf.text "#{printable(entry.fetch('label'))}: #{entry.fetch('count')}", size: 9
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def render_page_numbers(pdf)
|
|
118
|
+
pdf.number_pages("<page> / <total>", at: [pdf.bounds.right - 70, -18], width: 70, align: :right, size: 8)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def printable(value)
|
|
122
|
+
value.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
|
|
123
|
+
.gsub(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/, " ")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
class Result
|
|
5
|
+
class ValueError < StandardError; end
|
|
6
|
+
|
|
7
|
+
attr_reader :value, :error, :error_details
|
|
8
|
+
|
|
9
|
+
def self.success(value) = new(value:, error: nil)
|
|
10
|
+
|
|
11
|
+
def self.failure(error, error_details: {})
|
|
12
|
+
raise ArgumentError, "error must be a symbol" unless error.is_a?(Symbol)
|
|
13
|
+
|
|
14
|
+
new(value: nil, error:, error_details:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(value:, error:, error_details: {})
|
|
18
|
+
raise ArgumentError, "error must be a symbol" unless error.nil? || error.is_a?(Symbol)
|
|
19
|
+
|
|
20
|
+
@value = value
|
|
21
|
+
@error = error
|
|
22
|
+
@error_details = safe_details(error_details)
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def success? = error.nil?
|
|
27
|
+
def failure? = !success?
|
|
28
|
+
|
|
29
|
+
def value!
|
|
30
|
+
raise ValueError, error.to_s if failure?
|
|
31
|
+
|
|
32
|
+
value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def map
|
|
36
|
+
success? ? self.class.success(yield(value)) : self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def map_error
|
|
40
|
+
failure? ? self.class.failure(yield(error), error_details:) : self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def and_then
|
|
44
|
+
success? ? yield(value) : self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def safe_details(details)
|
|
50
|
+
details.each_with_object({}) do |(key, detail), safe|
|
|
51
|
+
value = safe_detail(key, detail)
|
|
52
|
+
safe[key] = value unless value.nil?
|
|
53
|
+
end.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def safe_detail(key, detail)
|
|
57
|
+
return safe_row(detail) if key == :row
|
|
58
|
+
return unless %i(section record_set column).include?(key)
|
|
59
|
+
return unless detail.is_a?(String) || detail.is_a?(Symbol)
|
|
60
|
+
|
|
61
|
+
detail.to_s.dup.freeze if detail.to_s.match?(Document::KEY_FORMAT)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def safe_row(detail)
|
|
65
|
+
detail if detail.is_a?(Integer) && detail.positive?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
class Table < Document::RecordSet
|
|
5
|
+
def initialize(columns:, rows:, labels: {})
|
|
6
|
+
source = rows.respond_to?(:call) ? rows : ->(&emit) { rows.each(&emit) }
|
|
7
|
+
super(name: "rows", columns:, column_labels: labels, row_source: source)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def each_row
|
|
11
|
+
index = 0
|
|
12
|
+
super do |row|
|
|
13
|
+
index += 1
|
|
14
|
+
yield normalize(row)
|
|
15
|
+
end
|
|
16
|
+
rescue Renderers::InvalidDocument => e
|
|
17
|
+
raise e.at(record_set: name, row: index)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def normalize(row)
|
|
23
|
+
validate_keys!(row)
|
|
24
|
+
validate_row_columns!(row.keys.map(&:to_s))
|
|
25
|
+
normalized = row.transform_keys(&:to_s)
|
|
26
|
+
columns.to_h { [it, normalized.fetch(it)] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_row_columns!(keys)
|
|
30
|
+
return if keys.uniq == keys && keys.sort == columns.sort
|
|
31
|
+
|
|
32
|
+
raise Renderers::InvalidDocument.new(error_details: { column: (columns - keys).first })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_keys!(row)
|
|
36
|
+
return if row.is_a?(Hash) && row.keys.all? { it.is_a?(String) || it.is_a?(Symbol) }
|
|
37
|
+
|
|
38
|
+
raise Renderers::InvalidDocument
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|