Automattic / Automattic/WP-Job-Manager
Job search cannot find listings whose title contains an ampersand
- Dominant language
- PHP
- Stars
- 899
- Forks
- 369
- Avg merge
- 11h 37m
- Merged PRs (30d)
- 12
Description
Third strand of the ampersand investigation behind #3026 and #3027. This one is a *query* bug rather than an output bug, so I kept it out of that PR.
## Problem
WordPress core entity-encodes `post_title` and `post_content` on save for any user without `unfiltered_html` (see #3026 for the full root cause). Our search runs `LIKE` against those columns with the user's raw search term. `R&D` never matches a stored `R&D`.
Because employers and guests are exactly the users who lack `unfiltered_html`, **the listings submitted through the frontend form are the ones that become unfindable**, while an administrator's identical listing is found.
## Evidence
Two listings, same title, different submitters:
```
subscriber post_title stored = [R&D Engineer]
administrator post_title stored = [R&D Engineer]
=== frontend get_job_listings( search_keywords ) ===
search "R&D Engineer" -> 1 hit(s): administrator
search "R&D Engineer" -> 1 hit(s): subscriber
```
A visitor typing `R&D Engineer` into the job search box gets the admin's listing and not the employer's.
## Affected code
- `wp-job-manager-functions.php:471` — `job_manager_construct_post_conditions()`, the frontend `[jobs]` search (hooked on `posts_search` via `get_job_listings_keyword_search()`).
- `includes/admin/class-wp-job-manager-cpt.php:765` — the admin job listings search, a direct `$wpdb` query against `posts.post_title` and `posts.post_content`.
## This is core's behaviour too
Worth knowing before we decide anything. A plain post with the same title, authored by a subscriber:
```
core post stored title = [R&D Engineer]
core WP_Query s="R&D Engineer" -> 0 hit(s)
core WP_Query s="R&D Engineer" -> 1 hit(s)
```
So `WP_Query` has the identical limitation. `job_manager_construct_post_conditions()` is explicitly a re-implementation of `WP_Query::parse_search()` (see its `@see` tag), and fixing this means **deliberately diverging from core search semantics**. That is the call that needs a human, and it is why this is `ready-for-human` rather than `ready-for-agent`.
I think the divergence is justified — a job board's entire value is that listings are findable by title, and `&` is common in real job titles ("Events & Marketing", "R&D", "Health & Safety"). But it should be a conscious decision, not a drive-by.
## Proposed fix
Match against **both** storage forms rather than normalising one of them. Normalising the needle alone would flip the bug, finding employer listings and missing administrator ones.
```php
$needle = $search_term;
$normalized = wp_kses_normalize_entities( $search_term ); // 'R&D' => 'R&D'
```
For a needle containing no `&`, `wp_kses_normalize_entities()` is the identity, so the extra clause can be skipped entirely and the common search path costs nothing.
Two details that are easy to get wrong:
1. **Only the post-column conditions need this.** Post meta is not run through kses on save — I verified `_company_name` stores `Smith & Sons` raw. So `job_manager_construct_secondary_conditions()` (meta and taxonomy `LIKE`s) must keep using the raw needle. Normalising globally would break meta search.
2. **The exclusion path inverts.** `job_manager_construct_post_conditions()` supports `NOT LIKE` for excluded terms. Matching becomes `raw OR normalized`, but excluding must become `raw AND normalized` — otherwise excluding `R&D` still returns the encoded listings.
## Acceptance criteria
- [ ] Frontend search for `R&D Engineer` returns listings authored by both an administrator and a subscriber.
- [ ] Frontend search for `R&D Engineer` still returns the subscriber's listing (no regression for anyone who worked around this).
- [ ] Admin listings search behaves the same on both.
- [ ] Meta search still matches a raw `&` in `_company_name` / `_job_location`.
- [ ] An excluded term (`-R&D`) excludes both storage forms.
- [ ] Regression test builds its fixture by inserting as a subscriber with kses filters active, rather than hand-writing the encoded title.
## Not in scope
Rewriting existing rows to store raw `&`. That diverges from core storage, misses the REST and admin editor paths, and needs a migration. The output-side decoding in #3026 is the supported way to render these titles.
Contributor guide
Research direction
Start in wp-job-manager-functions.php at job_manager_construct_post_conditions() and get_job_listings_keyword_search(), then compare the admin query in includes/admin/class-wp-job-manager-cpt.php. Confirm the core-search divergence decision before changing behavior, and add regression coverage using a subscriber fixture with kses filters active. Done means all listed frontend, admin, meta, and exclusion search cases pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php, wordpress
- Domain
- backend, search, testing-qa
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100