zizia 1.0.1
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/.gitignore +15 -0
- data/.rubocop.yml +65 -0
- data/.rubocop_todo.yml +21 -0
- data/.solr_wrapper +8 -0
- data/.travis.yml +11 -0
- data/Gemfile +12 -0
- data/README.md +77 -0
- data/Rakefile +34 -0
- data/docs/_config.yml +1 -0
- data/docs/index.md +98 -0
- data/lib/zizia/always_invalid_validator.rb +17 -0
- data/lib/zizia/hash_mapper.rb +44 -0
- data/lib/zizia/hyrax_basic_metadata_mapper.rb +149 -0
- data/lib/zizia/hyrax_record_importer.rb +261 -0
- data/lib/zizia/importer.rb +61 -0
- data/lib/zizia/input_record.rb +65 -0
- data/lib/zizia/log_stream.rb +43 -0
- data/lib/zizia/metadata_mapper.rb +83 -0
- data/lib/zizia/metadata_only_stack.rb +70 -0
- data/lib/zizia/parser.rb +132 -0
- data/lib/zizia/parsers/csv_parser.rb +45 -0
- data/lib/zizia/record_importer.rb +57 -0
- data/lib/zizia/spec/fakes/fake_parser.rb +22 -0
- data/lib/zizia/spec/shared_examples/a_mapper.rb +32 -0
- data/lib/zizia/spec/shared_examples/a_message_stream.rb +11 -0
- data/lib/zizia/spec/shared_examples/a_parser.rb +73 -0
- data/lib/zizia/spec/shared_examples/a_validator.rb +46 -0
- data/lib/zizia/spec.rb +15 -0
- data/lib/zizia/streams/formatted_message_stream.rb +70 -0
- data/lib/zizia/validator.rb +117 -0
- data/lib/zizia/validators/csv_format_validator.rb +26 -0
- data/lib/zizia/validators/title_validator.rb +30 -0
- data/lib/zizia/version.rb +5 -0
- data/lib/zizia.rb +73 -0
- data/log/.keep +0 -0
- data/spec/fixtures/bad_example.csv +2 -0
- data/spec/fixtures/example.csv +4 -0
- data/spec/fixtures/hyrax/example.csv +3 -0
- data/spec/fixtures/images/animals/cat.png +0 -0
- data/spec/fixtures/images/zizia.png +0 -0
- data/spec/fixtures/zizia.png +0 -0
- data/spec/integration/import_csv_spec.rb +28 -0
- data/spec/integration/import_hyrax_csv.rb +71 -0
- data/spec/spec_helper.rb +18 -0
- data/spec/stdout_stream_spec.rb +9 -0
- data/spec/support/hyrax/basic_metadata.rb +30 -0
- data/spec/support/hyrax/core_metadata.rb +15 -0
- data/spec/support/shared_contexts/with_work_type.rb +101 -0
- data/spec/zizia/csv_format_validator_spec.rb +38 -0
- data/spec/zizia/csv_parser_spec.rb +73 -0
- data/spec/zizia/formatted_message_stream_spec.rb +35 -0
- data/spec/zizia/hash_mapper_spec.rb +8 -0
- data/spec/zizia/hyrax_basic_metadata_mapper_spec.rb +190 -0
- data/spec/zizia/hyrax_record_importer_spec.rb +178 -0
- data/spec/zizia/importer_spec.rb +46 -0
- data/spec/zizia/input_record_spec.rb +71 -0
- data/spec/zizia/parser_spec.rb +47 -0
- data/spec/zizia/record_importer_spec.rb +70 -0
- data/spec/zizia/title_validator_spec.rb +23 -0
- data/spec/zizia/validator_spec.rb +9 -0
- data/spec/zizia/version_spec.rb +7 -0
- data/spec/zizia_spec.rb +19 -0
- data/zizia.gemspec +34 -0
- metadata +246 -0
data/lib/zizia/parser.rb
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Zizia
|
4
|
+
##
|
5
|
+
# A generic parser.
|
6
|
+
#
|
7
|
+
# `Parser` implementations provide a stream of `InputRecord`s, derived from an
|
8
|
+
# input object (`file`), through `Parser#records`. This method should be
|
9
|
+
# implemented efficiently for repeated access, generating records lazily if
|
10
|
+
# possible, and caching if appropriate.
|
11
|
+
#
|
12
|
+
# Input validation is handled by an array of `#validators`, which are run in
|
13
|
+
# sequence when `#validate` (or `#validate!`) is called. Errors caught in
|
14
|
+
# validation are accessible via `#errors`, and inputs generating errors result
|
15
|
+
# in `#valid? # => false`.
|
16
|
+
#
|
17
|
+
# A factory method `.for` is provided, and each implementation should
|
18
|
+
# provides a `.match?(**)` which returns `true` if the options passed
|
19
|
+
# indicate the parser can handle the given input. Parsers are checked for
|
20
|
+
# `#match?` in the reverse of load order (i.e. the most recently loaded
|
21
|
+
# `Parser` classes are given precedence).
|
22
|
+
#
|
23
|
+
# @example Getting a parser for a file input
|
24
|
+
# file = File.open('path/to/import/manifest.csv')
|
25
|
+
#
|
26
|
+
# Parser.for(file: file).records
|
27
|
+
#
|
28
|
+
# @example Validating a parser
|
29
|
+
# parser = Parser.for(file: invalid_input)
|
30
|
+
#
|
31
|
+
# parser.valid? # => true (always true before validation)
|
32
|
+
# parser.validate # => false
|
33
|
+
# parser.valid? # => false
|
34
|
+
# parser.errors # => an array of Validation::Error-like structs
|
35
|
+
#
|
36
|
+
# parser.validate! # ValidationError
|
37
|
+
#
|
38
|
+
# rubocop:disable Style/ClassVars
|
39
|
+
class Parser
|
40
|
+
DEFAULT_VALIDATORS = [].freeze
|
41
|
+
@@subclasses = [] # @private
|
42
|
+
|
43
|
+
##
|
44
|
+
# @!attribute [rw] file
|
45
|
+
# @return [File]
|
46
|
+
# @!attribute [rw] validators
|
47
|
+
# @return [Array<Validator>]
|
48
|
+
# @!attribute [r] errors
|
49
|
+
# @return [Array]
|
50
|
+
attr_accessor :file, :validators
|
51
|
+
attr_reader :errors
|
52
|
+
|
53
|
+
##
|
54
|
+
# @param file [File]
|
55
|
+
def initialize(file:, **_opts)
|
56
|
+
self.file = file
|
57
|
+
@errors = []
|
58
|
+
@validators ||= self.class::DEFAULT_VALIDATORS
|
59
|
+
|
60
|
+
yield self if block_given?
|
61
|
+
end
|
62
|
+
|
63
|
+
class << self
|
64
|
+
##
|
65
|
+
# @param file [Object]
|
66
|
+
#
|
67
|
+
# @return [Zizia::Parser] a parser instance appropriate for
|
68
|
+
# the arguments
|
69
|
+
#
|
70
|
+
# @raise [NoParserError]
|
71
|
+
def for(file:)
|
72
|
+
klass =
|
73
|
+
@@subclasses.find { |k| k.match?(file: file) } ||
|
74
|
+
raise(NoParserError)
|
75
|
+
|
76
|
+
klass.new(file: file)
|
77
|
+
end
|
78
|
+
|
79
|
+
##
|
80
|
+
# @abstract
|
81
|
+
# @return [Boolean]
|
82
|
+
def match?(**_opts); end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
##
|
87
|
+
# @private Register a new class when inherited
|
88
|
+
def inherited(subclass)
|
89
|
+
@@subclasses.unshift subclass
|
90
|
+
super
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
##
|
95
|
+
# @abstract
|
96
|
+
#
|
97
|
+
# @yield [record] gives each record in the file to the block
|
98
|
+
# @yieldparam record [ImportRecord]
|
99
|
+
#
|
100
|
+
# @return [Enumerable<ImportRecord>]
|
101
|
+
def records
|
102
|
+
raise NotImplementedError
|
103
|
+
end
|
104
|
+
|
105
|
+
##
|
106
|
+
# @return [Boolean] true if the file input is valid
|
107
|
+
def valid?
|
108
|
+
errors.empty?
|
109
|
+
end
|
110
|
+
|
111
|
+
##
|
112
|
+
# @return [Boolean] true if the file input is valid
|
113
|
+
def validate
|
114
|
+
validators.each_with_object(errors) do |validator, errs|
|
115
|
+
errs.concat(validator.validate(parser: self))
|
116
|
+
end
|
117
|
+
|
118
|
+
valid?
|
119
|
+
end
|
120
|
+
|
121
|
+
##
|
122
|
+
# @return [true] always true, unless an error is raised.
|
123
|
+
#
|
124
|
+
# @raise [ValidationError] if the file to parse is invalid
|
125
|
+
def validate!
|
126
|
+
validate || raise(ValidationError)
|
127
|
+
end
|
128
|
+
|
129
|
+
class NoParserError < TypeError; end
|
130
|
+
class ValidationError < RuntimeError; end
|
131
|
+
end # rubocop:enable Style/ClassVars
|
132
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
|
5
|
+
module Zizia
|
6
|
+
##
|
7
|
+
# A parser for CSV files. A single `InputRecord` is returned for each row
|
8
|
+
# parsed from the input.
|
9
|
+
#
|
10
|
+
# Validates the format of the CSV, generating a single error the file is
|
11
|
+
# malformed. This error gives the line number and a message for the first
|
12
|
+
# badly formatted row.
|
13
|
+
#
|
14
|
+
# @see CsvFormatValidator
|
15
|
+
class CsvParser < Parser
|
16
|
+
DEFAULT_VALIDATORS = [CsvFormatValidator.new].freeze
|
17
|
+
EXTENSION = '.csv'
|
18
|
+
|
19
|
+
class << self
|
20
|
+
##
|
21
|
+
# Matches all '.csv' filenames.
|
22
|
+
def match?(file:, **_opts)
|
23
|
+
File.extname(file) == EXTENSION
|
24
|
+
rescue TypeError
|
25
|
+
false
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
##
|
30
|
+
# Gives a record for each line in the .csv
|
31
|
+
#
|
32
|
+
# @see Parser#records
|
33
|
+
def records
|
34
|
+
return enum_for(:records) unless block_given?
|
35
|
+
|
36
|
+
file.rewind
|
37
|
+
|
38
|
+
CSV.parse(file.read, headers: true).each do |row|
|
39
|
+
yield InputRecord.from(metadata: row)
|
40
|
+
end
|
41
|
+
rescue CSV::MalformedCSVError
|
42
|
+
[]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Zizia
|
4
|
+
class RecordImporter
|
5
|
+
##
|
6
|
+
# @!attribute [rw] error_stream
|
7
|
+
# @return [#<<]
|
8
|
+
# @!attribute [rw] info_stream
|
9
|
+
# @return [#<<]
|
10
|
+
# @!attribute [rw] batch_id
|
11
|
+
# @return [String] an optional batch id for this import run
|
12
|
+
# @!attribute [rw] success_count
|
13
|
+
# @return [Integer] a count of the records that were successfully created
|
14
|
+
# @!attribute [rw] failure_count
|
15
|
+
# @return [Integer] a count of the records that failed import
|
16
|
+
attr_accessor :error_stream, :info_stream, :batch_id, :success_count, :failure_count
|
17
|
+
|
18
|
+
##
|
19
|
+
# @param error_stream [#<<]
|
20
|
+
def initialize(error_stream: Zizia.config.default_error_stream,
|
21
|
+
info_stream: Zizia.config.default_info_stream)
|
22
|
+
self.error_stream = error_stream
|
23
|
+
self.info_stream = info_stream
|
24
|
+
end
|
25
|
+
|
26
|
+
##
|
27
|
+
# @param record [ImportRecord]
|
28
|
+
#
|
29
|
+
# @return [void]
|
30
|
+
def import(record:)
|
31
|
+
create_for(record: record)
|
32
|
+
rescue Faraday::ConnectionFailed, Ldp::HttpError => e
|
33
|
+
error_stream << e
|
34
|
+
rescue RuntimeError => e
|
35
|
+
error_stream << e
|
36
|
+
raise e
|
37
|
+
end
|
38
|
+
|
39
|
+
def import_type
|
40
|
+
raise 'No curation_concern found for import' unless
|
41
|
+
defined?(Hyrax) && Hyrax&.config&.curation_concerns&.any?
|
42
|
+
|
43
|
+
Hyrax.config.curation_concerns.first
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def create_for(record:)
|
49
|
+
info_stream << 'Creating record: ' \
|
50
|
+
"#{record.respond_to?(:title) ? record.title : record}."
|
51
|
+
|
52
|
+
created = import_type.create(record.attributes)
|
53
|
+
|
54
|
+
info_stream << "Record created at: #{created.id}"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class FakeParser < Zizia::Parser
|
4
|
+
METADATA = [{ 'title' => '1' }, { 'title' => '2' }, { 'title' => '3' }].freeze
|
5
|
+
|
6
|
+
def initialize(file: METADATA)
|
7
|
+
super
|
8
|
+
end
|
9
|
+
|
10
|
+
def records
|
11
|
+
return enum_for(:records) unless block_given?
|
12
|
+
|
13
|
+
file.each { |hsh| yield Zizia::InputRecord.from(metadata: hsh) }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe FakeParser do
|
18
|
+
it_behaves_like 'a Zizia::Parser' do
|
19
|
+
subject(:parser) { described_class.new }
|
20
|
+
let(:record_count) { 3 }
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
shared_examples 'a Zizia::Mapper' do
|
4
|
+
subject(:mapper) { described_class.new }
|
5
|
+
|
6
|
+
before { mapper.metadata = metadata }
|
7
|
+
|
8
|
+
describe '#metadata' do
|
9
|
+
it 'can be set' do
|
10
|
+
expect { mapper.metadata = nil }
|
11
|
+
.to change { mapper.metadata }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe '#field?' do
|
16
|
+
it 'does not have bogus fields' do
|
17
|
+
expect(mapper.field?(:NOT_A_REAL_FIELD)).to be_falsey
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'has fields that are expected' do
|
21
|
+
if defined?(expected_fields)
|
22
|
+
expected_fields.each do |field|
|
23
|
+
expect(mapper.field?(field)).to be_truthy
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
describe '#fields' do
|
30
|
+
it { expect(mapper.fields).to contain_exactly(*expected_fields) }
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'zizia/always_invalid_validator'
|
4
|
+
|
5
|
+
shared_examples 'a Zizia::Parser' do
|
6
|
+
describe '#file' do
|
7
|
+
it 'is an accessor' do
|
8
|
+
expect { parser.file = :a_new_file }
|
9
|
+
.to change { parser.file }
|
10
|
+
.to(:a_new_file)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
describe '#records' do
|
15
|
+
it 'yields records' do
|
16
|
+
unless described_class == Zizia::Parser
|
17
|
+
expect { |b| parser.records(&b) }
|
18
|
+
.to yield_control.exactly(record_count).times
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '#valid?' do
|
24
|
+
it 'is valid' do
|
25
|
+
expect(parser).to be_valid
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'when not valid' do
|
29
|
+
before do
|
30
|
+
parser.validators = [Zizia::AlwaysInvalidValidator.new]
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'is invalid' do
|
34
|
+
expect { parser.validate }
|
35
|
+
.to change { parser.valid? }
|
36
|
+
.to be_falsey
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe '#validate' do
|
42
|
+
it 'is true when valid' do
|
43
|
+
expect(parser.validate).to be_truthy
|
44
|
+
end
|
45
|
+
|
46
|
+
context 'when not valid' do
|
47
|
+
before do
|
48
|
+
parser.validators = [Zizia::AlwaysInvalidValidator.new]
|
49
|
+
end
|
50
|
+
|
51
|
+
it 'is invalid' do
|
52
|
+
expect(parser.validate).to be_falsey
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe '#validate!' do
|
58
|
+
it 'is true when valid' do
|
59
|
+
expect(parser.validate).to be_truthy
|
60
|
+
end
|
61
|
+
|
62
|
+
context 'when not valid' do
|
63
|
+
before do
|
64
|
+
parser.validators = [Zizia::AlwaysInvalidValidator.new]
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'raises a ValidationError' do
|
68
|
+
expect { parser.validate! }
|
69
|
+
.to raise_error Zizia::Parser::ValidationError
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
shared_examples 'a Zizia::Validator' do
|
4
|
+
subject(:validator) { described_class.new(error_stream: error_stream) }
|
5
|
+
let(:error_stream) { [] }
|
6
|
+
|
7
|
+
define :be_a_validator_error do # |expected|
|
8
|
+
match { false } # { |actual| some_condition }
|
9
|
+
end
|
10
|
+
|
11
|
+
describe '#validate' do
|
12
|
+
context 'without a parser' do
|
13
|
+
it 'raises ArgumentError' do
|
14
|
+
expect { validator.validate }.to raise_error ArgumentError
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'gives an empty error collection for a valid parser' do
|
19
|
+
expect(validator.validate(parser: valid_parser)).not_to be_any if
|
20
|
+
defined?(valid_parser)
|
21
|
+
end
|
22
|
+
|
23
|
+
context 'for an invalid parser' do
|
24
|
+
it 'gives an non-empty error collection' do
|
25
|
+
expect(validator.validate(parser: invalid_parser)).to be_any if
|
26
|
+
defined?(invalid_parser)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'gives usable errors' do
|
30
|
+
pending 'we need to clarify the error type and usage'
|
31
|
+
|
32
|
+
validator.validate(parser: invalid_parser).each do |error|
|
33
|
+
expect(error).to be_a_validator_error
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'writes errors to the error stream' do
|
38
|
+
if defined?(invalid_parser)
|
39
|
+
expect { validator.validate(parser: invalid_parser) }
|
40
|
+
.to change { error_stream }
|
41
|
+
.to include(an_instance_of(Zizia::Validator::Error))
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
data/lib/zizia/spec.rb
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Zizia
|
4
|
+
##
|
5
|
+
# RSpec test support for {Zizia} importers.
|
6
|
+
#
|
7
|
+
# @see https://relishapp.com/rspec/rspec-core/docs/
|
8
|
+
module Spec
|
9
|
+
require 'zizia/spec/shared_examples/a_mapper'
|
10
|
+
require 'zizia/spec/shared_examples/a_message_stream'
|
11
|
+
require 'zizia/spec/shared_examples/a_parser'
|
12
|
+
require 'zizia/spec/shared_examples/a_validator'
|
13
|
+
require 'zizia/spec/fakes/fake_parser'
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Zizia
|
4
|
+
##
|
5
|
+
# A message stream that formats a message before forwarding it to an
|
6
|
+
# underlying {#stream} (STDOUT by default). Messages are formatted using
|
7
|
+
# the `#%` method; the formatter can be a string format specification like
|
8
|
+
# "Message received: %s".
|
9
|
+
#
|
10
|
+
# @example Using a simple formatter
|
11
|
+
# formatter = "Message received: %s\n"
|
12
|
+
# stream = Zizia::FormattedMessageStream.new(formatter: formatter)
|
13
|
+
#
|
14
|
+
# stream << "a message"
|
15
|
+
# # Message received: a message
|
16
|
+
# # => #<IO:<STDOUT>>
|
17
|
+
#
|
18
|
+
# @example A more complex formatter use case
|
19
|
+
# class MyFormatter
|
20
|
+
# def %(arg)
|
21
|
+
# "#{Time.now}: %s\n" % arg
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# formatter = MyFormatter.new
|
26
|
+
# stream = Zizia::FormattedMessageStream.new(formatter: formatter)
|
27
|
+
#
|
28
|
+
# stream << 'a message'
|
29
|
+
# # 2018-02-02 16:10:52 -0800: a message
|
30
|
+
# # => #<IO:<STDOUT>>
|
31
|
+
#
|
32
|
+
# stream << 'another message'
|
33
|
+
# # 2018-02-02 16:10:55 -0800: another message
|
34
|
+
# # => #<IO:<STDOUT>>
|
35
|
+
#
|
36
|
+
class FormattedMessageStream
|
37
|
+
##
|
38
|
+
# @!attribute [rw] formatter
|
39
|
+
# @return [#%] A format specification
|
40
|
+
# @see https://ruby-doc.org/core-2.4.0/String.html#method-i-25
|
41
|
+
# @!attribute [rw] stream
|
42
|
+
# @return [#<<] an underlying stream to forward messages to after
|
43
|
+
# formatting
|
44
|
+
attr_accessor :formatter, :stream
|
45
|
+
|
46
|
+
##
|
47
|
+
# @param formatter [#%] A format specification
|
48
|
+
# @param stream [#<<] an underlying stream to forward messages to after
|
49
|
+
# formatting
|
50
|
+
#
|
51
|
+
# @see https://ruby-doc.org/core-2.4.0/String.html#method-i-25
|
52
|
+
def initialize(stream: STDOUT, formatter: "%s\n")
|
53
|
+
self.formatter = formatter
|
54
|
+
self.stream = stream
|
55
|
+
end
|
56
|
+
|
57
|
+
##
|
58
|
+
def <<(msg)
|
59
|
+
stream << format_message(msg)
|
60
|
+
end
|
61
|
+
|
62
|
+
##
|
63
|
+
# @param msg [#to_s]
|
64
|
+
#
|
65
|
+
# @return [String] the input, cast to a string and formatted using
|
66
|
+
def format_message(msg)
|
67
|
+
formatter % msg
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Zizia
|
4
|
+
##
|
5
|
+
# @abstract A null validator; always returns an empty error collection
|
6
|
+
#
|
7
|
+
# Validators are used to ensure the correctness of input to a parser. Each
|
8
|
+
# validator must respond to `#validate` and return a collection of errors
|
9
|
+
# found during validation. If the input is valid, this collection must be
|
10
|
+
# empty. Otherwise, it contains any number of `Validator::Error` structs
|
11
|
+
# which should be sent to the `#error_stream` by the validator.
|
12
|
+
#
|
13
|
+
# The validation process accepts an entire `Parser` and is free to inspect
|
14
|
+
# the input `#file` content, or view its individual `#records`.
|
15
|
+
#
|
16
|
+
# The base class provides infrastructure for the key behavior, relying on a
|
17
|
+
# private `#run_validation` method to provide the core behavior. In most cases
|
18
|
+
# implementers will want to simply override this method.
|
19
|
+
#
|
20
|
+
# @example validating a parser
|
21
|
+
# validator = MyValidator.new
|
22
|
+
# validator.validate(parser: myParser)
|
23
|
+
#
|
24
|
+
# @example validating an invalid parser
|
25
|
+
# validator = MyValidator.new
|
26
|
+
# validator.validate(parser: invalidParser)
|
27
|
+
# # => Error<#... validator: MyValidator,
|
28
|
+
# name: 'An Error Name',
|
29
|
+
# description: '...'
|
30
|
+
# lineno: 37>
|
31
|
+
#
|
32
|
+
# @example Implementing a custom Validator and using it in a Parser
|
33
|
+
# # Validator checks that the title, when downcased is equal to `moomin`
|
34
|
+
# class TitleIsMoominValidator
|
35
|
+
# def run_validation(parser:)
|
36
|
+
# parser.records.each_with_object([]) do |record, errors|
|
37
|
+
# errors << Error.new(self, :title_is_not_moomin) unless
|
38
|
+
# title_is_moomin?(record)
|
39
|
+
# end
|
40
|
+
# end
|
41
|
+
#
|
42
|
+
# def title_is_moomin?(record)
|
43
|
+
# return false unless record.respond_to?(:title)
|
44
|
+
# return true if record.title.downcase == 'moomin
|
45
|
+
# true
|
46
|
+
# end
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# parser = MyParser.new(some_content)
|
50
|
+
# parser.validations << TitleIsMoominvalidator.new
|
51
|
+
# parser.validate
|
52
|
+
# parser.valid? # => false (unless all the records match the title)
|
53
|
+
#
|
54
|
+
# @see Parser#validate
|
55
|
+
class Validator
|
56
|
+
##
|
57
|
+
# A representation of an error encountered in validation.
|
58
|
+
Error = Struct.new(:validator, :name, :description, :lineno) do
|
59
|
+
##
|
60
|
+
# @!attribute [rw] validator
|
61
|
+
# @return [#to_s] the validator that generated this error
|
62
|
+
# @!attribute [rw] name
|
63
|
+
# @return [#to_s] a short descriptive name for the given error
|
64
|
+
# @!attribute [rw] description
|
65
|
+
# @return [#to_s] a long form description or message
|
66
|
+
# @!attribute [rw] lineno
|
67
|
+
# @return [#to_s] the line number, or other indication of the location
|
68
|
+
# where the error was encountered
|
69
|
+
|
70
|
+
##
|
71
|
+
# @return [Boolean]
|
72
|
+
def validator_error?
|
73
|
+
true
|
74
|
+
end
|
75
|
+
|
76
|
+
##
|
77
|
+
# @return [String]
|
78
|
+
def to_s
|
79
|
+
"#{name}: #{description} (#{validator})"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
##
|
84
|
+
# @!attribute [rw] error_stream
|
85
|
+
# @return [#<<]
|
86
|
+
attr_accessor :error_stream
|
87
|
+
|
88
|
+
##
|
89
|
+
# @param error_stream [#<<]
|
90
|
+
def initialize(error_stream: Zizia.config.default_error_stream)
|
91
|
+
self.error_stream = error_stream
|
92
|
+
end
|
93
|
+
|
94
|
+
##
|
95
|
+
# @param parser [Parser]
|
96
|
+
#
|
97
|
+
# @return [Enumerator<Error>] a collection of errors found in validation
|
98
|
+
def validate(parser:)
|
99
|
+
run_validation(parser: parser).tap do |errors|
|
100
|
+
errors.map { |error| error_stream << error }
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
107
|
+
|
108
|
+
##
|
109
|
+
# @return [Enumerator<Error>]
|
110
|
+
#
|
111
|
+
def run_validation(parser:)
|
112
|
+
[].to_enum
|
113
|
+
end
|
114
|
+
|
115
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Zizia
|
4
|
+
##
|
5
|
+
# A validator for correctly formatted CSV.
|
6
|
+
#
|
7
|
+
# @example
|
8
|
+
# parser = Parser.new(file: File.open('path/to/my.csv'))
|
9
|
+
#
|
10
|
+
# CsvFormatValidator.new.validate(parser: parser)
|
11
|
+
#
|
12
|
+
# @see http://ruby-doc.org/stdlib-2.0.0/libdoc/csv/rdoc/CSV/MalformedCSVError.html
|
13
|
+
class CsvFormatValidator < Validator
|
14
|
+
##
|
15
|
+
# @private
|
16
|
+
#
|
17
|
+
# @see Validator#validate
|
18
|
+
def run_validation(parser:, **)
|
19
|
+
return [] if CSV.parse(parser.file.read)
|
20
|
+
rescue CSV::MalformedCSVError => e
|
21
|
+
[Error.new(self.class, e.class, e.message)]
|
22
|
+
ensure
|
23
|
+
parser.file.rewind
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Zizia
|
4
|
+
class TitleValidator < Validator
|
5
|
+
##
|
6
|
+
# @private
|
7
|
+
#
|
8
|
+
# @see Validator#validate
|
9
|
+
def run_validation(parser:, **)
|
10
|
+
parser.records.each_with_object([]) do |record, errors|
|
11
|
+
titles = record.respond_to?(:title) ? record.title : []
|
12
|
+
|
13
|
+
errors << error_for(record: record) if Array(titles).empty?
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
protected
|
18
|
+
|
19
|
+
##
|
20
|
+
# @private
|
21
|
+
# @param record [InputRecord]
|
22
|
+
#
|
23
|
+
# @return [Error]
|
24
|
+
def error_for(record:)
|
25
|
+
Error.new(self,
|
26
|
+
:missing_title,
|
27
|
+
"Title is required; got #{record.mapper.metadata}")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|