Skip to content

fix: serialize range fields as bracket literals, symmetric with writes - #113

Merged
npflood merged 1 commit into
masterfrom
fix/range-literal-serialization
Aug 10, 2026
Merged

fix: serialize range fields as bracket literals, symmetric with writes#113
npflood merged 1 commit into
masterfrom
fix/range-literal-serialization

Conversation

@npflood

@npflood npflood commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Production evidence: curling the live plant endpoint shows the 7 range fields (nAccumulationRange, biomassProductionRange, optimalTemperatureRange, optimalRainfallRange, seasonalityDaysRange, optimalAltitudeRange, phRange) rendering as Ruby Range#to_s output -- e.g. '5.5..7.0', '600...1201', '0...Infinity' -- while CreatePlant/UpdatePlant only accept and document Postgres-style bracket literals ('[min,max]'). Reads and writes were asymmetric since these fields shipped, so the admin SPA's range parser renders a blank input for every existing value.
  • Adds RangeLiteral (app/services/range_literal.rb), a pure serializer symmetric with the write side:
    • Integer ranges (int4range) round-trip through Postgres canonicalized to inclusive-lower/exclusive-upper (an editor's "[500,2000]" comes back from the database as the Ruby Range 500...2001), so a finite exclusive upper bound is decremented by 1 back to the inclusive value that was actually typed.
    • Numeric ranges (numrange) preserve whatever inclusivity was written (Postgres doesn't canonicalize continuous ranges), formatted via BigDecimal#to_s('F') to avoid engineering notation (BigDecimal#to_s/#inspect defaults render "0.55e1"), with a trailing ".0" stripped so a whole number like 7.0 renders "7".
    • Unbounded bounds (nil or infinite) render as an empty side, always closed with "]" ("[10,]", "[,100]"), never ")".
  • Wires it into PlantType and VarietyType via Types::Concerns::RangeLiteralFields, which define_method's the 7 resolvers from the single field list already declared in Mutations::Concerns::RangeLiteralValidation::RANGE_FIELDS -- one source of truth for "the 7 range fields", no new field declarations (those already existed).
  • Also fixes RecordDraftInfoType#author/#last_editor: Principal#display_name is nil for principals created by resolve_actor's upsert (application_controller.rb) -- it never passes a display name -- so this field rendered "Unknown" for the common case in staging. Falls back to email. No new exposure: draft is gated by update?, and editors who can see it already see the record's owner/creator email via ownedBy/createdBy.

Test plan

  • docker compose run --rm -e RAILS_ENV=test web bundle exec rspec -- full suite green, 2324 examples, 0 failures.
  • spec/contracts/** (mobile compatibility contracts) verified to carry zero range-field assertions before this change, and still green after -- the mobile app's locked-in GraphQL documents are unaffected by this fix.
  • New specs (TDD): spec/services/range_literal_spec.rb (integer decrement, unbounded both sides, BigDecimal formatting incl. the scientific-notation trap, nil, exclusive-numeric paren form), spec/mutations/range_literal_round_trip_spec.rb (schema-level write-then-read round trips for "[10,]", "[500,2000]", "[5.5,7.0]" numrange, and a DRAFT-perspective read via saveAsDraft: true + plant(perspective: DRAFT), plus a VarietyType check), spec/types/record_draft_info_type_spec.rb (display_name present / nil / blank -> email fallback).
  • docker compose run --rm web bundle exec rubocop -- clean, 730 files, no offenses.
  • docker compose run --rm web bundle exec rails graphql:schema:dump -- diff is exactly the two RecordDraftInfo.author/.lastEditor description updates; committed.

Generated with Claude Code

https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu

The 7 range fields on PlantType/VarietyType (nAccumulationRange,
biomassProductionRange, optimalTemperatureRange, optimalRainfallRange,
seasonalityDaysRange, optimalAltitudeRange, phRange) were rendering
through graphql-ruby's default String coercion, i.e. Ruby's Range#to_s
("5.5..7.0", "600...1201", "0...Infinity" against prod). Writes accept
Postgres-style bracket literals ("[min,max]"); reads emitted Ruby Range
syntax. The admin SPA's range parser only understands bracket literals,
so every existing value rendered as a blank input.

Add RangeLiteral (app/services/range_literal.rb), a pure serializer that
mirrors the write-side format. Integer ranges (int4range) round-trip
through Postgres canonicalized to an inclusive-lower/exclusive-upper
Ruby Range, so a finite exclusive upper bound is decremented back to the
inclusive value an editor actually typed. Numeric ranges (numrange)
preserve whatever inclusivity was written, formatted via
BigDecimal#to_s('F') to avoid engineering notation, with a trailing
".0" stripped. Unbounded bounds (nil or infinite) render as an empty
side, always closed with "]".

Wire it into both types via Types::Concerns::RangeLiteralFields, which
define_method's the 7 resolvers from the single field list already
declared in Mutations::Concerns::RangeLiteralValidation::RANGE_FIELDS.

Also fix RecordDraftInfoType#author/#last_editor: Principal#display_name
is nil for principals created by resolve_actor's upsert (it never passes
one), so the field rendered "Unknown" for the common case. Falls back to
email, which editors (the only audience -- draft is update?-gated) can
already see via ownedBy/createdBy.

Full RSpec suite green (2324 examples), including mobile contracts
(spec/contracts -- zero range assertions there, confirmed by the run),
and rubocop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnYLUscETuPwsQzU9nCSxu
@npflood
npflood merged commit e820a7b into master Aug 10, 2026
2 checks passed
@npflood
npflood deleted the fix/range-literal-serialization branch August 10, 2026 15:44
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