Skip to content

Add plants:import for the ECHOcommunity plant migration - #117

Merged
npflood merged 4 commits into
masterfrom
phase1/import-ec-plants
Aug 13, 2026
Merged

Add plants:import for the ECHOcommunity plant migration#117
npflood merged 4 commits into
masterfrom
phase1/import-ec-plants

Conversation

@npflood

@npflood npflood commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Phase 1 of the plant-data ownership migration. Imports plants exported from
ECHOcommunity so the API can become the system of record for them.

Shape

Consumes the same JSON as db/seeds/Plants.json, so the export tooling targets
a format this app already understands rather than a new one. Logic lives in
lib/ec_plant_importer.rb with a thin rake wrapper, following
staging_rehearsal.rake.

bin/rails plants:import[tmp/import.json]              # dry run
APPLY=true bin/rails plants:import[tmp/import.json]   # write

ECHO_ORG_ID selects the owning organization — staging uses
StagingRehearsalMapping::ECHO_ORG_ID.

Writes go through the model layer, never SQL, so Mobility translations, the
visibility trio and PaperTrail all behave normally. Idempotent: an existing
uuid is skipped, never overwritten — bringing a record up to date is
reconciliation, which is a separate concern with conflict handling.

Two decisions worth reviewing

Absent ranges are written as NULL explicitly. These columns carry defaults
that assert facts nobody measured:

column default
ph_range [0.0,14.0]
optimal_temperature_range [0,61)
n_accumulation_range [0,1)
biomass_production_range [0.0,0.0]
optimal_rainfall_range, optimal_altitude_range [0,)

Omitting an absent range therefore records "tolerates pH 0–14" rather than
"unknown". That is how the 2020 seed left 174 of its 322 plants claiming
exactly that, and it is worth knowing those rows are still in production. There
is a spec pinning this behaviour.

created_by_principal_id. It is NOT NULL, and a real organization such as
ECHO has no principal of its own — only personal orgs do. Production
attributes its 322 seeded plants to the service principal for
echo@echonet.org, so that is the default here, overridable with
ECHO_PRINCIPAL_ID.

Verification

Run against a copy of the 70 published ECHOcommunity plants currently absent
from the API: 70 created, all public and not deleted, 177 common names, and
every range NULL where the source had no value. Field mapping spot-checked
against source — the baobab's resource.description (1,376 chars),
plant.description (2,219) and plant.uses (2,167) land in description,
info_sheet_description and uses respectively.

  • 5 new specs covering creation, the primary-common-name rule, NULL ranges,
    idempotency and the dry-run default
  • Full suite: 2,312 examples, 0 failures
  • Rubocop clean (plant_import.rake added to the Metrics/BlockLength exclude
    list alongside ownership.rake)

Depends on nothing else; #116 is independent.

compare_and_sync read local state with record.attributes.slice(*source_attributes).
Mobility is configured with backend :container and without the attribute_methods
plugin, so translated attributes (description, uses, cultivation, ...) live in the
translations jsonb and never appear in #attributes. Slicing there returned {} for
them, so local always looked different from the stored snapshot.

Consequences, both verified by the added spec before the fix:

  - an unchanged re-sync was scored locally_modified instead of synced
  - a genuine upstream edit to a translated attribute was silently dropped -
    applied stayed 0, no conflict was raised, and the value never changed

Translated fields therefore stopped syncing after record creation, silently.

The existing suite missed this because SOURCE_ATTRS is scientific_name and
family_names, both real columns, and the one spec passing %w[description]
exercises the invalid-payload path, which returns before compare_and_sync.

Read through the public reader instead, so column-backed and translated
attributes behave identically. Applied at both call sites.

Full suite: 2307 examples, 0 failures. Rubocop clean.
Imports plants exported from ECHOcommunity during the plant-data ownership
migration, consuming the same JSON shape as db/seeds/Plants.json so the export
tooling targets a format this application already understands.

Writes go through the model layer rather than SQL, so Mobility translations,
the visibility trio and PaperTrail behave normally. Idempotent: an existing
uuid is skipped, never overwritten - bringing a record up to date is
reconciliation, a separate concern with conflict handling.

Two things worth calling out:

  - Absent ranges are written as NULL explicitly. These columns default to
    values nobody measured (ph_range '[0.0,14.0]', optimal_temperature_range
    '[0,61)', n_accumulation_range '[0,1)'), so omitting an absent range
    records 'tolerates pH 0-14' rather than 'unknown'. That is how the 2020
    seed left 174 of its 322 plants claiming exactly that.
  - created_by_principal_id is NOT NULL and a 'real' organization such as ECHO
    has no principal of its own, so it defaults to the service principal for
    the owner address, matching how production attributes its seeded plants.

Verified against a copy of the 70 published plants currently missing from the
API: 70 created, all public, 177 common names, every range NULL where absent.

Full suite 2312 examples, 0 failures. Rubocop clean.
The export now carries each plant's ECHOcommunity status as a visibility, so a
draft is created as a draft rather than created public and hidden a moment
later. Anything unrecognised errs to draft: invisible is the safe direction.

Also fixes two spec bugs found while adding coverage. A bare hash in an
argument list is parsed as keyword arguments in Ruby 3, so record('k' => v)
passed zero positional args. And a memoised rake task cannot be invoked twice
in one example, so the task helper became a method that re-enables.

Full suite 2313 examples, 0 failures. Rubocop clean.
The previous commit added a Metrics/ClassLength section to .rubocop.yml to
exclude the importer. That overrode the exclusions inherited from
.rubocop_todo.yml and broke CI with six pre-existing offenses in files nobody
had touched - source_synchronizer.rb, ownership_backfill.rb and four others.

Local linting missed it because it only inspected lib and spec/tasks; CI
inspects 734 files.

Range parsing moves to lib/ec_range_parser.rb, which brings the importer under
the limit honestly and puts a pure function somewhere reusable.

Whole-repo rubocop: 730 files, no offenses. Suite 2313 examples, 0 failures.
@npflood
npflood merged commit f9cc2d5 into master Aug 13, 2026
2 checks passed
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.

1 participant