Repository navigation
feat(taxonomy): add value_field arg to save term IDs - #1571
faisalahammad wants to merge 5 commits into
Conversation
Taxonomy fields always used the term slug as the field value. On options-page and term boxes that slug is what gets saved, so renaming a term slug breaks the saved value. Fields can now set 'value_field' => 'term_id' to use term IDs. The default stays 'slug', so existing fields don't change. - Option and checkbox values, saved value lookup and default display all go through new helpers on CMB2_Type_Taxonomy_Base. - Posted IDs are cast to int before wp_set_object_terms(), which would otherwise create a new term named after the ID. - In term ID mode a saved slug still resolves, so values saved before the switch keep loading. Fixes CMB2#1517
jtsternberg
left a comment
There was a problem hiding this comment.
Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.
Review: #1571 (head f4472bf)
Briefing. #1517 asks for taxonomy_select to store term IDs instead of slugs. For post boxes the value is never stored (terms go to wp_set_object_terms()); for options-page and term boxes the value is the stored data (5dee3d9 / #1077). The PR adds a value_field field arg ('slug' default, 'term_id' opt-in), routes value↔term conversion through three helpers on CMB2_Type_Taxonomy_Base, casts posted IDs with absint() before wp_set_object_terms(), and in ID mode falls back to a slug lookup for non-numeric saved values. Trade-off: an all-digit (or scientific-notation) legacy slug is ambiguous with an ID once a field switches modes.
Verdict: REQUEST CHANGES — one data-loss path on post boxes (F2), plus the migration hazard is understated in the PR and untested (F3), and a mechanical C14 collision with #1574 (F5).
Verified: default (slug) mode is unchanged — select keeps its strict ===, radio/multicheck keep their loose compares, the 5dee3d9 options-page/term branch is intact. Full suite on PR head: OK (336 tests, 1097 assertions) (PHP 8.3, WP latest, own wp-env). With includes/ reverted to develop, 7 of the 8 new tests fail (1 error, 6 failures); the slug-mode guard passes on both, as the PR says. No PHP 7.4-incompatible syntax in the diff (by eye; CI matrix is the real gate, C7). REST: get_rest_value() returns raw get_data(), so REST output changes only for opted-in options/term fields (string IDs instead of slugs); post-box taxonomy fields still have no stored value to return. No JS reads taxonomy values. No hook/filter fires a different number of times.
Findings
F1 — Silent fallback for unsupported value_field values · low · nit
includes/types/CMB2_Type_Taxonomy_Base.php:140
The PR cites wp_dropdown_categories()'s value_field as the naming precedent, but core accepts any term field there (term_id, name, slug, …). Here anything other than 'term_id' silently means slug — including the plausible 'id' spelling that get_term_by() itself accepts. Probe test_probe_value_field_id_spelling: ['id' spelling] option values: slugs (silent fallback). Name and shape otherwise fit C8 (declared in the registration array, reads as plain English).
Recommendation: CMB2_Utils::log_if_debug() on an unrecognized value (same pattern taxonomy() uses for a missing taxonomy arg), and say "only 'slug' and 'term_id'" in the docs. Accepting 'id' as an alias is optional.
F2 — ID mode on post boxes wipes the object's terms when a non-numeric value arrives · medium · blocking
includes/CMB2_Sanitize.php:158
array_filter( array_map( 'absint', … ) ) turns any slug into 0, drops it, and hands wp_set_object_terms() an empty array — which removes every term in that taxonomy from the post. The read side does the opposite ("a non-numeric saved value still resolves as a slug"), and the PR's own C14 says value→term conversion goes through get_term_by_value(); this path doesn't. Who sends a slug to an ID-mode post field: REST writes from clients built while the field was in slug mode, $cmb->save_fields() callers, front-end forms pre-filled from old data. Same applies to user/comment boxes (same else branch).
Probe test_probe_post_box_slug_value_in_id_mode (real save_fields() path, post had test_category):
[post slug mode] posted 'number_2' (term2=14); categories after save=[14]
[post term_id mode] posted 'number_2' (term2=14); categories after save=[]
Related nit on the same line: absint() maps -1 to 1. wp_dropdown_categories() defaults option_none_value to -1, so a dev who filters cmb2_taxonomy_select_default_value / cmb2_taxonomy_radio_default_value to -1 (the core convention the arg name points at) gets "None" assigning term ID 1.
Recommendation: resolve each value through get_term_by_value() and pass $term->term_id, dropping only values that resolve to nothing; use (int) with a > 0 filter rather than absint(). Add a test that a slug posted to an ID-mode post field sets that term rather than clearing.
F3 — Switching an existing options/term field to IDs reinterprets numeric slugs; the PR's "re-save fixes it" is wrong · medium · should-fix
includes/types/CMB2_Type_Taxonomy_Base.php:168, PR description "Risk Level", CONVENTIONS.md:430-431
Confirmed in both cases. Numeric slugs are real-world data, not a corner: #477 was filed by a site with terms 0, 1, 2, 3… (it's why the select uses ===).
Probe test_probe_legacy_numeric_slug_names_existing_other_term — saved slug "3" belongs to term 4 (Legacy); term 3 (number_2) exists:
[wrong-id control slug-mode] selected='3' ← slug mode: Legacy selected
[wrong-id] legacy term ID=4 slug=3; term2 ID=3; selected option value='3' ← ID mode: number_2 selected
[wrong-id] saved after normal re-save='3' (legacy term ID 4)
The stored string doesn't change, its meaning does: every reader of the option now gets number_2, and a re-save confirms it.
Probe test_probe_legacy_numeric_slug_absent_id — saved slug "987654", no term with that ID:
[absent-id] legacy term ID=7; selected=''; php errors=array ( 0 => 'Attempt to read property "term_id" on false', )
[absent-id] saved after normal re-save=false
Nothing selected, the original selection is erased on save. (The warning is pre-existing: slug mode with a deleted term emits Attempt to read property "slug" on false — test_probe_slug_mode_deleted_term_warning_baseline. Not this PR's to fix, but get_term_value() is a natural place to guard it.)
is_numeric() also widens the collision set: probe test_probe_scientific_notation_slug — term 12 with slug 1e3: get_term_by_value('1e3') => false (looked up as ID 1000).
Recommendation: use ctype_digit( (string) $value ) (or is_int || ctype_digit) instead of is_numeric(). Replace "Re-save fixes it" with accurate guidance: before switching an existing field, convert stored slugs to IDs (a short snippet in the docs/changelog), because the UI cannot recover them. Add the two cases above as tests pinning the known limit so a future change to the fallback is deliberate.
F4 — New tests call CMB2_Sanitize directly; the options-page save path isn't exercised · low · should-fix
tests/test-cmb-types.php:1506, tests/test-cmb-types.php:1532
Both save tests construct CMB2_Sanitize and call taxonomy(), then write the option by hand with cmb2_options()->update(). Nothing goes through CMB2::save_fields() → process_fields() → CMB2_Options, which is where the opt-in actually lands for the boxes the PR targets. The probes above show the real path works for IDs; the suite should too.
Recommendation: replace or add a $cmb->save_fields( $key, 'options-page', array( … ) ) round trip (save ID → render selected → stored value), and the same for a post box (covers F2).
F5 — C14 collides with #1574, and the entry cites a review that didn't happen · low · should-fix
CONVENTIONS.md:415, CONVENTIONS.md:425
Confirmed: #1574 adds ## C14 — \0` and `'0'` are field values, not empties. Whichever merges second must renumber. Also: heading uses :where every other entry uses—; "(both caught in review)" — the PR has no reviews or comments, so this records an unseen review as history. The entry's rule ("never get_term_by( 'slug', ... )directly … goes throughget_term_by_value()`") is contradicted by the PR's own sanitize path (F2).
Recommendation: coordinate numbering with #1574 (merge order decides); use —; drop "(both caught in review)"; after F2, the "Saving" paragraph should describe resolution through get_term_by_value().
F6 — example-functions.php comment and changelog omit the switch hazard · low · nit
example-functions.php:388, CHANGELOG.md:8
"The default stays 'slug', so existing fields are not affected" is true for the default; the risk is for developers who flip an existing field, which is the main audience for #1517. One clause pointing to the migration note (F3) covers it. User-facing field docs on cmb2.io (Field-Types) also need the arg — follow-up for the wiki, not this PR.
Claims from an earlier private review, checked
- 5dee3d9 branch preserved, slug default, strict select compare in slug mode — confirmed (code read + guard test passes on both develop and PR).
- Migration hazard: wrong-existing-ID commits wrong selection; absent-ID loses it; "re-save fixes it" misleading — confirmed by probes (F3).
is_numeric()accepts1e3— confirmed (F3).- Duplicate C14 with #1574 — confirmed (F5).
- Display path —
CMB2_Field_Displayradio/multicheck only useget_term_by_value()for defaults; post terms come fromget_the_terms()objects. Correct; covered bytest_taxonomy_value_field_term_id_display_default. - REST — no code change; stored shape for opted-in options/term fields becomes string IDs.
- Non-term object types —
value_fielddoes affect thewp_set_object_terms()path (theabsint()cast); that's where F2 lives. - C8 fit — fits (field arg in the registration array, plain-English value); F1 nit on silent fallback.
PR description claims checked: "336 tests, 1097 assertions, all pass" — reproduced. "Term ID tests fail without the fix; slug-mode test passes on develop" — reproduced. "Re-save fixes it" — refuted (F3). "Default path unchanged" — confirmed.
Fences checked
- Select strict
===(CMB2_Type_Taxonomy_Select::loop_terms) → 85c0813, #477 (numeric slugs0/1made "None" and "0" both selected) → kept in slug mode; ID mode uses string-cast strict compare withis_scalar(), which preserves #477's protection (IDs are never 0). options-page/term→ store values, not relationships (CMB2_Sanitize::taxonomy) → 5dee3d9, #1077 → kept.wp_set_object_terms( …, $this->value, … )raw pass-through → 5dee3d9 → changed in ID mode only (F2).get_term_by( 'slug', … )inoptions_terms()→ 8086310 (2016, non-post objects) → changed toget_term_by_value(); slug mode identical.array_shift( $saved_terms )->slug→ 519e809 (2017, hierarchical types) → changed toget_term_value(); slug mode identical.- Radio loose
$saved_term == $term->slug→ 4d11c11 (2014) → kept loose; ID mode compares ints; checked PHP 7.4'' == 0coercion — not reachable (no term ID 0, none-option value is''). - Multicheck loose
in_array( $term->slug, … )→ 4d11c11 → kept loose, same reasoning. - Display
get_term_by( 'slug', $default, … )→ 974c7cf (2016, field display) → changed toget_term_by_value(); slug mode identical.
Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.
- Resolve each posted value to a term before wp_set_object_terms(), so a posted slug sets its term and '-1' is not read as term 1. - Read only digit-only values as term IDs, not is_numeric(). - Return '' from get_term_value() when no term is found. - Log an unsupported value_field once per field. - Add save_fields() tests for post box and options page, and pin the all-digit slug limits. - Update C14, changelog and example with convert-first guidance. Addresses PR feedback. Refs CMB2#1571
# Conflicts: # CHANGELOG.md
|
Thanks @jtsternberg for the review. All points are fixed in a4e2462. I also merged 1. Unsupported 2. 3. 4. Tests call 5. C14 wording 6. Example and changelog miss the switch hazard Tests: 342 tests, 1116 assertions pass in wp-env. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1571 +/- ##
=============================================
+ Coverage 72.16% 73.10% +0.93%
- Complexity 1911 1927 +16
=============================================
Files 53 53
Lines 5034 5067 +33
=============================================
+ Hits 3633 3704 +71
+ Misses 1401 1363 -38
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Covers the non-array default branch in CMB2_Display_Taxonomy_Multicheck with value_field term_id.
|
Justin's agent here (Claude Code), on behalf of @jtsternberg. @faisalahammad, thank you for working through both review rounds. Justin has decided not to add this to core, and here is why. CMB2's existing hooks can already store term IDs on options-page and term boxes. Those are the only boxes where a taxonomy field stores a value; post, user, and comment boxes set term relationships. We tested two ways against current
Because the hooks already cover it, a new field arg would mean two ways to do the same thing, and core would maintain it for good. #1517 is a single request with no follow-up, and that isn't enough demand to carry that cost. Would you be willing to submit this workaround to the CMB2 Snippet Library? Either approach (ID in place of the slug, or the ID stored alongside it), or both. Justin would be happy to merge it there. The drafts we tested are below as a starting point. They were checked on PHP 8.3 only, and only the One trap we found: use the Draft 1: store the term ID instead of the slug (
|
Description
Adds a
value_fieldfield arg to alltaxonomy_*field types. Set it to'term_id'and the field uses term IDs as the option and checkbox values instead of slugs. The default stays'slug'.Changes:
CMB2_Type_Taxonomy_Basegets three helpers:value_field(),get_term_value()andget_term_by_value(). Only'slug'and'term_id'are supported. Any other value falls back to slugs and logs a debug message once per field. The select, radio and multicheck types and the default display inCMB2_Field_Displayuse them, so the hierarchical and inline variants are covered too.CMB2_Sanitize::taxonomy()resolves each posted value to a term withget_term_by_value()and passes int term IDs towp_set_object_terms(). Values that match no term are dropped. Without this, WordPress reads"14"as a term name and creates a new term called "14". A posted slug still sets its term, and'-1'is not read as term ID 1.example-functions.php.Motivation and Context
On options pages and term boxes the field value is what gets saved. With slugs, renaming a term slug breaks the saved value, and code reading the option has to look the term up by slug every time. The arg name matches
value_fieldinwp_dropdown_categories().Fixes #1517.
Risk Level
Low. It is opt-in and the default path is unchanged. One known limit: if a field switches to
term_idwhile it has a saved slug made of only digits (like2024), that value is read as a term ID. It can select the wrong term or none, and a re-save then stores the wrong term or clears the value. So convert saved slugs to term IDs before you switch an existingoptions-pageortermfield. The changelog has an example:Testing procedure
save_fields()round trips for a post box (ID, slug,'-1'and a nested array) and an options page, the all-digit slug limits, and an unsupportedvalue_fieldfalling back to slugs. The term ID tests fail without the fix. The slug mode test is a guard and passes ondeveloptoo.composer phpcsis clean. CodeRabbit review has no findings.term_idstill shows as selected.value_fieldstill use slugs.Types of changes
Checklist: