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,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module ExportKit
|
|
8
|
+
class ModelRows
|
|
9
|
+
def initialize(record_set:, input:, batch_size:)
|
|
10
|
+
@record_set = record_set
|
|
11
|
+
@input = input
|
|
12
|
+
@batch_size = batch_size
|
|
13
|
+
raise ArgumentError, "batch_size must be positive" unless batch_size.is_a?(Integer) && batch_size.positive?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def each
|
|
17
|
+
index = 0
|
|
18
|
+
batches do |rows|
|
|
19
|
+
rows.each do |row|
|
|
20
|
+
index += 1
|
|
21
|
+
yield present(row.drop(1))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
rescue Renderers::InvalidDocument => e
|
|
25
|
+
raise e.at(record_set: record_set.name, row: index)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :batch_size, :input, :record_set
|
|
31
|
+
|
|
32
|
+
def batches
|
|
33
|
+
relation = record_set.scope.call(input)
|
|
34
|
+
validate_relation!(relation)
|
|
35
|
+
remaining = relation.limit_value
|
|
36
|
+
cursor = nil
|
|
37
|
+
while remaining != 0 && (rows = read_batch(cursor, remaining)).any?
|
|
38
|
+
yield rows
|
|
39
|
+
cursor = rows.last.first
|
|
40
|
+
remaining -= rows.size if remaining
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read_batch(cursor, remaining)
|
|
45
|
+
limit = remaining ? [remaining, batch_size].min : batch_size
|
|
46
|
+
record_set.page(input, after: cursor, limit:).pluck(*selections)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_relation!(relation)
|
|
50
|
+
unless valid_relation?(relation)
|
|
51
|
+
raise ArgumentError, "export requires an unordered, ungrouped relation without offset or distinct"
|
|
52
|
+
end
|
|
53
|
+
return unless record_set.model.primary_key.is_a?(Array) || !record_set.model.primary_key
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "export requires a single primary key"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def valid_relation?(relation)
|
|
59
|
+
relation.is_a?(ActiveRecord::Relation) && relation.klass == record_set.model &&
|
|
60
|
+
!relation.offset_value && relation.group_values.empty? &&
|
|
61
|
+
!relation.distinct_value && relation.order_values.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def selections
|
|
65
|
+
[record_set.model.arel_table[record_set.model.primary_key], *record_set.selections(input)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def present(values)
|
|
69
|
+
row = record_set.output_keys.zip(values).to_h do |key, value|
|
|
70
|
+
[key, scalar_field(key, value)]
|
|
71
|
+
end
|
|
72
|
+
record_set.time_of_day_fields.each do |key|
|
|
73
|
+
value = values[record_set.output_keys.index(key)]
|
|
74
|
+
row[key] = value.strftime("%H:%M:%S") if value
|
|
75
|
+
end
|
|
76
|
+
record_set.present(row, input:)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def scalar_field(key, value)
|
|
80
|
+
scalar(value)
|
|
81
|
+
rescue Renderers::InvalidDocument => e
|
|
82
|
+
raise e.at(column: key)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def scalar(value)
|
|
86
|
+
case value
|
|
87
|
+
when Time, ActiveSupport::TimeWithZone then value.utc.iso8601(6)
|
|
88
|
+
when Date then value.iso8601
|
|
89
|
+
when BigDecimal then value.to_s("F")
|
|
90
|
+
when Array then value.map { scalar(it) }
|
|
91
|
+
when *Renderers::Base::SCALARS then value
|
|
92
|
+
else raise Renderers::InvalidDocument, "unsupported model value"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
# Lets an application model explicitly describe the columns available to any export.
|
|
5
|
+
# Completeness is checked only when the application calls validate_export_schema!.
|
|
6
|
+
module ModelSchema
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
class_attribute :defined_export_schema, instance_writer: false, default: nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class_methods do
|
|
14
|
+
def export_schema(&definition)
|
|
15
|
+
return defined_export_schema unless definition
|
|
16
|
+
|
|
17
|
+
raise ArgumentError, "Export schema already defined for #{name}" if defined_export_schema
|
|
18
|
+
|
|
19
|
+
self.defined_export_schema = DefinitionBuilder.new.tap { it.instance_eval(&definition) }.build
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate_export_schema!
|
|
23
|
+
schema = export_schema
|
|
24
|
+
raise ArgumentError, "export schema is missing" unless schema
|
|
25
|
+
|
|
26
|
+
classified = schema.allowed + schema.denied.keys
|
|
27
|
+
raise ArgumentError, "export schema does not classify every column" unless classified.sort == column_names.sort
|
|
28
|
+
|
|
29
|
+
schema
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Definition = Data.define(:allowed, :denied, :formatters) do
|
|
34
|
+
alias_method :export, :allowed
|
|
35
|
+
alias_method :omit, :denied
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class DefinitionBuilder
|
|
39
|
+
def initialize
|
|
40
|
+
@allowed = []
|
|
41
|
+
@denied = {}
|
|
42
|
+
@formatters = {}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def allow(*declarations)
|
|
46
|
+
each_field_declaration(declarations) do |key, formatter|
|
|
47
|
+
key = key.to_s
|
|
48
|
+
allowed << key
|
|
49
|
+
formatters[key] = formatter if formatter
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def timestamps
|
|
54
|
+
allow :created_at, :updated_at
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def omit(*columns, reason:)
|
|
58
|
+
raise ArgumentError, "omission reason is required" if reason.to_s.strip.empty?
|
|
59
|
+
raise ArgumentError, "omitted fields must be unique" if denied.keys.intersect?(columns.map(&:to_s))
|
|
60
|
+
|
|
61
|
+
columns.each { |column| denied[column.to_s] = reason.to_s }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
alias deny omit
|
|
65
|
+
|
|
66
|
+
def build
|
|
67
|
+
validate!
|
|
68
|
+
|
|
69
|
+
Definition.new(allowed: allowed.freeze, denied: denied.freeze, formatters: formatters.freeze).freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
attr_reader :allowed, :denied, :formatters
|
|
75
|
+
|
|
76
|
+
def each_field_declaration(declarations)
|
|
77
|
+
items = declarations.one? && declarations.first.is_a?(Array) ? declarations.first.dup : declarations.dup
|
|
78
|
+
until items.empty?
|
|
79
|
+
key = items.shift
|
|
80
|
+
|
|
81
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
82
|
+
raise ArgumentError, "An export field must be a String or Symbol"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
yield key, (items.shift if items.first.respond_to?(:call))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate!
|
|
90
|
+
duplicates = allowed & denied.keys
|
|
91
|
+
raise ArgumentError, "Fields cannot be both exported and omitted: #{duplicates.join(', ')}" if duplicates.any?
|
|
92
|
+
raise ArgumentError, "Export fields must be unique" unless allowed.uniq == allowed
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
# Generic DSL for selecting model rows and a subset of fields allowed by `export_schema`.
|
|
5
|
+
# It knows Active Record, but not a concrete export, document shape or output format.
|
|
6
|
+
# Non-model rows deliberately bypass this class and are created by an export presenter.
|
|
7
|
+
class ModelSerializer
|
|
8
|
+
Field = Data.define(:key, :label_key)
|
|
9
|
+
|
|
10
|
+
Category = Data.define(:category, :version, :record_sets, :omitted_tables, :omission_reason) do
|
|
11
|
+
def included? = record_sets.any?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RecordSet = Data.define(
|
|
15
|
+
:name, :model, :scope, :fields, :expressions, :export_fields, :field_presenters, :policy
|
|
16
|
+
) do
|
|
17
|
+
def output_keys = export_fields.map(&:key)
|
|
18
|
+
|
|
19
|
+
def selections(subject)
|
|
20
|
+
table = model.arel_table
|
|
21
|
+
fields.map { table[it] } + expressions.values.map { Arel.sql(it.call(subject)) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def page(subject, after:, limit:)
|
|
25
|
+
id = model.arel_table[model.primary_key]
|
|
26
|
+
relation = scope.call(subject)
|
|
27
|
+
relation = relation.where(id.gt(after)) unless after.nil?
|
|
28
|
+
relation.reorder(id).limit(limit)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each_row(input: nil, batch_size: 500, &block)
|
|
32
|
+
return enum_for(__method__, input:, batch_size:) unless block
|
|
33
|
+
|
|
34
|
+
ModelRows.new(record_set: self, input:, batch_size:).each(&block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def table(input: nil, labels: {}, batch_size: 500)
|
|
38
|
+
Table.new(columns: output_keys, labels:, rows: ->(&emit) { each_row(input:, batch_size:, &emit) })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def time_of_day_fields
|
|
42
|
+
fields.select { model.type_for_attribute(it).type == :time }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def present(row, input:)
|
|
46
|
+
row.to_h do |key, value|
|
|
47
|
+
formatter = field_presenters[key]
|
|
48
|
+
[key, formatter ? present_field(key, formatter, value, input) : value]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def present_field(key, formatter, value, input)
|
|
55
|
+
present_value(formatter, value, input)
|
|
56
|
+
rescue ArgumentError, TypeError, EncodingError
|
|
57
|
+
raise Renderers::InvalidDocument.new(error_details: { record_set: name, column: key })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def present_value(formatter, value, input)
|
|
61
|
+
formatter.arity == 1 ? formatter.call(value) : formatter.call(value, input)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Read-only table index derived from the model schemas used by this serializer.
|
|
66
|
+
class ModelSchemas
|
|
67
|
+
def initialize(serializer:)
|
|
68
|
+
@schemas = serializer.all.flat_map(&:record_sets).each_with_object({}) do |record_set, index|
|
|
69
|
+
table_name = record_set.model.table_name
|
|
70
|
+
existing = index[table_name]
|
|
71
|
+
if existing && existing != record_set.policy
|
|
72
|
+
raise ArgumentError, "Conflicting export schemas for #{table_name}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
index[table_name] = record_set.policy
|
|
76
|
+
end.freeze
|
|
77
|
+
freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def all = schemas
|
|
81
|
+
def each(&) = schemas.each(&)
|
|
82
|
+
def fetch(table_name) = schemas.fetch(table_name.to_s)
|
|
83
|
+
def key?(table_name) = schemas.key?(table_name.to_s)
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
attr_reader :schemas
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def initialize(record_set_builder: RecordSetBuilder)
|
|
91
|
+
@record_set_builder = record_set_builder
|
|
92
|
+
@categories = {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def category(key, version: 1, &definition)
|
|
96
|
+
key = normalize(key)
|
|
97
|
+
raise ArgumentError, "Export category already registered: #{key}" if categories.key?(key)
|
|
98
|
+
|
|
99
|
+
categories[key] = CategoryBuilder.new(key, version:, record_set_builder:, &definition).build
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def exclude(key, reason:)
|
|
103
|
+
key = normalize(key)
|
|
104
|
+
raise ArgumentError, "Export category already registered: #{key}" if categories.key?(key)
|
|
105
|
+
|
|
106
|
+
categories[key] = Category.new(
|
|
107
|
+
category: key, version: 1, record_sets: [].freeze, omitted_tables: {}.freeze,
|
|
108
|
+
omission_reason: reason
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def all = categories.values.freeze
|
|
113
|
+
def each(&) = categories.each_value(&)
|
|
114
|
+
def fetch(key) = categories.fetch(normalize(key))
|
|
115
|
+
|
|
116
|
+
def freeze
|
|
117
|
+
categories.freeze
|
|
118
|
+
super
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
attr_reader :categories, :record_set_builder
|
|
124
|
+
|
|
125
|
+
def normalize(key)
|
|
126
|
+
key.to_s.tap do |value|
|
|
127
|
+
raise ArgumentError, "Invalid export category: #{value.inspect}" unless value.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# DSL for one category and its model-backed record sets.
|
|
132
|
+
class CategoryBuilder
|
|
133
|
+
def initialize(category, version:, record_set_builder:, &definition)
|
|
134
|
+
@record_set_builder = record_set_builder
|
|
135
|
+
@category = category.to_s
|
|
136
|
+
@version = version
|
|
137
|
+
@record_sets = []
|
|
138
|
+
@omitted_tables = {}
|
|
139
|
+
instance_eval(&definition)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def records(name, model:, scope:, fields:, schema: nil, &definition)
|
|
143
|
+
record_sets << record_set_builder.new(name, model:, scope:, fields:, schema:, &definition).build
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def omit_table(table_name, reason:)
|
|
147
|
+
omitted_tables[table_name.to_s] = reason
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build
|
|
151
|
+
Category.new(category:, version:, record_sets: record_sets.freeze,
|
|
152
|
+
omitted_tables: omitted_tables.freeze, omission_reason: nil)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
attr_reader :category, :omitted_tables, :record_sets, :version, :record_set_builder
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# DSL for selecting model fields, SQL expressions and per-export formatter overrides.
|
|
161
|
+
class RecordSetBuilder
|
|
162
|
+
def initialize(name, model:, scope:, fields:, schema:, &definition)
|
|
163
|
+
@name = name.to_s
|
|
164
|
+
@model = model
|
|
165
|
+
@scope = scope
|
|
166
|
+
@configured_fields, inline_formatters = parse_fields(fields)
|
|
167
|
+
@policy = schema || (model.export_schema if model.respond_to?(:export_schema))
|
|
168
|
+
|
|
169
|
+
@expressions = {}
|
|
170
|
+
@label_keys = {}
|
|
171
|
+
@presenters = inline_formatters
|
|
172
|
+
instance_eval(&definition) if definition
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def computed(key, sql:, label: key, presenter: nil)
|
|
176
|
+
key = key.to_s
|
|
177
|
+
raise ArgumentError, "computed SQL must be callable" unless sql.respond_to?(:call)
|
|
178
|
+
raise ArgumentError, "computed fields must be unique" if expressions.key?(key)
|
|
179
|
+
|
|
180
|
+
expressions[key] = sql
|
|
181
|
+
label_keys[key] = label.to_s
|
|
182
|
+
presenters[key] = presenter if presenter
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def format(key, formatter = nil, &block)
|
|
186
|
+
key = key.to_s
|
|
187
|
+
callable = formatter || block
|
|
188
|
+
raise ArgumentError, "#{key} formatter must be callable" unless callable.respond_to?(:call)
|
|
189
|
+
|
|
190
|
+
presenters[key] = callable
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def build
|
|
194
|
+
fields = chosen_fields.freeze
|
|
195
|
+
validate_presenters!(fields)
|
|
196
|
+
RecordSet.new(**record_set_attributes(fields))
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
attr_reader :configured_fields, :expressions, :label_keys, :model, :name, :policy, :presenters, :scope
|
|
202
|
+
|
|
203
|
+
def chosen_fields
|
|
204
|
+
allowed = allowed_fields
|
|
205
|
+
chosen = configured_fields == :all ? allowed : Array(configured_fields).map(&:to_s)
|
|
206
|
+
raise ArgumentError, "#{name} reads a field outside the export schema" unless (chosen - allowed).empty?
|
|
207
|
+
if chosen.uniq != chosen || chosen.intersect?(expressions.keys)
|
|
208
|
+
raise ArgumentError,
|
|
209
|
+
"export fields must be unique"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
chosen
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def allowed_fields
|
|
216
|
+
return policy.allowed if policy
|
|
217
|
+
raise ArgumentError, "fields: :all requires an export schema" if configured_fields == :all
|
|
218
|
+
|
|
219
|
+
model.column_names
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def registered_fields(keys)
|
|
223
|
+
labels = keys.index_with { it }.merge(label_keys)
|
|
224
|
+
keys.map { Field.new(key: it, label_key: labels.fetch(it)) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def field_presenters_for(fields)
|
|
228
|
+
defaults = policy ? policy.formatters.slice(*fields) : {}
|
|
229
|
+
defaults.merge(presenters).freeze
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def record_set_attributes(fields)
|
|
233
|
+
{
|
|
234
|
+
name:, model:, scope:, fields:, expressions: expressions.freeze,
|
|
235
|
+
export_fields: registered_fields(fields + expressions.keys).freeze,
|
|
236
|
+
field_presenters: field_presenters_for(fields), policy:
|
|
237
|
+
}
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_presenters!(fields)
|
|
241
|
+
unknown = presenters.keys - fields - expressions.keys
|
|
242
|
+
raise ArgumentError, "#{name} formats a field it does not export" if unknown.any?
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def parse_fields(declarations)
|
|
246
|
+
return [:all, {}] if declarations == :all
|
|
247
|
+
|
|
248
|
+
items = Array(declarations).dup
|
|
249
|
+
fields = []
|
|
250
|
+
formatters = {}
|
|
251
|
+
until items.empty?
|
|
252
|
+
key = items.shift
|
|
253
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
254
|
+
raise ArgumentError, "An export field must be a String or Symbol"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
key = key.to_s
|
|
258
|
+
fields << key
|
|
259
|
+
formatters[key] = items.shift if items.first.respond_to?(:call)
|
|
260
|
+
end
|
|
261
|
+
[fields, formatters]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
class Output
|
|
5
|
+
def initialize(io:, max_bytes:)
|
|
6
|
+
@io = io
|
|
7
|
+
@max_bytes = max_bytes
|
|
8
|
+
@written = 0
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def write(*strings)
|
|
12
|
+
bytes = strings.sum(&:bytesize)
|
|
13
|
+
position = io.respond_to?(:pos) ? io.pos : written
|
|
14
|
+
raise Renderers::TooLarge if position + bytes > max_bytes
|
|
15
|
+
|
|
16
|
+
@written += bytes
|
|
17
|
+
io.write(*strings)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def <<(string)
|
|
21
|
+
write(string)
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def copy_from(source)
|
|
26
|
+
while (chunk = source.read(16_384))
|
|
27
|
+
write(chunk)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reopen(other)
|
|
32
|
+
raise ArgumentError, "cannot replace export output" unless other.is_a?(Output) && io.equal?(other.send(:io))
|
|
33
|
+
|
|
34
|
+
self
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def seek(*) = io.seek(*)
|
|
38
|
+
def pos = io.pos
|
|
39
|
+
|
|
40
|
+
def pos=(position)
|
|
41
|
+
raise Renderers::TooLarge if position > max_bytes
|
|
42
|
+
|
|
43
|
+
io.pos = position
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def size = io.size
|
|
47
|
+
def flush = io.flush
|
|
48
|
+
def rewind = io.rewind
|
|
49
|
+
def tell = io.tell
|
|
50
|
+
def binmode = self
|
|
51
|
+
def close = flush
|
|
52
|
+
def closed? = io.closed?
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :io, :max_bytes, :written
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ExportKit
|
|
4
|
+
class Render
|
|
5
|
+
FORMAT_UNSUPPORTED = :export_format_unsupported
|
|
6
|
+
TOO_LARGE = :export_too_large
|
|
7
|
+
RENDER_FAILED = :export_render_failed
|
|
8
|
+
|
|
9
|
+
def initialize(data:, format:, max_bytes:, renderer: nil, content_type: nil, extension: nil,
|
|
10
|
+
font: nil, bold_font: nil, **options)
|
|
11
|
+
@data = data
|
|
12
|
+
@format = format.to_s
|
|
13
|
+
@max_bytes = max_bytes
|
|
14
|
+
@renderer = renderer
|
|
15
|
+
@content_type = content_type
|
|
16
|
+
@extension = extension
|
|
17
|
+
@fonts = { normal: font, bold: bold_font }
|
|
18
|
+
@options = options.merge(font:, bold_font:).freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
validate_limit!
|
|
23
|
+
definition = format_definition
|
|
24
|
+
return Result.failure(FORMAT_UNSUPPORTED) unless definition
|
|
25
|
+
return Result.failure(RENDER_FAILED) unless supported_data?
|
|
26
|
+
|
|
27
|
+
render_file(definition)
|
|
28
|
+
rescue Renderers::TooLarge
|
|
29
|
+
Result.failure(TOO_LARGE)
|
|
30
|
+
rescue JSON::GeneratorError, EncodingError, Renderers::InvalidDocument,
|
|
31
|
+
Prawn::Errors::IncompatibleStringEncoding, Prawn::Errors::CannotFit, Zip::Error => e
|
|
32
|
+
Result.failure(RENDER_FAILED, error_details: e.respond_to?(:error_details) ? e.error_details : {})
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :content_type, :data, :extension, :fonts, :format, :max_bytes, :renderer, :options
|
|
38
|
+
|
|
39
|
+
def render_file(definition)
|
|
40
|
+
file = Tempfile.new(["export-kit", ".#{definition.extension}"], binmode: true)
|
|
41
|
+
output = Output.new(io: file, max_bytes:)
|
|
42
|
+
manifest = render_to(output)
|
|
43
|
+
output.flush
|
|
44
|
+
file.rewind
|
|
45
|
+
artifact = build_artifact(file, definition, manifest)
|
|
46
|
+
Result.success(artifact)
|
|
47
|
+
ensure
|
|
48
|
+
file&.close! unless artifact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_limit!
|
|
52
|
+
return if max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
53
|
+
|
|
54
|
+
raise Renderers::InvalidDocument, "max_bytes must be a positive integer"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def format_definition
|
|
58
|
+
return FormatRegistry.find(format) unless renderer
|
|
59
|
+
|
|
60
|
+
registered = FormatRegistry.find(format)
|
|
61
|
+
custom_extension = extension || registered&.extension
|
|
62
|
+
custom_type = content_type || registered&.content_type
|
|
63
|
+
validate_descriptor!(custom_extension, custom_type)
|
|
64
|
+
FormatRegistry::Definition.new(key: format, extension: custom_extension, content_type: custom_type)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_descriptor!(custom_extension, custom_type)
|
|
68
|
+
unless valid_descriptor?(custom_extension, /\A[a-z0-9]{1,12}\z/) &&
|
|
69
|
+
valid_descriptor?(custom_type, %r{\A[\w.+-]+/[\w.+-]+\z})
|
|
70
|
+
raise Renderers::InvalidDocument, "custom renderers require an extension and content type"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def valid_descriptor?(value, pattern)
|
|
75
|
+
value.is_a?(String) && value.match?(pattern)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def supported_data?
|
|
79
|
+
return true if renderer || format == "json"
|
|
80
|
+
return data.is_a?(Table) if format == "csv"
|
|
81
|
+
|
|
82
|
+
data.is_a?(Document)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_to(output)
|
|
86
|
+
if renderer
|
|
87
|
+
renderer.call(input: data, output:, options:)
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
return render_document(output) if data.is_a?(Document)
|
|
91
|
+
return render_table(output) if data.is_a?(Table)
|
|
92
|
+
|
|
93
|
+
write_json(data, output)
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def render_document(output)
|
|
98
|
+
implementation = case format
|
|
99
|
+
when "json" then Renderers::Json.new
|
|
100
|
+
when "csv_zip" then Renderers::CsvArchive.new
|
|
101
|
+
when "pdf" then Renderers::Pdf.new(fonts:)
|
|
102
|
+
end
|
|
103
|
+
implementation.render(document: data, output:, max_bytes:)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def render_table(output)
|
|
107
|
+
format == "json" ? write_json_rows(output) : write_csv_rows(output)
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write_json_rows(output)
|
|
112
|
+
output.write("[")
|
|
113
|
+
separator = ""
|
|
114
|
+
each_table_row do |row|
|
|
115
|
+
output.write(separator)
|
|
116
|
+
write_json(row, output)
|
|
117
|
+
separator = ","
|
|
118
|
+
end
|
|
119
|
+
output.write("]")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def write_csv_rows(output)
|
|
123
|
+
csv = CSV.new(output)
|
|
124
|
+
csv << data.headers.map { Renderers::CsvArchive.csv_value(it) }
|
|
125
|
+
each_table_row do |row|
|
|
126
|
+
csv << data.columns.map { Renderers::CsvArchive.csv_value(row.fetch(it)) }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def each_table_row(&)
|
|
131
|
+
Renderers::Base.new.send(:each_row, data, &)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def write_json(value, output, depth = 0)
|
|
135
|
+
raise Renderers::InvalidDocument, "JSON nesting exceeds limit" if depth > 100
|
|
136
|
+
|
|
137
|
+
case value
|
|
138
|
+
when Hash then write_json_hash(value, output, depth)
|
|
139
|
+
when Array then write_json_array(value, output, depth)
|
|
140
|
+
when *Renderers::Base::SCALARS then output.write(JSON.generate(value))
|
|
141
|
+
else raise Renderers::InvalidDocument, "unsupported JSON value"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def write_json_hash(value, output, depth)
|
|
146
|
+
validate_json_keys!(value.keys)
|
|
147
|
+
output.write("{")
|
|
148
|
+
value.each_with_index do |(key, item), index|
|
|
149
|
+
output.write(",") if index.positive?
|
|
150
|
+
output.write(JSON.generate(key.to_s), ":")
|
|
151
|
+
write_json(item, output, depth + 1)
|
|
152
|
+
end
|
|
153
|
+
output.write("}")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_json_keys!(keys)
|
|
157
|
+
unless keys.all? { it.is_a?(String) || it.is_a?(Symbol) }
|
|
158
|
+
raise Renderers::InvalidDocument, "JSON object keys must be strings or symbols"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
normalized = keys.map(&:to_s)
|
|
162
|
+
raise Renderers::InvalidDocument, "duplicate JSON keys" unless normalized.uniq == normalized
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def write_json_array(value, output, depth)
|
|
166
|
+
output.write("[")
|
|
167
|
+
value.each_with_index do |item, index|
|
|
168
|
+
output.write(",") if index.positive?
|
|
169
|
+
write_json(item, output, depth + 1)
|
|
170
|
+
end
|
|
171
|
+
output.write("]")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def build_artifact(file, definition, manifest)
|
|
175
|
+
document = data if data.is_a?(Document) || data.is_a?(Table)
|
|
176
|
+
Artifact.new(file:, byte_size: file.size, sha256: Digest::SHA256.file(file.path).hexdigest,
|
|
177
|
+
manifest:, format:, content_type: definition.content_type, extension: definition.extension,
|
|
178
|
+
**document_metadata, document:)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def document_metadata
|
|
182
|
+
return { identifier: nil, schema_version: nil, metadata: {} } unless data.is_a?(Document)
|
|
183
|
+
|
|
184
|
+
{ identifier: data.identifier, schema_version: data.schema_version, metadata: data.metadata }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|