| 1 |
<?php |
| 2 |
/** |
| 3 |
* Single-MLS -> multi-MLS migration (issue #274, decisions #270 / #263 / #266). |
| 4 |
* |
| 5 |
* WHY THIS FILE EXISTS |
| 6 |
* -------------------- |
| 7 |
* An existing single-MLS install must silently become a one-connection |
| 8 |
* multi-MLS install on plugin update — and be able to roll the plugin back |
| 9 |
* with zero loss. The rules (decision #270): |
| 10 |
* |
| 11 |
* COPY, NEVER MOVE. Flat globals are copied into the priority-1 connection |
| 12 |
* record and into "_{mls_id}"-suffixed options. The originals are left |
| 13 |
* untouched, so a downgraded plugin reads them exactly as before. No |
| 14 |
* reverse migration exists or is needed. |
| 15 |
* |
| 16 |
* PURE-LOCAL. No SaaS calls — migration works offline and never fails on |
| 17 |
* network state. |
| 18 |
* |
| 19 |
* SET-BASED IDEMPOTENT STAMPING. Existing listing posts, import tasks and |
| 20 |
* standalone rows get their provenance (which MLS they came from) via three |
| 21 |
* SQL statements, each scoped to not-yet-stamped rows, so a crashed run |
| 22 |
* resumes safely on the next load. |
| 23 |
* |
| 24 |
* LOAD-TIME GATE. The 'mlsimport_multimls_migrated' flag is compared on |
| 25 |
* every load (same pattern as mlsimport_installed_version) and written only |
| 26 |
* after EVERY step succeeds; until then each load retries and the per-step |
| 27 |
* "already done" guards make retries no-ops. |
| 28 |
* |
| 29 |
* The option mapping itself is a PURE function (flat state in => connection |
| 30 |
* record + option copies out) so it is unit-testable without WordPress. |
| 31 |
* |
| 32 |
* @since 7.2.0 |
| 33 |
* @package Mlsimport |
| 34 |
*/ |
| 35 |
|
| 36 |
if ( ! defined( 'ABSPATH' ) ) { |
| 37 |
exit; |
| 38 |
} |
| 39 |
|
| 40 |
/** |
| 41 |
* Translate one slot-named credential option key into its adapter-generic name. |
| 42 |
* |
| 43 |
* Flat credentials are provider-slot-named ('mlsimport_tresle_client_id'), so |
| 44 |
* two same-provider MLSs collide. Connection records store them under generic |
| 45 |
* names instead (decision #263: creds => {client_id, client_secret, ...}). |
| 46 |
* Every credential key the plugin has ever stored ends in exactly one of the |
| 47 |
* generic names below, so the suffix IS the generic name. |
| 48 |
* |
| 49 |
* @param string $field Slot-named option key (e.g. 'mlsimport_rapattoni_username'). |
| 50 |
* @return string Generic credential name, or '' when the key is not a credential. |
| 51 |
*/ |
| 52 |
function mlsimport_multimls_generic_credential_name( string $field ): string { |
| 53 |
// Longest suffixes first so 'client_secret' never half-matches as 'secret'. |
| 54 |
foreach ( array( 'client_secret', 'client_id', 'mls_token', 'username', 'password' ) as $generic ) { |
| 55 |
if ( substr( $field, -strlen( $generic ) ) === $generic ) { |
| 56 |
return $generic; |
| 57 |
} |
| 58 |
} |
| 59 |
return ''; |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* PURE option mapping: flat single-MLS state in => migration plan out. |
| 64 |
* |
| 65 |
* Step by step: |
| 66 |
* 1. Read the MLS id from the flat admin options ('mlsimport_mls_name' holds |
| 67 |
* the numeric id). Empty/non-numeric => unconfigured install: the plan is |
| 68 |
* a null connection with nothing to copy (clean zero-connections state). |
| 69 |
* 2. Resolve the provider adapter from the saved type (numeric-range fallback |
| 70 |
* inside the Provider Family module covers legacy installs with no type). |
| 71 |
* 3. Copy the adapter's declared credential fields out of the flat options |
| 72 |
* into generic names — values verbatim, credentials are never sanitized. |
| 73 |
* 4. Assemble the priority-1 connection record (status from the connection |
| 74 |
* test flag, live config as stored). |
| 75 |
* 5. List the option copies: field selection, sync settings and the three |
| 76 |
* metadata blobs become "_{mls_id}"-suffixed copies. The theme schema |
| 77 |
* stays global (decision #264) and is deliberately absent here. |
| 78 |
* |
| 79 |
* @param array $flat { |
| 80 |
* Flat single-MLS state, read by the caller. |
| 81 |
* |
| 82 |
* @type array $admin_options The mlsimport_admin_options array. |
| 83 |
* @type string $provider_type Saved provider type for this MLS ('' when none). |
| 84 |
* @type string $connection_test The mlsimport_connection_test flag ('yes' or ''). |
| 85 |
* @type array $live_config The mlsimport_live_mls_config array. |
| 86 |
* } |
| 87 |
* @return array { |
| 88 |
* @type array|null $connection Priority-1 record, or null when unconfigured. |
| 89 |
* @type array $copy_options Source option name => suffixed destination name. |
| 90 |
* } |
| 91 |
*/ |
| 92 |
function mlsimport_multimls_migration_plan( array $flat ): array { |
| 93 |
$options = isset( $flat['admin_options'] ) && is_array( $flat['admin_options'] ) |
| 94 |
? $flat['admin_options'] |
| 95 |
: array(); |
| 96 |
|
| 97 |
// Step 1: the MLS id is the whole trigger — no id means nothing to migrate. |
| 98 |
$mls_id = (int) trim( (string) ( $options['mlsimport_mls_name'] ?? '' ) ); |
| 99 |
if ( $mls_id <= 0 ) { |
| 100 |
return array( |
| 101 |
'connection' => null, |
| 102 |
'copy_options' => array(), |
| 103 |
); |
| 104 |
} |
| 105 |
|
| 106 |
// Step 2: resolve the adapter; its type() is the authoritative stored type. |
| 107 |
$adapter = Mlsimport_Provider_Family::adapter( (string) ( $flat['provider_type'] ?? '' ), $mls_id ); |
| 108 |
|
| 109 |
// Step 3: slot-named flat credentials => generic-named record credentials. |
| 110 |
$creds = array(); |
| 111 |
foreach ( $adapter->credential_fields() as $field ) { |
| 112 |
$generic = mlsimport_multimls_generic_credential_name( (string) $field ); |
| 113 |
if ( '' !== $generic ) { |
| 114 |
// Credential values are stored verbatim (trim only) — see #204. |
| 115 |
$creds[ $generic ] = trim( (string) ( $options[ $field ] ?? '' ) ); |
| 116 |
} |
| 117 |
} |
| 118 |
|
| 119 |
// Step 4: the migrated install's single connection is priority 1. |
| 120 |
$connection = array( |
| 121 |
'mls_id' => $mls_id, |
| 122 |
'mls_name' => (string) ( $options['mlsimport_mls_name_front'] ?? '' ), |
| 123 |
'provider_type' => $adapter->type(), |
| 124 |
'creds' => $creds, |
| 125 |
'status' => 'yes' === ( $flat['connection_test'] ?? '' ) ? 'yes' : '', |
| 126 |
'live_config' => isset( $flat['live_config'] ) && is_array( $flat['live_config'] ) ? $flat['live_config'] : array(), |
| 127 |
'priority' => 1, |
| 128 |
); |
| 129 |
|
| 130 |
// Step 5: the large per-MLS state becomes suffixed copies (COPIES — the |
| 131 |
// flat sources stay untouched so downgrade keeps working). |
| 132 |
$copy_options = array(); |
| 133 |
foreach ( array( |
| 134 |
'mlsimport_admin_fields_select', |
| 135 |
'mlsimport_admin_mls_sync', |
| 136 |
'mlsimport_mls_metadata_mls_data', |
| 137 |
'mlsimport_mls_metadata_mls_enums', |
| 138 |
'mlsimport_mls_metadata_populated', |
| 139 |
) as $base ) { |
| 140 |
$copy_options[ $base ] = $base . '_' . $mls_id; |
| 141 |
} |
| 142 |
|
| 143 |
return array( |
| 144 |
'connection' => $connection, |
| 145 |
'copy_options' => $copy_options, |
| 146 |
); |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Run the one-time single-MLS -> multi-MLS migration (gated, resumable). |
| 151 |
* |
| 152 |
* Step by step: |
| 153 |
* 1. GATE: bail immediately when the migration flag says the run completed. |
| 154 |
* 2. Gather the flat state and build the pure plan above. |
| 155 |
* 3. Unconfigured install: write an empty (non-autoloaded) connections |
| 156 |
* option, set the flag, done — clean zero-connections state. |
| 157 |
* 4. Register the priority-1 connection (skipped when a retry already did). |
| 158 |
* 5. Copy each planned option to its suffixed name (skipped per-option when |
| 159 |
* a retry already created the copy; the flat source is never touched). |
| 160 |
* 6. Ensure the standalone table schema is current (the mls_id column and |
| 161 |
* composite unique index arrive via the schema-version upgrade path). |
| 162 |
* 7. Stamp provenance with three set-based idempotent statements, each |
| 163 |
* scoped to rows not yet stamped: |
| 164 |
* a. listing posts (identified by '_mlsimport_listing_key') get |
| 165 |
* 'mlsimport_mls_id', |
| 166 |
* b. import tasks (post_type mlsimport_item) get 'mlsimport_item_mls_id', |
| 167 |
* c. standalone rows still at mls_id = 0 get the connection's id. |
| 168 |
* 8. Write the flag ONLY now — any earlier failure leaves it unset so the |
| 169 |
* next load retries, and steps 4-7 are all no-ops for finished work. |
| 170 |
* |
| 171 |
* @return bool True when the migration is complete (now or previously). |
| 172 |
*/ |
| 173 |
function mlsimport_multimls_migrate(): bool { |
| 174 |
global $wpdb; |
| 175 |
|
| 176 |
// Step 1: the completed flag makes every later call free. |
| 177 |
if ( get_option( 'mlsimport_multimls_migrated' ) ) { |
| 178 |
return true; |
| 179 |
} |
| 180 |
|
| 181 |
// Step 2: flat state in, pure plan out. |
| 182 |
$admin_options = get_option( 'mlsimport_admin_options', array() ); |
| 183 |
$mls_id_raw = is_array( $admin_options ) ? (string) ( $admin_options['mlsimport_mls_name'] ?? '' ) : ''; |
| 184 |
$plan = mlsimport_multimls_migration_plan( |
| 185 |
array( |
| 186 |
'admin_options' => is_array( $admin_options ) ? $admin_options : array(), |
| 187 |
'provider_type' => Mlsimport_Provider_Family::saved_type( $mls_id_raw ), |
| 188 |
'connection_test' => (string) get_option( 'mlsimport_connection_test', '' ), |
| 189 |
'live_config' => (array) get_option( 'mlsimport_live_mls_config', array() ), |
| 190 |
) |
| 191 |
); |
| 192 |
|
| 193 |
// Step 3: nothing configured => clean zero-connections state and done. |
| 194 |
if ( null === $plan['connection'] ) { |
| 195 |
if ( false === get_option( Mlsimport_Connections::OPTION, false ) ) { |
| 196 |
add_option( Mlsimport_Connections::OPTION, array(), '', 'no' ); |
| 197 |
} |
| 198 |
update_option( 'mlsimport_multimls_migrated', MLSIMPORT_VERSION ); |
| 199 |
return true; |
| 200 |
} |
| 201 |
|
| 202 |
$mls_id = (int) $plan['connection']['mls_id']; |
| 203 |
|
| 204 |
// Step 4: register the connection once; a retry that already saved it skips. |
| 205 |
if ( null === Mlsimport_Connections::get( $mls_id ) ) { |
| 206 |
if ( ! Mlsimport_Connections::save( $plan['connection'] ) ) { |
| 207 |
return false; |
| 208 |
} |
| 209 |
} |
| 210 |
|
| 211 |
// Step 5: copy the large per-MLS state. Copies are non-autoloaded (read on |
| 212 |
// demand only); each copy is skipped when a prior retry already made it. |
| 213 |
foreach ( $plan['copy_options'] as $source => $destination ) { |
| 214 |
$value = get_option( $source, false ); |
| 215 |
if ( false === $value || false !== get_option( $destination, false ) ) { |
| 216 |
continue; |
| 217 |
} |
| 218 |
add_option( $destination, $value, '', 'no' ); |
| 219 |
} |
| 220 |
|
| 221 |
// Step 6: the mls_id column + composite unique index come from the |
| 222 |
// standalone table's own versioned upgrade path (idempotent). |
| 223 |
Mlsimport_Standalone_Table::maybe_upgrade(); |
| 224 |
|
| 225 |
// Step 7a: stamp listing posts. DISTINCT guards against a post carrying |
| 226 |
// duplicate identity rows; the LEFT JOIN scopes to not-yet-stamped posts. |
| 227 |
// Direct SQL is intentional: per-post meta calls would issue tens of |
| 228 |
// thousands of statements on large sites (same as the #286 migration). |
| 229 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 230 |
$stamped_listings = $wpdb->query( |
| 231 |
$wpdb->prepare( |
| 232 |
"INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) |
| 233 |
SELECT DISTINCT identity.post_id, 'mlsimport_mls_id', %d |
| 234 |
FROM {$wpdb->postmeta} identity |
| 235 |
LEFT JOIN {$wpdb->postmeta} stamped |
| 236 |
ON stamped.post_id = identity.post_id |
| 237 |
AND stamped.meta_key = 'mlsimport_mls_id' |
| 238 |
WHERE identity.meta_key = '_mlsimport_listing_key' |
| 239 |
AND stamped.meta_id IS NULL", |
| 240 |
$mls_id |
| 241 |
) |
| 242 |
); |
| 243 |
if ( false === $stamped_listings ) { |
| 244 |
return false; |
| 245 |
} |
| 246 |
|
| 247 |
// Step 7b: stamp import tasks (every status — trashed tasks can be |
| 248 |
// restored and must keep their binding). |
| 249 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 250 |
$stamped_tasks = $wpdb->query( |
| 251 |
$wpdb->prepare( |
| 252 |
"INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) |
| 253 |
SELECT p.ID, 'mlsimport_item_mls_id', %d |
| 254 |
FROM {$wpdb->posts} p |
| 255 |
LEFT JOIN {$wpdb->postmeta} stamped |
| 256 |
ON stamped.post_id = p.ID |
| 257 |
AND stamped.meta_key = 'mlsimport_item_mls_id' |
| 258 |
WHERE p.post_type = 'mlsimport_item' |
| 259 |
AND stamped.meta_id IS NULL", |
| 260 |
$mls_id |
| 261 |
) |
| 262 |
); |
| 263 |
if ( false === $stamped_tasks ) { |
| 264 |
return false; |
| 265 |
} |
| 266 |
|
| 267 |
// Step 7c: stamp standalone rows still at the DEFAULT 0 backstop. |
| 268 |
$table = Mlsimport_Standalone_Table::table_name(); |
| 269 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared |
| 270 |
$stamped_rows = $wpdb->query( |
| 271 |
$wpdb->prepare( "UPDATE {$table} SET mls_id = %d WHERE mls_id = 0", $mls_id ) |
| 272 |
); |
| 273 |
if ( false === $stamped_rows ) { |
| 274 |
return false; |
| 275 |
} |
| 276 |
|
| 277 |
// The set-based stamps above write postmeta with direct SQL, which never |
| 278 |
// invalidates the object cache — on a persistent-cache site get_post_meta |
| 279 |
// would keep serving the pre-stamp (empty) meta until eviction, so tasks |
| 280 |
// read as unbound. One flush for a one-time migration clears all of it. |
| 281 |
wp_cache_flush(); |
| 282 |
|
| 283 |
// Step 8: every step succeeded — only now does the gate close. |
| 284 |
update_option( 'mlsimport_multimls_migrated', MLSIMPORT_VERSION ); |
| 285 |
return true; |
| 286 |
} |
| 287 |
|
| 288 |
// Priority 6: AFTER the listing-key identity migration (init priority 5, |
| 289 |
// includes/mlsimport-listing-key-migration.php) — stamping finds listing posts |
| 290 |
// by '_mlsimport_listing_key', which that migration creates on old sites. |
| 291 |
add_action( 'init', 'mlsimport_multimls_migrate', 6 ); |
| 292 |
|