Skip to content

Commit 637652e

Browse files
authored
Refactor validation service (#1154)
* Implement End to End CSV Validation Service Spec * Begin to integrate validations into parser * Refactor step 2... set up interface * Step 3: use real parser for validation * Validate empty file * Validate headers based on parser * Implement additional validators * Remove obsolete validation csv parser * Add a csv template end to end spec * Add CsvTemplate:: namespace Add CsvTemplate:: namespace as re-namespaced copy of template-generation components Copies all template-generation service objects from CsvValidationService::* into a new CsvTemplate:: module under app/services/bulkrax/csv_template/. No behaviour changes — the originals remain in place. This is the first step in moving template generation into a CsvParser concern (upstream integration of refactor/validation-lambdas). * Add CsvParser::CsvTemplateGeneration concern Introduces app/parsers/concerns/bulkrax/csv_parser/csv_template_generation.rb with a TemplateContext that drives the CsvTemplate:: components. Includes the concern in CsvParser and updates CsvValidationService.generate_template to delegate to CsvParser.generate_template — the service is now a thin shim for template generation. * Add CsvParser::CsvValidation and CsvRow:: Introduces app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb with CsvParser.validate_csv as a class method. Uses CsvEntry.read_data (not CSV.read) to preserve blank-row filtering and header normalisation identical to a real import. Adds four callable validator modules under app/validators/bulkrax/csv_row/: DuplicateIdentifier, ParentReference, RequiredValues, and ControlledVocabulary. Not yet wired in — no behaviour change. * Wire CsvValidation concern and csv_row_validators Includes CsvParser::CsvValidation in CsvParser, making CsvParser.validate_csv available. Adds Bulkrax.csv_row_validators (defaulting to the four CsvRow:: modules) and Bulkrax.register_csv_row_validator to lib/bulkrax.rb as the new extensibility point for per-row validation. CsvValidationService.validate still drives its own path — delegation happens in Step 7. * Fix field_metadata stub and dead param Updates the rights_statement spec stubs in csv_validation_service_spec and csv_validation_service_end_to_end_spec to target CsvTemplate::FieldAnalyzer directly, since validation now runs through CsvParser.validate_csv. Removes the dead additional_validators: parameter from CsvValidationService.validate and initialize — use Bulkrax.register_csv_row_validator instead. * Delete old CsvValidationService:: template classes Switches CsvValidationService instance to use CsvTemplate:: classes (MappingManager, FieldAnalyzer, ModelLoader, CsvBuilder, FileValidator, FilePathGenerator, ColumnBuilder). Deletes the 12 duplicate files from the old csv_validation_service/ namespace and migrates their specs to spec/services/bulkrax/csv_template/. Updates all remaining spec stubs and doubles that referenced the old namespace. * Remove CsvValidationService, delegate to CsvParser Deletes CsvValidationService and all subclasses — validation was never in production. Controllers now call CsvParser.validate_csv and CsvParser.generate_template directly. Removes row_validator_service config from lib/bulkrax.rb. Deletes all associated specs; the CsvParser concerns and CsvTemplate:: specs are the new home. * Update documentation to reflect changes * Add missing specs * Fix behavior in model_loader
1 parent 5384d15 commit 637652e

76 files changed

Lines changed: 2187 additions & 4400 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/controllers/bulkrax/guided_imports_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def render_invalid_uploaded_files_response
8585
# @param admin_set_id [String, nil] optional admin set ID for validation context
8686
# @return [Hash] validation result data
8787
def run_validation(csv_file, zip_file, admin_set_id: nil)
88-
CsvValidationService.validate(csv_file: csv_file, zip_file: zip_file, admin_set_id: admin_set_id)
88+
CsvParser.validate_csv(csv_file: csv_file, zip_file: zip_file, admin_set_id: admin_set_id)
8989
end
9090

9191
def importer_params

app/controllers/bulkrax/importers_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def new
7070
# GET /importers/sample_csv_file
7171
def sample_csv_file
7272
admin_set_id = params[:admin_set_id].presence
73-
sample = Bulkrax::CsvValidationService.generate_template(models: 'all', output: 'file', admin_set_id: admin_set_id)
73+
sample = Bulkrax::CsvParser.generate_template(models: 'all', output: 'file', admin_set_id: admin_set_id)
7474
send_file sample, filename: File.basename(sample), type: 'text/csv', disposition: 'attachment'
7575
rescue StandardError => e
7676
flash[:error] = "Unable to generate sample CSV file: #{e.message}"

app/parsers/bulkrax/csv_parser.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ module Bulkrax
44
class CsvParser < ApplicationParser # rubocop:disable Metrics/ClassLength
55
include ErroredEntries
66
include ExportBehavior
7+
include CsvParser::CsvTemplateGeneration
8+
include CsvParser::CsvValidation
79
attr_writer :collections, :file_sets, :works
10+
attr_accessor :validation_mode
811

912
def self.export_supported?
1013
true
@@ -15,8 +18,10 @@ def records(_opts = {})
1518

1619
file_for_import = only_updates ? parser_fields['partial_import_file_path'] : import_file_path
1720
csv_data = entry_class.read_data(file_for_import)
18-
importer.parser_fields['total'] = csv_data.count
19-
importer.save
21+
unless validation_mode
22+
importer.parser_fields['total'] = csv_data.count
23+
importer.save
24+
end
2025

2126
@records = csv_data.map { |record_data| entry_class.data_for_entry(record_data, nil, self) }
2227
@records
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# frozen_string_literal: true
2+
3+
module Bulkrax
4+
class CsvParser < ApplicationParser
5+
module CsvTemplateGeneration
6+
extend ActiveSupport::Concern
7+
8+
class_methods do
9+
# Generate a CSV template for the specified models.
10+
#
11+
# @param models [Array<String>, String] Model names or 'all' for all available models
12+
# @param output [String] Output format: 'file' or 'csv_string'
13+
# @param admin_set_id [String, nil] Optional admin set ID for context
14+
# @param args [Hash] Additional arguments passed to output method (e.g., file_path)
15+
# @return [String] File path (for 'file' output) or CSV string (for 'csv_string' output)
16+
def generate_template(models: [], output: 'file', admin_set_id: nil, **args)
17+
raise NameError, "Hyrax is not defined" unless defined?(::Hyrax)
18+
TemplateContext.new(models: models, admin_set_id: admin_set_id).send("to_#{output}", **args)
19+
end
20+
end
21+
22+
##
23+
# Holds state for a single template generation run.
24+
# Provides the interface expected by CsvTemplate:: components.
25+
class TemplateContext
26+
attr_reader :mappings, :all_models, :admin_set_id, :field_analyzer, :mapping_manager
27+
28+
def initialize(models: nil, admin_set_id: nil)
29+
@admin_set_id = admin_set_id
30+
@mapping_manager = CsvTemplate::MappingManager.new
31+
@mappings = @mapping_manager.mappings
32+
@field_analyzer = CsvTemplate::FieldAnalyzer.new(@mappings, admin_set_id)
33+
@all_models = CsvTemplate::ModelLoader.new(Array.wrap(models)).models
34+
@csv_builder = CsvTemplate::CsvBuilder.new(self)
35+
end
36+
37+
def to_file(file_path: nil)
38+
file_path ||= CsvTemplate::FilePathGenerator.default_path(@admin_set_id)
39+
@csv_builder.write_to_file(file_path)
40+
file_path
41+
end
42+
43+
def to_csv_string
44+
@csv_builder.generate_string
45+
end
46+
47+
def field_metadata_for_all_models
48+
@field_metadata ||= @all_models.each_with_object({}) do |model, hash|
49+
field_list = @field_analyzer.find_or_create_field_list_for(model_name: model)
50+
hash[model] = {
51+
properties: field_list.dig(model, "properties") || [],
52+
required_terms: field_list.dig(model, "required_terms") || [],
53+
controlled_vocab_terms: field_list.dig(model, "controlled_vocab_terms") || []
54+
}
55+
end
56+
end
57+
58+
def valid_headers_for_models
59+
@valid_headers ||= begin
60+
column_builder = CsvTemplate::ColumnBuilder.new(self)
61+
all_columns = column_builder.all_columns
62+
all_columns - CsvTemplate::CsvBuilder::IGNORED_PROPERTIES
63+
rescue StandardError => e
64+
Rails.logger.error("Error building valid headers: #{e.message}")
65+
standard_fields = %w[model source_identifier parent parents file]
66+
model_fields = field_metadata_for_all_models.values.flat_map { |m| m[:properties] }
67+
(standard_fields + model_fields).uniq
68+
end
69+
end
70+
end
71+
end
72+
end
73+
end
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
# frozen_string_literal: true
2+
3+
module Bulkrax
4+
class CsvParser < ApplicationParser
5+
module CsvValidation # rubocop:disable Metrics/ModuleLength
6+
extend ActiveSupport::Concern
7+
8+
included do
9+
# Lightweight struct used to satisfy the CsvTemplate::ColumnBuilder
10+
# interface without constructing a full template context.
11+
ValidationContext = Struct.new(:mapping_manager, :field_analyzer, :all_models, :mappings, keyword_init: true)
12+
end
13+
14+
class_methods do
15+
# Validate a CSV (and optional zip) without a persisted Importer record.
16+
#
17+
# @param csv_file [File, ActionDispatch::Http::UploadedFile, String] path or file object
18+
# @param zip_file [File, ActionDispatch::Http::UploadedFile, nil]
19+
# @param admin_set_id [String, nil]
20+
# @return [Hash] validation result compatible with the guided import UI
21+
def validate_csv(csv_file:, zip_file: nil, admin_set_id: nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
22+
file_path = csv_file.respond_to?(:path) ? csv_file.path : csv_file.to_s
23+
24+
# 1. Read headers — use CsvEntry.read_data so header normalisation
25+
# (special-char stripping, symbolisation) is identical to a real import.
26+
raw_csv = CsvEntry.read_data(file_path)
27+
headers = raw_csv.headers.map(&:to_s)
28+
29+
# 2. Field mappings / column name resolution
30+
mapping_manager = CsvTemplate::MappingManager.new
31+
mappings = mapping_manager.mappings
32+
33+
source_id_key = resolve_validation_key(mapping_manager, flag: 'source_identifier', default: :source_identifier)
34+
parent_key = resolve_validation_key(mapping_manager, flag: 'related_parents_field_mapping', default: :parents)
35+
children_key = resolve_validation_key(mapping_manager, flag: 'related_children_field_mapping', default: :children)
36+
file_key = resolve_validation_key(mapping_manager, key: 'file', default: :file)
37+
38+
# 3. Parse rows — CsvEntry.read_data already filters blank rows and
39+
# returns symbol-keyed rows (same as a real import).
40+
csv_data = parse_validation_rows(raw_csv, source_id_key, parent_key, children_key, file_key)
41+
42+
# 4. Field metadata
43+
all_models = csv_data.map { |r| r[:model] }.compact.uniq
44+
field_analyzer = CsvTemplate::FieldAnalyzer.new(mappings, admin_set_id)
45+
field_metadata = build_validation_field_metadata(all_models, field_analyzer)
46+
47+
# 5. Valid-header set (drives unrecognised-header detection)
48+
valid_headers = build_valid_validation_headers(mapping_manager, field_analyzer, all_models, mappings, field_metadata)
49+
50+
# 6. Suffixed variants seen in this specific CSV (e.g. title_1, creator_2)
51+
suffixed_headers = headers.select { |h| h.match?(/_\d+\z/) }
52+
valid_headers = (valid_headers + suffixed_headers).uniq
53+
54+
# 7. Header-level checks
55+
missing_required = find_missing_required_headers(headers, field_metadata, mapping_manager)
56+
unrecognized = find_unrecognized_validation_headers(headers, valid_headers)
57+
58+
# 8. Row-level validators
59+
parent_split = resolve_parent_split_pattern(mappings)
60+
all_ids = csv_data.map { |r| r[:source_identifier] }.compact.to_set
61+
validator_context = {
62+
errors: [],
63+
warnings: [],
64+
seen_ids: {},
65+
all_ids: all_ids,
66+
source_identifier: source_id_key.to_s,
67+
parent_split_pattern: parent_split,
68+
mappings: mappings,
69+
field_metadata: field_metadata
70+
}
71+
72+
csv_data.each_with_index do |record, index|
73+
row_number = index + 2 # 1-indexed, plus header row
74+
Bulkrax.csv_row_validators.each { |v| v.call(record, row_number, validator_context) }
75+
end
76+
77+
# 9. File validation
78+
file_validator = CsvTemplate::FileValidator.new(csv_data, zip_file, admin_set_id)
79+
80+
# 10. Item hierarchy for UI display
81+
collections, works, file_sets = extract_validation_items(csv_data)
82+
83+
# 11. Assemble result
84+
row_errors = validator_context[:errors]
85+
has_errors = missing_required.any? || headers.blank? || csv_data.empty? ||
86+
file_validator.missing_files.any? || row_errors.any?
87+
has_warnings = unrecognized.any? || file_validator.possible_missing_files?
88+
89+
result = {
90+
headers: headers,
91+
missingRequired: missing_required,
92+
unrecognized: unrecognized,
93+
rowCount: csv_data.length,
94+
isValid: !has_errors,
95+
hasWarnings: has_warnings,
96+
rowErrors: row_errors,
97+
collections: collections,
98+
works: works,
99+
fileSets: file_sets,
100+
totalItems: csv_data.length,
101+
fileReferences: file_validator.count_references,
102+
missingFiles: file_validator.missing_files,
103+
foundFiles: file_validator.found_files_count,
104+
zipIncluded: file_validator.zip_included?
105+
}
106+
107+
apply_rights_statement_validation_override!(result, missing_required)
108+
result
109+
end
110+
111+
private
112+
113+
# Resolve a symbol key from mappings for use as a record hash key.
114+
# Returns a Symbol matching the parser's symbol-keyed record hash.
115+
def resolve_validation_key(mapping_manager, key: nil, flag: nil, default:)
116+
options = mapping_manager.resolve_column_name(key: key, flag: flag, default: default.to_s)
117+
options.first&.to_sym || default
118+
end
119+
120+
# Parse rows from a CsvEntry.read_data result into the canonical record shape.
121+
# CsvEntry.read_data returns CSV::Row objects with symbol headers; blank rows
122+
# are already filtered by CsvWrapper.
123+
def parse_validation_rows(raw_csv, source_id_key, parent_key, children_key, file_key)
124+
raw_csv.map do |row|
125+
# CSV::Row#to_h converts symbol headers → string-keyed hash
126+
row_hash = row.to_h.transform_keys(&:to_s)
127+
{
128+
source_identifier: row[source_id_key],
129+
model: row[:model],
130+
parent: row[parent_key],
131+
children: row[children_key],
132+
file: row[file_key],
133+
raw_row: row_hash
134+
}
135+
end
136+
rescue StandardError => e
137+
Rails.logger.error("CsvParser.validate_csv: error parsing rows – #{e.message}")
138+
[]
139+
end
140+
141+
def build_validation_field_metadata(all_models, field_analyzer)
142+
all_models.each_with_object({}) do |model, hash|
143+
field_list = field_analyzer.find_or_create_field_list_for(model_name: model)
144+
hash[model] = {
145+
properties: field_list.dig(model, 'properties') || [],
146+
required_terms: field_list.dig(model, 'required_terms') || [],
147+
controlled_vocab_terms: field_list.dig(model, 'controlled_vocab_terms') || []
148+
}
149+
end
150+
end
151+
152+
def build_valid_validation_headers(mapping_manager, field_analyzer, all_models, mappings, field_metadata)
153+
svc = ValidationContext.new(
154+
mapping_manager: mapping_manager,
155+
field_analyzer: field_analyzer,
156+
all_models: all_models,
157+
mappings: mappings
158+
)
159+
all_cols = CsvTemplate::ColumnBuilder.new(svc).all_columns
160+
all_cols - CsvTemplate::CsvBuilder::IGNORED_PROPERTIES
161+
rescue StandardError => e
162+
Rails.logger.error("CsvParser.validate_csv: error building valid headers – #{e.message}")
163+
standard = %w[model source_identifier parent parents file]
164+
model_fields = field_metadata.values.flat_map { |m| m[:properties] }
165+
(standard + model_fields).uniq
166+
end
167+
168+
def find_missing_required_headers(headers, field_metadata, mapping_manager)
169+
csv_keys = headers.map { |h| mapping_manager.mapped_to_key(h).sub(/_\d+\z/, '') }.uniq
170+
missing = []
171+
field_metadata.each do |model, meta|
172+
(meta[:required_terms] || []).each do |field|
173+
missing << { model: model, field: field } unless csv_keys.include?(field)
174+
end
175+
end
176+
missing.uniq
177+
end
178+
179+
def find_unrecognized_validation_headers(headers, valid_headers)
180+
checker = DidYouMean::SpellChecker.new(dictionary: valid_headers)
181+
headers
182+
.reject { |h| valid_headers.include?(h) || valid_headers.include?(h.sub(/_\d+\z/, '')) }
183+
.index_with { |h| checker.correct(h).first }
184+
end
185+
186+
def resolve_parent_split_pattern(mappings)
187+
split_val = mappings.dig('parents', 'split') || mappings.dig(:parents, :split)
188+
return nil if split_val.blank?
189+
return Bulkrax::DEFAULT_MULTI_VALUE_ELEMENT_SPLIT_ON if split_val == true
190+
191+
split_val
192+
end
193+
194+
def extract_validation_items(csv_data) # rubocop:disable Metrics/MethodLength
195+
child_to_parents = build_child_to_parents_map(csv_data)
196+
collections = []
197+
works = []
198+
file_sets = []
199+
200+
csv_data.each do |item|
201+
categorise_validation_item(item, child_to_parents, collections, works, file_sets)
202+
end
203+
204+
[collections, works, file_sets]
205+
end
206+
207+
def build_child_to_parents_map(csv_data)
208+
Hash.new { |h, k| h[k] = [] }.tap do |map|
209+
csv_data.each do |item|
210+
parse_relationship_field(item[:children]).each do |child_id|
211+
map[child_id] << item[:source_identifier]
212+
end
213+
end
214+
end
215+
end
216+
217+
def categorise_validation_item(item, child_to_parents, collections, works, file_sets)
218+
item_id = item[:source_identifier]
219+
title = item[:raw_row]['title'] || item_id
220+
model_str = item[:model].to_s
221+
222+
if model_str.casecmp('collection').zero? || model_str.casecmp('collectionresource').zero?
223+
explicit = parse_relationship_field(item[:parent])
224+
inferred = child_to_parents[item_id] || []
225+
collections << { id: item_id, title: title, type: 'collection',
226+
parentIds: (explicit + inferred).uniq,
227+
childIds: parse_relationship_field(item[:children]) }
228+
elsif model_str.casecmp('fileset').zero? || model_str.casecmp('hyrax::fileset').zero?
229+
file_sets << { id: item_id, title: title, type: 'file_set' }
230+
else
231+
explicit = parse_relationship_field(item[:parent])
232+
inferred = child_to_parents[item_id] || []
233+
works << { id: item_id, title: title, type: 'work',
234+
parentIds: (explicit + inferred).uniq,
235+
childIds: parse_relationship_field(item[:children]) }
236+
end
237+
end
238+
239+
def parse_relationship_field(value)
240+
return [] if value.blank?
241+
value.to_s.split('|').map(&:strip).reject(&:blank?)
242+
end
243+
244+
def apply_rights_statement_validation_override!(result, missing_required)
245+
only_rights = missing_required.present? &&
246+
missing_required.all? { |h| h[:field].to_s == 'rights_statement' }
247+
return unless only_rights && !result[:isValid]
248+
return if result[:headers].blank?
249+
return if result[:missingFiles]&.any?
250+
251+
result[:isValid] = true
252+
result[:hasWarnings] = true
253+
end
254+
end
255+
end
256+
end
257+
end

0 commit comments

Comments
 (0)