Skip to content

fix: only save custom fields the imported row carried [3.x] - #193

Merged
ManukMinasyan merged 3 commits into
3.xfrom
fix/import-only-saves-mapped-custom-fields
Aug 3, 2026
Merged

fix: only save custom fields the imported row carried [3.x]#193
ManukMinasyan merged 3 commits into
3.xfrom
fix/import-only-saves-mapped-custom-fields

Conversation

@ManukMinasyan

Copy link
Copy Markdown
Collaborator

Found while business-reviewing #192 end-to-end against a real consumer app.

The bug

ImporterBuilder::saveValues() delegated to saveCustomFields():

$this->customFields()->each(function (CustomField $customField) use ($customFields, $tenant): void {
    $value = $customFields[$customField->code] ?? null;   // ← absent becomes null
    $this->saveCustomFieldValue($customField, $value, $tenant);
});

That semantic is correct for a form — it renders every field, so a field absent from the submitted state means the user cleared it. It is destructive for an import, where a CSV legitimately maps a subset of columns and absent means "not in this file".

Result: an update import that mapped one custom field silently wiped every other custom field on the record.

after create:                   referral_source => 'Continuum of Care'   caseworker => 'Alex Rivera'
after update mapping only one:  referral_source => 'Street Outreach'     caseworker => null   ← wiped

Why it hasn't bitten yet

saveValues() returns early when ImportDataStorage::pull() is empty. Consumers that strip custom_fields_* keys from $this->data before Filament's Importer::fillRecord() runs never populate the storage at all, so saveValues() no-ops and nothing is written — or wiped. In the app I reviewed, that describes all 11 importers: custom field values were never persisted, which also hid this.

Fix that consumer-side ordering and the wipe becomes live immediately. So this is latent today and load-bearing the moment anyone gets the ordering right.

Fix

saveValues() now writes only the codes the row actually carried:

$this->getAllFields()
    ->filter(fn (CustomField $field): bool => array_key_exists($field->code, $customFieldsData))
    ->each(fn (CustomField $field) => $this->model->saveCustomFieldValue(
        $field, $customFieldsData[$field->code], $tenant,
    ));

Also fixes HasCustomFields::saveCustomFieldValue(), which was missing the ?Model $tenant = null parameter its UsesCustomFields implementation has accepted since multi-tenancy landed. PHPStan caught it as soon as the interface was called with three arguments. Any implementer following the contract would silently drop tenant scoping.

Verification

  • New tests/Feature/Imports/ImporterBuilderSaveValuesTest.php — three cases: carried fields are saved, uncarried fields are untouched, empty payload is a no-op. Confirmed the "untouched" case fails without the fix (null instead of 'Alex Rivera'), so it isn't vacuous.
  • pest --parallel — 764 passed, 0 failed
  • phpstan analyse — no errors
  • pint --dirty, rector --dry-run — clean
  • Verified end-to-end in a consumer app: with this plus the consumer's fill-ordering fix, a subset-column update import preserves the unmapped values.

Relationship to #192

Independent, both in the import path, both should land in the same release. #192 stops optional custom fields from failing validation on a mapped-but-empty column; this stops a subset-column import from wiping data once values actually reach the save path.

ImporterBuilder::saveValues() delegated to saveCustomFields(), which iterates
every custom field on the entity and writes null for any absent from its
payload. That is right for a form — it renders all fields, so absent means the
user cleared it — and destructive for an import, where a CSV legitimately maps
a subset of columns and absent means "not in this file".

An update import that mapped one custom field silently wiped every other custom
field on the record. The blast radius was masked because saveValues() returns
early on an empty payload, and callers that strip custom field keys before
Filament's fillRecord() never populate the payload at all.

saveValues() now writes only the codes the row actually carried, leaving the
rest untouched.

HasCustomFields::saveCustomFieldValue() was also missing the $tenant parameter
its implementation has accepted since multi-tenancy landed; the contract now
matches, so implementers can't drop tenant scoping by following the interface.
Copilot AI review requested due to automatic review settings August 3, 2026 18:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes destructive behavior in the import pipeline where updating a record with a subset of mapped custom-field columns could unintentionally wipe all other custom-field values, and aligns the HasCustomFields contract with the multi-tenancy-aware implementation.

Changes:

  • Update ImporterBuilder::saveValues() to persist only custom field codes actually present in the imported row payload.
  • Fix the HasCustomFields::saveCustomFieldValue() contract to accept an optional tenant model parameter.
  • Add feature tests covering: saving carried fields, leaving uncarried fields untouched, and no-op behavior when no custom-field columns were carried.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
tests/Feature/Imports/ImporterBuilderSaveValuesTest.php Adds regression tests to prevent imports from wiping uncarried custom fields.
src/Models/Contracts/HasCustomFields.php Updates interface signature to include optional tenant parameter for tenant-scoped value saving.
src/Filament/Integration/Builders/ImporterBuilder.php Changes save logic to write only row-carried custom field codes instead of nulling absent ones.

Comment on lines +126 to +130
->each(fn (CustomField $field) => $this->model->saveCustomFieldValue(
$field,
$customFieldsData[$field->code],
$tenant,
));
The distinction saveValues() draws is between a column the row carried and one
it did not, not between a value that is filled and one that is blank. A mapped
column left blank in the CSV reaches storage as an explicit null and must still
clear the stored value; only an absent key is left alone.
Collection::each() breaks out of the loop when its callback returns false, and
an arrow function implicitly returns whatever it evaluates. saveCustomFieldValue()
returns null today so nothing breaks, but the arrow function couples this loop's
completion to that return value: if the method ever returned false, the remaining
custom fields would be skipped silently, which is exactly the failure this method
exists to prevent.

Matches the block closure UsesCustomFields::saveCustomFields() already uses.
@ManukMinasyan
ManukMinasyan merged commit 1a1bb5e into 3.x Aug 3, 2026
4 checks passed
@ManukMinasyan
ManukMinasyan deleted the fix/import-only-saves-mapped-custom-fields branch August 3, 2026 19:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants