PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / mlsimport-multimls-migration.php

mlsimport-multimls-migration.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2.1, at includes/mlsimport-multimls-migration.php

292 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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