zizia 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|