Skip to content

Fixes: #708 - Represent select/multiselect fields as {value, label} in REST and GraphQL - #718

Merged
jnovinger merged 3 commits into
715-integer-decimal-min-max-validationfrom
708-select-multiselect-value-label
Sep 21, 2026
Merged

jnovinger merged 3 commits into
715-integer-decimal-min-max-validationfrom
708-select-multiselect-value-label

Conversation

@bctiemann

@bctiemann bctiemann commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes: #708

Summary

SelectFieldType/MultiSelectFieldType never wrapped their stored value in a labeled representation, unlike every core NetBox ChoiceField (e.g. dcim.Site.status) — both APIs returned the raw stored string(s) directly. One underlying gap, surfacing in both REST and GraphQL, as the issue itself notes.

Note: this PR targets the 715-integer-decimal-min-max-validation branch (itself stacked on 700-required-fields-not-enforced/#714), not main — it builds on the get_serializer_field() methods those introduce. Retarget to main once the earlier PRs in the stack merge.

  • REST: SelectFieldType.get_serializer_field() now returns netbox.api.fields.ChoiceField — core's own field that represents {value, label} on read and still accepts a bare value on write — instead of DRF's plain ChoiceField. MultiSelectFieldType's ListField uses the same core ChoiceField as its child, so each item in the list reads the same way.
  • GraphQL: added a small CustomObjectChoiceType (value, label) and a resolver for SELECT/MULTISELECT fields, mirroring core's CustomField.resolve_selection_value() almost exactly, and reusing this plugin's own CustomObjectTypeField.get_choice_label() — the same helper already backing the UI table column (SelectFieldType.get_table_column_field()) and get_display_value().
  • Updated test_scalar_fields_query, whose existing assertion on the raw "status" value is now intentionally outdated by this fix.

Test plan

  • SelectMultiSelectValueLabelAPITest (new, test_api.py) — REST GET returns {value, label} for select and a list of such dicts for multiselect; POST still accepts bare values on write.
  • test_multiselect_field_query_returns_value_label_pairs (new, test_graphql.py) — GraphQL query returns {value, label} per item for a multiselect field.
  • test_scalar_fields_query (updated) — GraphQL select field now queried/asserted as {value, label}.
  • Empirically verified both layers end-to-end: REST GET/POST round-trip, and the actual built Strawberry type's resolver called directly against a real instance.
  • Full test_graphql suite (32 tests, including the schema-assembly smoke test) passes.
  • Broader regression pass (test_api, test_graphql, test_field_types, test_views — 455 tests) — 4 pre-existing errors observed, matching the already-established netbox_branching app_label baseline, confirmed unrelated.
  • ruff check passes on all changed files.

@bctiemann bctiemann changed the title Fix #708: represent select/multiselect fields as {value, label} in REST and GraphQL Fixes: #708 - Represent select/multiselect fields as {value, label} in REST and GraphQL Sep 17, 2026
@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review and limit response to actionable issues.

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

Claude finished @bctiemann's task in 1m 51s —— View job


Review

  • Read changed files
  • Analyze implementation
  • Post review

The implementation is clean and closely mirrors core NetBox's own pattern. Two actionable issues:


1. Stale graphql_annotation on SelectFieldType and MultiSelectFieldType

field_types.py:737 and field_types.py:832:

class SelectFieldType(FieldType):
    graphql_annotation = str          # now bypassed by CHOICE_TYPES guard

class MultiSelectFieldType(FieldType):
    graphql_annotation = List[str]    # same

After this PR, _build_object_type hits the CHOICE_TYPES guard before it ever calls _scalar_annotation_for()get_graphql_annotation(), so these attributes are dead code. But they still say str / List[str], which is wrong — anyone calling SelectFieldType().get_graphql_annotation() directly (e.g., future tooling, docs generation, schema introspection helpers) gets a lie. They should either be removed (set to None) or updated to reflect the actual GQL return type.

Fix this →


2. Null select value not tested in REST

test_api.pytest_select_field_reads_as_value_label creates status="choice2" (always non-null). There's no test covering a non-required select field with no value set, which exercises a different branch in NetBoxChoiceField.to_representation(). Given the allow_null=True path on the serializer field, it's worth asserting that a null DB value comes back as None (not {"value": None, "label": ""} or similar).

Fix this →


Nothing else blocks this; the core logic, the _make_choice_resolver close-over, and the write-accepts-bare-value tests are all correct.

@bctiemann

Copy link
Copy Markdown
Contributor Author

Addressed both in 63405a8:

  1. Stale graphql_annotation — removed = str/= List[str] from SelectFieldType/MultiSelectFieldType entirely (matching ObjectFieldType/MultiObjectFieldType, which never set it either, for the same reason: GraphQL resolution goes through a dedicated resolver, not get_graphql_annotation()). Dropped the now-unused List import.
  2. Null-value coverage — added test_select_field_reads_as_none_when_unset and test_multiselect_field_reads_as_none_when_unset. Worth noting: an unset multiselect also reads back as None, not [] — DRF's Serializer.to_representation() short-circuits to None before ever calling the field's own to_representation() when the underlying attribute is None, so this is inherent to how the model field's own default works, unaffected by this fix either way.

259/260 in the affected test classes (1 pre-existing skip, expected).

@bctiemann
bctiemann added this pull request to stack #717 September 17, 2026 23:57
@bctiemann
bctiemann requested a review from jnovinger September 18, 2026 00:13
Comment thread netbox_custom_objects/field_types.py Outdated
bctiemann added a commit that referenced this pull request Sep 21, 2026
Core's netbox.api.fields.ChoiceField coerces numeric- and boolean-looking
string input (e.g. "1", "true") to int/bool in to_internal_value() before
checking choice membership. That's safe for developer-defined enum choices
with non-string values, but this plugin's choice values are always the raw
strings an admin typed into a choice set, so a choice like "1" or "true"
would fail to validate against itself -- the API rejecting its own GET
output on write.

Add SafeChoiceField (api/serializers.py), a subclass of core's ChoiceField
that keeps the {value, label} read representation but drops the write-side
coercion, and use it in SelectFieldType/MultiSelectFieldType's
get_serializer_field(). Local imports are used to avoid a circular import
(serializers.py imports field_types at module level).

Verified empirically by round-tripping GET output through PUT/PATCH, per
jnovinger's repro in review. Adds regression coverage for numeric- and
boolean-looking choice values on both select and multiselect fields,
including that out-of-set values are still rejected.

Closes: #718 (comment)
@bctiemann

Copy link
Copy Markdown
Contributor Author

Pushed c6bd376 addressing Jason's finding on the choice-field coercion bug (inline reply here).

Summary: core's netbox.api.fields.ChoiceField coerces numeric-/boolean-looking string input to int/bool before checking choice membership, which breaks any choice set with a value like "1" or "true" — the API rejects its own GET output on write. Added SafeChoiceField, a ChoiceField subclass that keeps the {value, label} read shape but drops that write-side coercion, and swapped it in for SelectFieldType/MultiSelectFieldType. Verified empirically and added regression tests for both select and multiselect (numeric string, boolean-like string, and confirming out-of-set values are still rejected).

…ST and GraphQL

SelectFieldType/MultiSelectFieldType never wrapped their stored value in a
labeled representation, unlike core NetBox's own ChoiceField convention
(e.g. dcim.Site.status), so both APIs returned the raw stored string(s)
directly - REST because get_serializer_field() used a plain DRF ChoiceField,
GraphQL because the field was just annotated with the raw scalar type.

REST: SelectFieldType now returns netbox.api.fields.ChoiceField (core's own
{value, label}-on-read field, matching every other core ChoiceField exactly)
instead of DRF's plain ChoiceField. MultiSelectFieldType's ListField now uses
the same core ChoiceField as its child, so each item reads the same way.
Both still accept a bare value/list of values on write, unchanged.

GraphQL: added a small CustomObjectChoiceType (value, label) and a resolver
for SELECT/MULTISELECT fields, mirroring core's CustomField.
resolve_selection_value() and reusing this plugin's own
CustomObjectTypeField.get_choice_label() for the label lookup - the same
helper already backing the UI table column and get_display_value().

Updated test_scalar_fields_query, whose existing assertion on the raw
"status" value is now intentionally outdated by this fix.
- Remove the now-stale graphql_annotation = str / List[str] on
  SelectFieldType/MultiSelectFieldType. GraphQL resolution for these two
  types goes through graphql/types.py's CHOICE_TYPES resolver, bypassing
  get_graphql_annotation() entirely - the same reason ObjectFieldType/
  MultiObjectFieldType never set graphql_annotation either. Drops the now-
  unused List import.
- Add null-value coverage to SelectMultiSelectValueLabelAPITest: an unset
  select field reads back as None (not {"value": None, ...}), and an unset
  multiselect field reads back as None too (the underlying ArrayField's own
  default, unaffected by this fix - DRF's serializer short-circuits to None
  before ever calling the field's to_representation()).
Core's netbox.api.fields.ChoiceField coerces numeric- and boolean-looking
string input (e.g. "1", "true") to int/bool in to_internal_value() before
checking choice membership. That's safe for developer-defined enum choices
with non-string values, but this plugin's choice values are always the raw
strings an admin typed into a choice set, so a choice like "1" or "true"
would fail to validate against itself -- the API rejecting its own GET
output on write.

Add SafeChoiceField (api/serializers.py), a subclass of core's ChoiceField
that keeps the {value, label} read representation but drops the write-side
coercion, and use it in SelectFieldType/MultiSelectFieldType's
get_serializer_field(). Local imports are used to avoid a circular import
(serializers.py imports field_types at module level).

Verified empirically by round-tripping GET output through PUT/PATCH, per
jnovinger's repro in review. Adds regression coverage for numeric- and
boolean-looking choice values on both select and multiselect fields,
including that out-of-set values are still rejected.

Closes: #718 (comment)
@bctiemann
bctiemann force-pushed the 708-select-multiselect-value-label branch from c6bd376 to e58c6cb Compare September 21, 2026 18:21
@bctiemann
bctiemann requested a review from jnovinger September 21, 2026 18:49
@jnovinger
jnovinger merged commit 0a550ea into main Sep 21, 2026
14 checks passed
@jnovinger
jnovinger deleted the 708-select-multiselect-value-label branch September 21, 2026 19:16
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.

Select/multiselect fields return the raw value instead of {value, label} in REST and GraphQL

2 participants