Module: OpenLoam::Import
- Defined in:
- lib/open_loam/import.rb
Overview
CSV import mapping engine — reusable, tenant-scoped, policy-safe.
OpenLoam::Import.preview(csv) # headers + first rows for the mapping UI
OpenLoam::Import.run(csv, model:, mapping:, actor:, # map CSV header => field
match_key: "name", # update-or-create by a key (nil = create-only)
dry_run: true, # validate + report, commit nothing
progress: progress_job) # advance a OpenLoam::ProgressJob per row
Safety: the mapping may only target fields the actor's role can WRITE (real columns + declared custom fields); tenant_id, plumbing, and non-permitted fields are refused (no crafted mapping can smuggle a value past the policy). Each row is its OWN save — a bad row is logged (row number + reason) and SKIPPED, never half-committed, and the import continues. Records land in the current tenant automatically (TenantRecord). Malformed CSV is a clean error, not a crash.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- PLUMBING =
%w[id tenant_id created_at updated_at lock_version deleted_at custom_fields].freeze
Class Method Summary collapse
-
.allowed_model(entity_type) ⇒ Object
Resolve an entity_type string to a model — ONLY a OpenLoam::TenantRecord subclass (never an arbitrary constant), so an import target can't be smuggled to a global model like User (same guard as the business-rules engine).
-
.allowed_targets(model, actor) ⇒ Object
The fields a mapping may target for
modelgivenactor's role. - .assign(record, attrs, model) ⇒ Object
-
.error_csv(result, csv_string) ⇒ Object
The failed rows as a fix-and-re-upload CSV — rebuilt from the ORIGINAL csv (a transient download the user already holds), NOT from the persisted result (which carries no cell values).
- .find_or_build(model, attrs, match_key) ⇒ Object
- .policy_for(model, actor) ⇒ Object
- .preview(csv_string, limit: 5) ⇒ Object
-
.refuse_bad_mapping!(mapping, allowed) ⇒ Object
---- internals ----.
- .row_attributes(headers, row, mapping) ⇒ Object
- .run(csv_string, model:, mapping:, actor:, match_key: nil, dry_run: false, progress: nil) ⇒ Object
- .safe_parse(csv_string) ⇒ Object
Class Method Details
.allowed_model(entity_type) ⇒ Object
Resolve an entity_type string to a model — ONLY a OpenLoam::TenantRecord subclass (never an arbitrary constant), so an import target can't be smuggled to a global model like User (same guard as the business-rules engine).
34 35 36 37 38 39 40 41 |
# File 'lib/open_loam/import.rb', line 34 def allowed_model(entity_type) klass = entity_type.to_s.safe_constantize unless klass.is_a?(Class) && klass < OpenLoam::TenantRecord raise OpenLoam::Error, "import target #{entity_type.inspect} is not a OpenLoam entity" end klass end |
.allowed_targets(model, actor) ⇒ Object
The fields a mapping may target for model given actor's role.
49 50 51 52 53 54 55 56 57 58 |
# File 'lib/open_loam/import.rb', line 49 def allowed_targets(model, actor) policy = policy_for(model, actor) columns = (model.column_names - PLUMBING).select { |c| policy.writable?(c) } customs = if model.respond_to?(:custom_field_definitions) model.custom_field_definitions.map(&:name).select { |n| policy.custom_field_writable?(n) } else [] end (columns + customs).to_set end |
.assign(record, attrs, model) ⇒ Object
142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/open_loam/import.rb', line 142 def assign(record, attrs, model) columns = model.column_names.to_set customs = model.respond_to?(:custom_field_definitions) ? model.custom_field_definitions.map(&:name).to_set : Set.new attrs.each do |field, value| if columns.include?(field) record.public_send("#{field}=", value) elsif customs.include?(field) record.set_custom_field(field, value) end end end |
.error_csv(result, csv_string) ⇒ Object
The failed rows as a fix-and-re-upload CSV — rebuilt from the ORIGINAL csv (a transient download the user already holds), NOT from the persisted result (which carries no cell values). Each cell is neutralized against CSV formula injection.
97 98 99 100 101 102 103 104 105 106 107 108 109 |
# File 'lib/open_loam/import.rb', line 97 def error_csv(result, csv_string) rows = safe_parse(csv_string) headers = rows.first || [] data_rows = rows.drop(1) CSV.generate do |out| out << (Array(headers) + [ "_error" ]) result.errors.each do |error| original = data_rows[error["row"] - 2] || [] # row is 1-based incl. the header out << original.map { |cell| OpenLoam::Csv.safe(cell) } + [ error["message"] ] end end end |
.find_or_build(model, attrs, match_key) ⇒ Object
131 132 133 134 135 136 137 138 139 140 |
# File 'lib/open_loam/import.rb', line 131 def find_or_build(model, attrs, match_key) key = match_key.to_s if key.present? && attrs[key].present? # find_by is tenant-scoped, so update-by-key can only ever hit a record # in the CURRENT tenant. existing = model.find_by(key => attrs[key]) return [ existing, false ] if existing end [ model.new, true ] end |
.policy_for(model, actor) ⇒ Object
161 162 163 |
# File 'lib/open_loam/import.rb', line 161 def policy_for(model, actor) OpenLoam::Policy.for_model(model, actor) end |
.preview(csv_string, limit: 5) ⇒ Object
43 44 45 46 |
# File 'lib/open_loam/import.rb', line 43 def preview(csv_string, limit: 5) rows = safe_parse(csv_string) { headers: rows.first || [], rows: rows.drop(1).first(limit) } end |
.refuse_bad_mapping!(mapping, allowed) ⇒ Object
---- internals ----
113 114 115 116 117 118 119 120 |
# File 'lib/open_loam/import.rb', line 113 def refuse_bad_mapping!(mapping, allowed) mapping.each_value do |target| next if target.to_s.blank? next if allowed.include?(target.to_s) raise OpenLoam::Error, "import mapping refuses #{target.inspect} — not a writable column or custom field" end end |
.row_attributes(headers, row, mapping) ⇒ Object
122 123 124 125 126 127 128 129 |
# File 'lib/open_loam/import.rb', line 122 def row_attributes(headers, row, mapping) cells = Hash[headers.zip(row)] mapping.each_with_object({}) do |(header, target), attrs| next if target.to_s.blank? attrs[target.to_s] = cells[header] end end |
.run(csv_string, model:, mapping:, actor:, match_key: nil, dry_run: false, progress: nil) ⇒ Object
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
# File 'lib/open_loam/import.rb', line 60 def run(csv_string, model:, mapping:, actor:, match_key: nil, dry_run: false, progress: nil) refuse_bad_mapping!(mapping, allowed_targets(model, actor)) rows = safe_parse(csv_string) headers = rows.first || [] result = Result.new(created: 0, updated: 0, failed: 0, errors: []) rows.drop(1).each_with_index do |row, index| line = index + 2 # human row number (1-based + header) begin attrs = row_attributes(headers, row, mapping) record, is_new = find_or_build(model, attrs, match_key) assign(record, attrs, model) if dry_run raise ActiveRecord::RecordInvalid, record unless record.valid? else record.save! end is_new ? (result.created += 1) : (result.updated += 1) rescue StandardError => error # Store ONLY the row number + message — NEVER the raw cell values. The # result is persisted (OpenLoam::ProgressJob.result); a failed row into an # encrypted field would otherwise write PLAINTEXT PII at rest. result.failed += 1 result.errors << { "row" => line, "message" => error. } end progress&.advance end result end |