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.
@@ -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