Skip to content

Commit 57eccec

Browse files
authored
Add nested_attributes flag for object: mappings (#1193)
* Add nested_attributes flag for object: mappings Field mappings can now declare nested_attributes: true alongside an object: target. When the flag is set, Bulkrax routes the imported data through parsed_metadata['<name>_attributes'] as a numbered-key hash with '_destroy: false' markers — the shape Reform's nested-attributes machinery and similar populators consume directly. Without the flag, callers had to add hardcoded translators (e.g. ValkyrieObjectFactory#convert_based_near_to_attributes) to rename parsed_metadata['<name>'] into the form their downstream form expected. The flag generalizes that rename so each new nested-attribute consumer declares its needs in the mapping rather than in factory code. Also makes object_metadata on export tolerant of plain hashes (in addition to the legacy stringified-hash literal). Resources backed by JSONB or similar non-string persistence return Ruby hashes directly; calling eval on them would fail. The legacy stringified path still works for ActiveFedora-backed resources. * Add round-trip spec for nested_attributes flag Confirms that a single field-mapping declaration drives both directions: imported numbered columns become _attributes-shaped data the form populator consumes, and the persisted plain-hash array exports back to the same numbered columns. * Permit nested *_attributes keys in object factory When a field-mapping declares nested_attributes: true, the parsed metadata arrives at the object factory with a *_attributes virtual key (e.g. redirects_attributes). The slice in #transform_attributes was dropping these keys because permitted_attributes only listed bare schema properties, so downstream form populators received nothing and the data silently disappeared. permitted_attributes now allows a *_attributes key through whenever the corresponding bare property is itself permitted. Generic — any nested-attributes property added in the future works without further changes here. * Expand object: properties in CSV template columns When a property declared on a model (e.g. redirects) is the target of one or more `object:` field-mappings, the CSV template now emits each of those mappings' `from:` columns (e.g. redirect_path, redirect_canonical, redirect_sequence) instead of the bare property name. Adopters get a template that matches the column shape Bulkrax actually consumes for nested-attribute imports. * Tighten suffix validation to require known base Headers ending in `_<digits>` previously bypassed validation regardless of the base name, which masked typos like `creater_1` at validation time even though the real importer would fail to map the column. A suffixed header now passes validation only when its base name is itself recognised — either present in valid_headers directly or resolvable via the mapping manager to a known property — mirroring the recognition rule already used for unsuffixed headers in find_unrecognized_validation_headers. Adds spec coverage for both the bare and numbered forms of an `object:` mapping with `nested_attributes: true`, and a regression spec for the typo-with-suffix case the new rule now catches. * Keep object: columns in CSV template downloads The CSV builder prunes columns whose data rows are all blank, so per- child columns from `object:` mappings (e.g. redirect_path) were getting dropped from the downloaded template even when they appeared in the column-builder output. The row builder didn't recognise them as belonging to a known property — `mapped_to_key('redirect_path')` returned `path`, which isn't in the model's properties list. ValueDeterminer now consults the mapping's `object:` value: if the key isn't a property but its object name is, the cell is filled ("Required"/"Optional") based on the parent property. The columns survive the empty-column pruning and reach the user. * Memoize known_property_keys in check_headers The known-property set was being rebuilt on every suffixed header header_base_recognized? checked. Computing the set once in check_headers and passing it down avoids the repeated allocations during validation of CSVs with many numbered columns. Behavior is unchanged.
1 parent 2df78bd commit 57eccec

15 files changed

Lines changed: 513 additions & 37 deletions

File tree

app/factories/bulkrax/object_factory_interface.rb

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,24 @@ def conditionally_destroy_existing_files
468468
# Regardless of what the Parser gives us, these are the properties we are
469469
# prepared to accept.
470470
def permitted_attributes
471-
klass.properties.keys.map(&:to_sym) + base_permitted_attributes
471+
bare = klass.properties.keys.map(&:to_sym) + base_permitted_attributes
472+
bare + nested_attributes_keys(bare)
473+
end
474+
475+
# Permits `*_attributes` virtual keys when the corresponding bare property
476+
# is itself permitted. Field mappings declaring `nested_attributes: true`
477+
# (see Bulkrax::HasMatchers#set_parsed_object_data) emit data under keys
478+
# like `redirects_attributes`; without this, the slice in
479+
# #transform_attributes would drop them and downstream form populators
480+
# would receive nothing.
481+
def nested_attributes_keys(bare_permitted)
482+
bare_set = bare_permitted.map(&:to_sym).to_set
483+
attributes.keys.filter_map do |key|
484+
key_str = key.to_s
485+
next unless key_str.end_with?('_attributes')
486+
bare_name = key_str.sub(/_attributes\z/, '').to_sym
487+
key.to_sym if bare_set.include?(bare_name)
488+
end.uniq
472489
end
473490

474491
# Return a copy of the given attributes, such that all values that are empty

app/factories/bulkrax/valkyrie_object_factory.rb

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -498,14 +498,15 @@ def perform_transaction_for(object:, attrs:)
498498
#
499499
# @return [Array<Symbols>]
500500
def permitted_attributes
501-
@permitted_attributes ||= (
502-
base_permitted_attributes + if klass.respond_to?(:schema)
503-
admin_set_id = attributes[:admin_set_id] || attributes['admin_set_id']
504-
Bulkrax::ValkyrieObjectFactory.schema_properties(klass: klass, admin_set_id: admin_set_id)
505-
else
506-
klass.properties.keys.map(&:to_sym)
507-
end
508-
).uniq
501+
@permitted_attributes ||= begin
502+
bare = base_permitted_attributes + if klass.respond_to?(:schema)
503+
admin_set_id = attributes[:admin_set_id] || attributes['admin_set_id']
504+
Bulkrax::ValkyrieObjectFactory.schema_properties(klass: klass, admin_set_id: admin_set_id)
505+
else
506+
klass.properties.keys.map(&:to_sym)
507+
end
508+
(bare + nested_attributes_keys(bare)).uniq
509+
end
509510
end
510511

511512
def update_work(attrs)

app/models/bulkrax/csv_entry.rb

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -329,15 +329,14 @@ def prepare_export_data(datum)
329329
end
330330

331331
def object_metadata(data)
332-
# NOTE: What is `d` in this case:
333-
#
334-
# "[{\"single_object_first_name\"=>\"Fake\", \"single_object_last_name\"=>\"Fakerson\", \"single_object_position\"=>\"Leader, Jester, Queen\", \"single_object_language\"=>\"english\"}]"
335-
#
336-
# The above is a stringified version of a Ruby string. Using eval is a very bad idea as it
337-
# will execute the value of `d` within the full Ruby interpreter context.
338-
#
339-
# TODO: Would it be possible to store this as a non-string? Maybe the actual Ruby Array and Hash?
340-
data = data.map { |d| eval(d) }.flatten # rubocop:disable Security/Eval
332+
# Each `d` may be either a stringified Ruby hash literal (legacy
333+
# ActiveFedora persistence) or a plain Hash (Valkyrie/Postgres
334+
# JSONB). For the legacy stringified form we eval to recover the
335+
# hash; for plain Hashes we pass through. Using eval is a very bad
336+
# idea as it will execute the value of `d` within the full Ruby
337+
# interpreter context — only do it when we know the input is a
338+
# stringified hash.
339+
data = data.map { |d| d.is_a?(Hash) ? d : eval(d) }.flatten # rubocop:disable Security/Eval
341340

342341
data.each_with_index do |obj, index|
343342
next if obj.nil?

app/models/concerns/bulkrax/has_matchers.rb

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def add_metadata(node_name, node_content, index = nil)
3838

3939
if object_name
4040
Rails.logger.info("Bulkrax Column automatically matched object #{node_name}, #{node_content}")
41-
parsed_metadata[object_name] ||= object_multiple ? [{}] : {}
41+
init_object_container(object_name, object_multiple)
4242
end
4343

4444
value = if matcher
@@ -60,6 +60,30 @@ def get_object_name(field)
6060
mapping&.[](field)&.[]('object')
6161
end
6262

63+
# When any field-mapping sibling under `object_name` carries
64+
# `nested_attributes: true`, Bulkrax routes the imported data through
65+
# `parsed_metadata["#{object_name}_attributes"]` as a numbered-key hash
66+
# (with `_destroy: 'false'` per row) — the shape consumed by Reform's
67+
# nested-attributes machinery and other `*_attributes`-style populators.
68+
def nested_attributes_object?(object_name)
69+
return false unless mapping.is_a?(Hash) && object_name.present?
70+
mapping.any? { |_, cfg| cfg.is_a?(Hash) && cfg['object'] == object_name && cfg['nested_attributes'] }
71+
end
72+
73+
def parsed_object_target_key(object_name)
74+
nested_attributes_object?(object_name) ? "#{object_name}_attributes" : object_name
75+
end
76+
77+
def init_object_container(object_name, object_multiple)
78+
target_key = parsed_object_target_key(object_name)
79+
default = if object_multiple
80+
nested_attributes_object?(object_name) ? {} : [{}]
81+
else
82+
{}
83+
end
84+
parsed_metadata[target_key] ||= default
85+
end
86+
6387
def set_parsed_data(name, value)
6488
return parsed_metadata[name] = value unless multiple?(name)
6589

@@ -69,22 +93,33 @@ def set_parsed_data(name, value)
6993
end
7094

7195
def set_parsed_object_data(object_multiple, object_name, name, index, value)
72-
if object_multiple
73-
index ||= 0
74-
parsed_metadata[object_name][index] ||= {}
75-
parsed_metadata[object_name][index][name] ||= []
76-
if value.is_a?(Array)
77-
parsed_metadata[object_name][index][name] += value
78-
else
79-
parsed_metadata[object_name][index][name] = value
80-
end
96+
target_key = parsed_object_target_key(object_name)
97+
target = object_target_for(target_key, object_name, object_multiple, index)
98+
assign_object_value(target, name, value)
99+
end
100+
101+
# Resolve the hash slot that `name` should be written into, initializing
102+
# any intermediate containers. Returns the leaf hash so the caller can
103+
# assign the value with a single statement.
104+
def object_target_for(target_key, object_name, object_multiple, index)
105+
return parsed_metadata[target_key] unless object_multiple
106+
107+
idx = index || 0
108+
if nested_attributes_object?(object_name)
109+
parsed_metadata[target_key][idx.to_s] ||= { '_destroy' => 'false' }
110+
parsed_metadata[target_key][idx.to_s]
81111
else
82-
parsed_metadata[object_name][name] ||= []
83-
if value.is_a?(Array)
84-
parsed_metadata[object_name][name] += value
85-
else
86-
parsed_metadata[object_name][name] = value
87-
end
112+
parsed_metadata[target_key][idx] ||= {}
113+
parsed_metadata[target_key][idx]
114+
end
115+
end
116+
117+
def assign_object_value(target, name, value)
118+
target[name] ||= []
119+
if value.is_a?(Array)
120+
target[name] += value
121+
else
122+
target[name] = value
88123
end
89124
end
90125

app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,15 @@ def check_headers(headers, raw_csv, mapping_manager, mappings, field_metadata, f
8484
all_models = field_metadata.keys
8585
valid_headers = build_valid_validation_headers(mapping_manager, field_analyzer,
8686
all_models, mappings, field_metadata)
87-
suffixed = headers.select { |h| h.match?(/_\d+\z/) }
87+
# Only allow a suffixed header (e.g. `creator_1`, `redirect_path_2`)
88+
# when its base name is itself recognised. The blanket allow that
89+
# used to live here let through any *_<digits> column, which masked
90+
# typos in numbered columns at validation time even though the real
91+
# importer would fail to map them.
92+
known_property_keys = (field_metadata || {}).values.flat_map { |m| Array(m[:properties]) }.to_set
93+
suffixed = headers.select do |h|
94+
h.match?(/_\d+\z/) && header_base_recognized?(h, valid_headers, mapping_manager, known_property_keys)
95+
end
8896
valid_headers = (valid_headers + suffixed).uniq
8997

9098
{
@@ -96,6 +104,20 @@ def check_headers(headers, raw_csv, mapping_manager, mappings, field_metadata, f
96104
}
97105
end
98106

107+
# Mirrors the recognition rule used by
108+
# find_unrecognized_validation_headers: a header's base name is
109+
# recognised if it appears in valid_headers directly or if its
110+
# mapping_manager#mapped_to_key resolves to a known model property.
111+
# known_property_keys is precomputed by check_headers so this can be
112+
# called per-header without rebuilding the set each time.
113+
def header_base_recognized?(header, valid_headers, mapping_manager, known_property_keys)
114+
base = header.sub(/_\d+\z/, '')
115+
return true if valid_headers.include?(base)
116+
117+
mapped_key = mapping_manager&.mapped_to_key(base)
118+
mapped_key.present? && known_property_keys.include?(mapped_key)
119+
end
120+
99121
def extract_hierarchy_items(csv_data, all_ids, find_record, mappings)
100122
extract_validation_items(
101123
csv_data, all_ids, find_record,

app/services/bulkrax/csv_template/column_builder.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,22 @@ def property_columns
3535
properties = field_lists
3636
.flat_map { |item| item.values.flat_map { |config| config["properties"] || [] } }
3737
.uniq
38-
.map { |property| @service.mapping_manager.key_to_mapped_column(property) }
38+
.flat_map { |property| columns_for_property(property) }
3939
.uniq
4040

4141
(properties - required_columns).sort
4242
end
4343

44+
# When a property is the target of one or more `object:` field mappings,
45+
# emit each of those mappings' `from:` columns (e.g. redirect_path,
46+
# redirect_canonical, redirect_sequence) rather than the bare property
47+
# name (redirects). Otherwise fall back to the standard 1:1 mapping.
48+
def columns_for_property(property)
49+
nested = @service.mapping_manager.object_columns_for(property)
50+
return nested if nested.any?
51+
[@service.mapping_manager.key_to_mapped_column(property)]
52+
end
53+
4454
def relationship_columns
4555
[
4656
@service.mapping_manager.find_by_flag("related_children_field_mapping", 'children'),

app/services/bulkrax/csv_template/mapping_manager.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ def key_to_mapped_column(key)
2626
@mappings.dig(key, "from")&.first || key
2727
end
2828

29+
# Returns the `object:` value for a given mapping key, or nil. Mirrors
30+
# the importer-side `Bulkrax::HasMatchers#get_object_name` for callers
31+
# working with the template-side mapping manager.
32+
def get_object_name(key)
33+
@mappings.dig(key, "object")
34+
end
35+
36+
# Returns the column names that target a given object name via the
37+
# `object:` field-mapping pattern. The template generator uses this to
38+
# emit the per-child columns (e.g. redirect_path, redirect_canonical,
39+
# redirect_sequence) instead of the bare property name (redirects).
40+
# Numbering is intentionally omitted — the template shows the column
41+
# shape once; CSV rows can repeat the column with numeric suffixes
42+
# (e.g. redirect_path_1, redirect_path_2) at import time.
43+
def object_columns_for(object_name)
44+
@mappings
45+
.select { |_k, v| v.is_a?(Hash) && v["object"] == object_name }
46+
.values
47+
.flat_map { |v| Array(v["from"]) }
48+
.uniq
49+
end
50+
2951
def find_by_flag(field_name, default)
3052
@mappings.find { |_k, v| v[field_name] == true }&.first || default
3153
end

app/services/bulkrax/csv_template/value_determiner.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ def initialize(service)
1212
def determine_value(column, model_name, field_list)
1313
key = @service.mapping_manager.mapped_to_key(column)
1414
required_terms = field_list.dig(model_name, 'required_terms')
15+
properties = field_list.dig(model_name, "properties") || []
16+
object_name = @service.mapping_manager.get_object_name(key)
1517

16-
if field_list.dig(model_name, "properties")&.include?(key)
18+
if properties.include?(key)
1719
mark_required_or_optional(key, required_terms)
20+
elsif object_name && properties.include?(object_name)
21+
# Column belongs to an `object:` mapping (e.g. `redirect_path` → object
22+
# `redirects`). Treat the column as required/optional based on the
23+
# parent property's required-terms list.
24+
mark_required_or_optional(object_name, required_terms)
1825
elsif special_column?(column, key)
1926
special_value(column, key, model_name, required_terms)
2027
end

0 commit comments

Comments
 (0)