Skip to content

Fix: CLI export silently drops _-prefixed options from wp_options (WordPress 6.4+)#157

Open
Striffly wants to merge 1 commit intodeliciousbrains:masterfrom
Striffly:master
Open

Fix: CLI export silently drops _-prefixed options from wp_options (WordPress 6.4+)#157
Striffly wants to merge 1 commit intodeliciousbrains:masterfrom
Striffly:master

Conversation

@Striffly
Copy link
Copy Markdown

Problem

When exporting via CLI (wp migratedb export), all wp_options rows with option_name starting with _ (underscore) are silently excluded from the dump. This does not occur when exporting via the admin UI.

Exemple of affected rows:

  • ACF field reference entries (_options_*) — breaks get_field() on options pages
  • WordPress core entries (_wp_suggested_policy_text_has_changed)

Root Cause

WP Migrate DB paginates results using the table's PRIMARY KEY:

-- First chunk: no key filter
SELECT * FROM wp_options ORDER BY option_name LIMIT 100

-- Subsequent chunks: resume after last seen value
SELECT * FROM wp_options WHERE option_name > 'last_value' ORDER BY option_name LIMIT 100

The Table::$first_select flag controls whether the WHERE primary_key > value clause is added:

  • null → first query for a table, no clause added
  • false → subsequent queries, clause added with the last seen key value

In UI mode (AJAX), each HTTP request instantiates a new Table object, so $first_select starts at null for each table. This works correctly.

In CLI mode, a single PHP process handles all tables sequentially. The Table object persists across tables. When get_structure_info() is called for a new table, it resets $this->primary_keys to ['column' => 0] but does not reset $this->first_select. The flag remains false from the previous table.

This causes the first query of every table (except the very first one processed) to include:

WHERE option_name > '0'

...where '0' is the placeholder initialization value.

Why This Only Manifests on WordPress 6.4+

WordPress 6.4 changed the wp_options schema:

-- Before 6.4
PRIMARY KEY (option_id)    -- bigint auto-increment

-- After 6.4
PRIMARY KEY (option_name)  -- varchar(191)
  • Integer PKs (wp_posts.ID, wp_postmeta.meta_id): WHERE ID > '0' is cast to ID > 0, which is TRUE for all auto-increment values ≥ 1. No data loss.
  • Varchar PK (wp_options.option_name): WHERE option_name > '0' uses string comparison under utf8mb4_unicode_520_ci collation. In this collation, _ (underscore, U+005F) sorts before 0 (U+0030). So _options_xxx > '0' evaluates to FALSE, and all underscore-prefixed rows are excluded.

Impact

In a test database with 608 non-transient options:

  • UI export: 608 rows exported ✅
  • CLI export: 462 rows exported ❌ (146 rows missing — all starting with _)

This directly breaks any functionality relying on _-prefixed option entries, most notably ACF options pages where get_field('field_name', 'options') returns empty/raw values because the field reference entries (_options_field_name → field_XXXXX) are missing.

Fix

One line added in Table::get_structure_info():

$this->primary_keys = array();
$this->first_select = null;  // Reset for new table in CLI mode
$use_primary_keys   = true;

This ensures $first_select is reset alongside $primary_keys when processing a new table, so the first SELECT for each table does not include a stale compound key clause.

How to Reproduce

  1. Use WordPress 6.4+ (where wp_options PRIMARY KEY is option_name)
  2. Have ACF Pro with an options page containing fields
  3. Export via CLI: wp migratedb export /tmp/dump.sql
  4. Check the dump: grep "_options_" /tmp/dump.sql0 matches
  5. Export via UI (WP admin → WP Migrate → Export) → grep "_options_"matches found

How to Verify the Fix

# Before fix
wp migratedb export /tmp/before.sql
grep -c "_options_" /tmp/before.sql  # 0

# After fix (add $this->first_select = null; in get_structure_info)
wp migratedb export /tmp/after.sql
grep -c "_options_" /tmp/after.sql   # > 0 (matches UI export count)

Table::$first_select was not reset in get_structure_info() when
processing a new table. In CLI mode (single PHP process), the flag
carried over from the previous table, causing the first SELECT query
to include a stale WHERE primary_key > '0' clause.

This had no visible effect on tables with integer primary keys (e.g.
wp_posts.ID) since ID > '0' evaluates to ID > 0 which is always true
for auto-increment values.

However, WordPress 6.4 changed wp_options PRIMARY KEY from option_id
(bigint) to option_name (varchar). With utf8mb4_unicode_520_ci
collation, underscore '_' sorts before '0', so the clause
option_name > '0' silently excluded all '_'-prefixed options
(_options_*, _site_transient_*, _squidge_*, etc.).

This broke ACF get_field() for options pages after a CLI export/pull,
because ACF reference entries (e.g. _options_cookies_categories)
were missing from the exported dump.

The fix resets $this->first_select = null alongside the existing
$this->primary_keys reset in get_structure_info(), ensuring each
table's first query starts without a compound key WHERE clause.
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.

1 participant