Skip to content

refactor: reduce PHPMD complexity in the documents CPT - #239

Merged
erseco merged 11 commits into
mainfrom
refactor/reduce-complexity-documents
Jul 29, 2026
Merged

refactor: reduce PHPMD complexity in the documents CPT#239
erseco merged 11 commits into
mainfrom
refactor/reduce-complexity-documents

Conversation

@erseco

@erseco erseco commented Jul 29, 2026

Copy link
Copy Markdown
Member

Third round on the PHPMD Code Size alerts, this time in class-documentate-documents.php — the largest remaining source after #234.

Result

Rule main This branch
NPathComplexity 29 20
CyclomaticComplexity 26 18
ExcessiveMethodLength 7 6
Class-level rules 14 14
All alerts 76 58

In class-documentate-documents.php specifically: 28 alerts down to 10.

Method main Now
filter_post_data_compose_content() NPath 172,540,368 · CC 41 resolved
save_dynamic_fields_meta() NPath 113,337 · CC 22 resolved
get_raw_schema_for_post() NPath 43,920 · CC 27 resolved
render_array_field_item() NPath 3,407,880 · CC 44 · 259 lines NPath 6,152 · CC 15
get_structured_field_values() NPath 2,952 · CC 18 resolved
apply_admin_filters() NPath 2,700 · CC 16 resolved

What the refactors found

The recurring shape in this file is the same block copied per type, format or taxonomy. Parameterising those removes the complexity and the risk that the copies drift apart:

  • add_admin_filters() built three near-identical <select> dropdowns by hand — now one render_admin_filter_select().
  • apply_admin_filters() registered two posts_clauses closures differing only in taxonomy and SQL alias — now one parameterised sort_by_term_name(). The taxonomy reaches the join through $wpdb->prepare() instead of being interpolated.
  • get_raw_schema_for_post() derived "slug, or name as a fallback" three times — now schema_entry_slug().
  • save_dynamic_fields_meta() spelled out "empty value deletes, otherwise update" three times — now write_or_delete_meta().
  • compose_array_field() and the meta save path both encoded repeater rows with a specific set of JSON flags, with a comment asking the two to stay in sync. They now share encode_array_field_items(), so that is enforced by construction.
  • The three repeater controls each carried the same help-text block: leading text, description, validation message, the ids tying them to the control, and the two trailing paragraphs. Six binary decisions multiplied into whatever else the method did — a factor of 64, three times over. Now build_field_help_context(), apply_help_attributes() and render_help_descriptions().

That last one is worth stating beyond the metric: the id in aria-describedby and the paragraph it points at were produced in two separate places, in triplicate. A control that renders one without the other is inaccessible without looking broken.

Two dead assignments were removed along the way: $dynamic_schema in the compose filter, assigned twice and never read.

Two bugs this PR introduced, and how they were caught

Splitting render_array_field_item() by control type dropped two variables that came from the enclosing scope, and both failed silently:

  • render_array_item_single() read $definition['data_type'], where $definition was the foreach loop variable. The read is wrapped in isset(), so PHP raised nothing — repeater columns declared date, number or boolean quietly rendered as plain text inputs.
  • render_array_item_rich() read $is_template, a parameter of the caller. The documentate-array-rich-template class was never applied, so the front-end could no longer tell the clone template from a live row.

Neither PHPCS, nor the 1840 tests, nor the E2E suite detected them. DocumentateArrayFieldRenderTest now asserts what these variables control, and was verified against the broken code — 4 of its 8 tests fail there.

Patch coverage then showed render_array_item_textarea() was uncovered in full: the third branch of the split that no test reached. Eleven more tests cover it and the help-text branches across all three control types, including that the description is actually wired to its control via aria-describedby.

A pre-existing failure fixed first

make test failed on main before any of this. #236 stopped committing admin/vendor/libreoffice-converter, generating it from node_modules instead, which silently broke a test added in #234: it needs assets_available() to be true, so on a checkout that has not run npm install the metabox renders disabled buttons and the assertion fails. CI never showed it, because lint_and_test runs npm ci before PHPUnit.

The test now states that precondition and skips with an actionable message, so make test is self-contained again.

The class-level trade-off, stated plainly

Extracting methods within a class resolves method alerts and worsens the class ones:

Class metric main Now
Lines 3183 3603
Methods 63 96
Class complexity 535 537

That is the cost of this approach. Class complexity is roughly flat — the deduplication above removed real complexity rather than relocating it — but the class is larger and has more methods, so its four class-level alerts stay open.

Closing those means moving repeater rendering out of Documentate_Documents into its own class, the pattern used for Documents_Input_Attributes in #234. That is an architectural change with a different risk profile, so it belongs in its own PR rather than being bolted onto this one.

Remaining alerts in this file

Method Alert
render_sections_metabox() CC 37 · NPath 906,854,404 · 184 lines
render_array_field_item() CC 15 · NPath 6,152
render_array_field() NPath 576
the class itself 4 class-level alerts

Verification

make lint            ✓ PHPCS/WPCS clean
make check-plugin    ✓ No errors found
make test            ✓ 1859 tests, 13171 assertions
make test-generation ✓ 400 tests, 9687 assertions
make test-e2e        ✓ green

Coverage: project 84.05% → 85.54%, patch 88.11%.

The generation suite matters here: filter_post_data_compose_content() writes the post_content that document rendering reads back, so it exercises the change end to end rather than just at the unit level.

erseco added 5 commits July 29, 2026 09:03
#236 stopped committing admin/vendor/libreoffice-converter, generating it
from node_modules instead. That silently broke a test added in #234:
test_browser_wasm_engine_enables_popup_conversion needs
assets_available() to be true, so on a checkout that has not run
`npm install` the metabox renders the disabled buttons and the assertion
fails. `make test` stopped being self-contained.

CI never showed it, because lint_and_test runs npm ci before PHPUnit and
the postinstall hook regenerates the assets.

State the precondition instead of assuming it: the test skips with an
actionable message when the glue is absent, and still runs in CI and on
any machine that has installed the npm dependencies.

Verified both ways: 1 skipped without the assets, 16 passing with them.
Four methods in Documentate_Documents carried straightforward sequences
of conditionals that multiply into large path counts.

enforce_locked_doc_type() (NPath 576) inlined both branches of the lock:
recording the first assignment and detecting drift from it. Each is now a
named method, and doc_type_drifted_from_lock() collapses the
is_wp_error / empty / count triple into one predicate.

sanitize_array_field_items() (complexity 15, NPath 1300) filtered a
repeater row and then decided whether it held anything, in one body.
Split into sanitize_array_item() and array_item_has_content(). The
trailing empty-check disappears: array_values(array_slice(array(), ...))
is already an empty array.

save_meta_boxes() (complexity 15, NPath 540) chained four preconditions
before handling the type selection. The preconditions move into
can_save_meta_boxes() and the selection into save_doc_type_selection(),
which keeps its own nonce check so the sniff can still see it.

add_admin_filters() (complexity 15, NPath 1080) built three near-identical
dropdowns by hand. They now share render_admin_filter_select(), fed by
render_author_filter() and a parameterised render_term_filter(); the
category cap moves into that call's arguments.

Clears 8 method-level PHPMD alerts, 24 down to 16 in this file.
apply_admin_filters() (complexity 16, NPath 2700) mixed three concerns:
deciding whether the query is ours, hiding archived documents, and
registering a posts_clauses closure per sortable taxonomy. The two
closures were near-identical, differing only in taxonomy and SQL alias.
They collapse into one parameterised sort_by_term_name(), driven by a
small map, and the guards become is_documents_list_query() and
hide_archived_by_default().

The taxonomy now reaches the join as a placeholder through $wpdb->prepare()
rather than being interpolated. The alias is still interpolated - it comes
from the hardcoded map, never from a request - so the sniff is disabled
just around that statement with the reason stated.

get_structured_field_values() (complexity 18, NPath 2952) nested the whole
meta fallback, including the repeater special case with its two legacy
meta keys, inside the parse-content path. The fallback becomes
build_field_values_from_meta() and the repeater lookup
read_array_field_from_meta(), which returns the JSON payload or an empty
string instead of assigning through a flag.

Clears 4 more method-level alerts: 16 down to 13 in this file.
get_raw_schema_for_post() (complexity 27, NPath 43920) repeated the same
"slug, or name as a fallback" derivation three times and nested the
repeater field loop inside the repeater loop. That derivation becomes
schema_entry_slug(), and the two indexes become index_schema_entries()
and index_schema_repeaters(), the latter reusing the former for its own
fields. The term lookup moves to get_assigned_doc_type_id().

save_dynamic_fields_meta() (complexity 22, NPath 113337) handled schema
fields, repeaters and unknown posted keys in one body, and spelled out
"empty value deletes, otherwise update" three separate times. That
becomes write_or_delete_meta(); the per-field work becomes
save_schema_field(), the JSON encoding encode_array_field_items() (which
now returns an empty string for no rows, so the delete branch falls out of
the same helper), and the trailing loop save_unknown_field_meta().

The double wp_unslash() on unknown values is preserved: the request body
is already unslashed, but removing the second call would change what is
stored for values containing backslashes, which is not this change's
business.

Clears 4 more method-level alerts: 13 down to 9 in this file. Verified
with the full suite and the generation suite, which exercises the saved
values end to end.
filter_post_data_compose_content() (complexity 41, NPath 172540368) built
the stored post_content from three different sources in one body: the
fields the schema declares, values already stored that the schema no
longer declares, and anything else posted under the field prefix.

Each source becomes its own method - compose_schema_fields(),
compose_carried_over_fields(), compose_posted_fields() - with the
repeater case in compose_array_field() and the serialisation in
build_structured_content(). The early "nothing to store" return
disappears: imploding no fragments already yields an empty string.

compose_array_field() now encodes through encode_array_field_items(),
the helper the meta save path uses, so the comment claiming both use the
same JSON flags is enforced by construction rather than by two copies
staying in sync.

Also drops $dynamic_schema, assigned twice and never read.

Clears 2 more method-level alerts: 9 down to 7 in this file. Verified with
the full suite and the generation suite, which renders documents from the
post_content this method writes.
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.11475% with 58 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
.../custom-post-types/class-documentate-documents.php 88.11% 58 Missing ⚠️

📢 Thoughts on this report? Let us know!

Codecov flagged the previous commits: 78.20% of the diff hit against an
84.05% target. The refactors created many small methods whose guard
branches nothing exercised, so the extraction was unverified exactly
where it was easiest to get wrong.

Two focused suites, 32 tests:

DocumentateDocumentsHelpersTest pins the pure helpers - how a schema
entry derives its slug and what happens when it has neither slug nor
name, which entries the indexes drop, how repeater rows are judged empty
(markup stripped for rich values), the JSON flags used for storage, and
that the list filters escape their output and render nothing when there
is nothing to choose from.

DocumentateDocumentsQueryTest covers the query side by applying the
posts_clauses filter directly rather than driving a real admin request:
the join, the ASC/DESC handling, and that an unrelated ordering is left
untouched. It also covers the archived-status default, the three meta
shapes read_array_field_from_meta() understands including the historical
documentate_annexes key, and the precedence rules in the carried-over and
posted composition passes.

One test asserted sanitize_key('Solo Nombre') === 'solo_nombre'. It does
not - characters outside a-z0-9_- are dropped, so spaces collapse. The
expectation was wrong, not the code; corrected and commented.

Diff coverage for this file goes from 79.6% to 91.6%.
render_array_field_item() was 259 lines with a cyclomatic complexity of
44 and an NPath of 3407880: a per-field loop whose body branched into
single, rich and textarea rendering, 80, 73 and 39 lines respectively.

Each branch becomes its own method. The bodies are copied verbatim - only
dedented - and take the loop variables under their existing names, so
nothing inside them was retyped. Rendering has no unit coverage, only E2E,
so a mechanical move was worth more here than a tidier signature.

render_array_field_item() drops to complexity 15 and loses the length
alert. The extracted methods carry NPath of their own (single 1024, rich
512), addressed next.

Verified with the CPT unit tests and the document-fields E2E spec, which
exercises adding, removing and persisting repeater rows.
Comment thread includes/custom-post-types/class-documentate-documents.php Fixed
Comment thread includes/custom-post-types/class-documentate-documents.php Fixed
erseco added 2 commits July 29, 2026 13:00
Splitting render_array_field_item() by control type left two variables
behind that used to come from the enclosing scope. Both failed silently.

render_array_item_single() reads $definition['data_type'], where
$definition was the `foreach ( $item_schema as $key => $definition )` loop
variable. It was never a parameter, and the read is wrapped in isset(), so
PHP raised nothing and $raw_data_type became permanently ''. That disables
the data_type fallback in map_single_input_type(): a repeater column
declared date, number or boolean rendered as a plain text input.

render_array_item_rich() reads $is_template, a parameter of the caller
marking the hidden row the front-end clones. Not being a parameter either,
PHP 8 warned and the documentate-array-rich-template class was never
applied, so the JS could no longer tell the template row from a live one.

Both are now passed explicitly.

The extraction was checked before landing, but the check only looked for
variables assigned with `=` in the loop preamble. $definition enters scope
through `foreach ... as`, and $is_template is a method parameter, so
neither matched the pattern and both were reported as safe. The check was
wrong, not the conclusion it produced.

Adds DocumentateArrayFieldRenderTest, which renders a repeater row and
asserts what these variables control: the input type produced by each
data_type, and that the template marker class appears on the template row
and only there. Verified against the broken code - 4 of its 8 tests fail
there.
Comment thread includes/custom-post-types/class-documentate-documents.php Fixed
Comment thread includes/custom-post-types/class-documentate-documents.php Fixed
Code scanning flagged the two methods this PR introduced:
render_array_item_single() at NPath 1024 and render_array_item_rich() at
512, both over the 500 threshold. A PR whose purpose is lowering these
should not add new ones.

The cause was duplication rather than any one method being convoluted. All
three controls carried the same block: collect the leading text, the
description and the validation message, derive their ids, point the control
at them through aria-describedby, hand the message to the JS validator, and
finally echo the two paragraphs. Six binary decisions, multiplied into
whatever else the method did - a factor of 64 each, three times over.

Extracted into build_field_help_context(), apply_help_attributes() and
render_help_descriptions(). Single drops to 16, rich to 8, and textarea
loses the same weight. Overall class complexity drops 546 to 537, so this
removes complexity rather than relocating it - though the class grows by
three methods and 20 lines, which nudges the class-level alerts the wrong
way. That tension only resolves by moving work out of this class.

The accessibility wiring is what makes this worth sharing: the id in
aria-describedby and the paragraph it points at are produced in two
separate places, and a control that renders one without the other is
inaccessible without looking broken. One definition now, not three.

Covered by the tests added in the previous commit, which assert exactly
these branches across all three control types.
@erseco
erseco marked this pull request as ready for review July 29, 2026 14:05
@erseco
erseco merged commit 46f5398 into main Jul 29, 2026
12 checks passed
@erseco
erseco deleted the refactor/reduce-complexity-documents branch July 29, 2026 17:50
erseco added a commit that referenced this pull request Jul 29, 2026
Clears the last two method-level alerts in the documents CPT:
render_array_field_item() at NPath 6,152 with cyclomatic complexity 15, and
render_array_field() at NPath 576. The file now has none.

Both came from work the caller had already done.

render_array_field() re-resolved the repeater's title and hover text from
the raw definition, but render_repeater_field_row() resolves exactly the
same values before calling it - it has to, to draw the surrounding table
row. The label parameter was being handed in and then overwritten with an
identical value. It now takes the hover text alongside the label and trusts
both. (get_field_title() sanitizes before returning, so dropping the second
sanitize_text_field() over the same string changes nothing.)

render_array_field_item() spelled out, per column, the same eight lines
prepare_schema_row() already spells out per schema row: read the label or
fall back, read the type, look up the raw field, let a declared title win,
prefer the pattern message for hover text. Both now call
prepare_field_control(), with prepare_array_item_field() adding what only a
repeater column needs - submitted name, DOM id, stored value, and the item
schema entry the single control reads its data_type from. The three-way
type dispatch moved to render_array_item_control().

Markup verified identical to the previous commit, byte for byte, by
rendering a schema with two repeater rows plus the clone template, four
column types, titles, descriptions and validation messages, and diffing
against the pre-refactor code.

DocumentateSchemaRowTest gains six cases for the column preparation,
including that the item schema entry survives the trip - that variable
going missing was one of the two bugs in #239.
erseco added a commit that referenced this pull request Jul 29, 2026
Clears the last two method-level alerts in the documents CPT:
render_array_field_item() at NPath 6,152 with cyclomatic complexity 15, and
render_array_field() at NPath 576. The file now has none.

Both came from work the caller had already done.

render_array_field() re-resolved the repeater's title and hover text from
the raw definition, but render_repeater_field_row() resolves exactly the
same values before calling it - it has to, to draw the surrounding table
row. The label parameter was being handed in and then overwritten with an
identical value. It now takes the hover text alongside the label and trusts
both. (get_field_title() sanitizes before returning, so dropping the second
sanitize_text_field() over the same string changes nothing.)

render_array_field_item() spelled out, per column, the same eight lines
prepare_schema_row() already spells out per schema row: read the label or
fall back, read the type, look up the raw field, let a declared title win,
prefer the pattern message for hover text. Both now call
prepare_field_control(), with prepare_array_item_field() adding what only a
repeater column needs - submitted name, DOM id, stored value, and the item
schema entry the single control reads its data_type from. The three-way
type dispatch moved to render_array_item_control().

Markup verified identical to the previous commit, byte for byte, by
rendering a schema with two repeater rows plus the clone template, four
column types, titles, descriptions and validation messages, and diffing
against the pre-refactor code.

DocumentateSchemaRowTest gains six cases for the column preparation,
including that the item schema entry survives the trip - that variable
going missing was one of the two bugs in #239.
erseco added a commit that referenced this pull request Jul 29, 2026
Clears the last two method-level alerts in the documents CPT:
render_array_field_item() at NPath 6,152 with cyclomatic complexity 15, and
render_array_field() at NPath 576. The file now has none.

Both came from work the caller had already done.

render_array_field() re-resolved the repeater's title and hover text from
the raw definition, but render_repeater_field_row() resolves exactly the
same values before calling it - it has to, to draw the surrounding table
row. The label parameter was being handed in and then overwritten with an
identical value. It now takes the hover text alongside the label and trusts
both. (get_field_title() sanitizes before returning, so dropping the second
sanitize_text_field() over the same string changes nothing.)

render_array_field_item() spelled out, per column, the same eight lines
prepare_schema_row() already spells out per schema row: read the label or
fall back, read the type, look up the raw field, let a declared title win,
prefer the pattern message for hover text. Both now call
prepare_field_control(), with prepare_array_item_field() adding what only a
repeater column needs - submitted name, DOM id, stored value, and the item
schema entry the single control reads its data_type from. The three-way
type dispatch moved to render_array_item_control().

Markup verified identical to the previous commit, byte for byte, by
rendering a schema with two repeater rows plus the clone template, four
column types, titles, descriptions and validation messages, and diffing
against the pre-refactor code.

DocumentateSchemaRowTest gains six cases for the column preparation,
including that the item schema entry survives the trip - that variable
going missing was one of the two bugs in #239.
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