Automattic / Automattic/WP-Job-Manager

Job search cannot find listings whose title contains an ampersand

Open
#3,030 0 comments 0 reactions 0 assignees View on GitHub
[Status] Accepted Bug ready-for-human
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.