PluginProbe
Hash Form – Drag & Drop Form Builder / trunk
Hash Form – Drag & Drop Form Builder vtrunk
1.4.4 1.4.3 1.4.2 1.4.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.6.1 1.2.7 1.2.8 1.2.9 1.3.0 All 47 releases
hash-form / includes / HashFormMigrations.php

HashFormMigrations.php in Hash Form – Drag & Drop Form Builder trunk, at includes/HashFormMigrations.php

458 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 defined('ABSPATH') || die();
4
5 /**
6 * Ordered, resumable schema migrations.
7 *
8 * Before this existed, each schema change carried its own boolean option -
9 * hashform_entries_read_migrated, hashform_options_noautoload,
10 * hashform_transactions_has_status and so on - so there was no order between
11 * them, no record of a partial run, and no single answer to "what shape is
12 * this database in".
13 *
14 * A step here is named, ordered, and idempotent. Each one is recorded the
15 * moment it succeeds, so a run interrupted half way through - a timeout on a
16 * large table is the usual way - resumes at the step it stopped on rather
17 * than starting again.
18 *
19 * This does not replace HashFormCreateTable. dbDelta still owns creating
20 * tables and adding columns; this owns everything dbDelta cannot express or
21 * cannot be trusted with, which in practice means indexes and column types.
22 *
23 * The existing per-change flags are deliberately left where they are. They
24 * are idempotent and the code that reads them still works; rewriting that to
25 * be tidier would risk a migration re-running on a live site for no gain.
26 */
27 class HashFormMigrations {
28
29 /**
30 * Bump when a step is added. Steps themselves are keyed by name, so the
31 * number is a fast "is there anything to do" check, not the source of
32 * truth about what has run.
33 */
34 const VERSION = 2;
35
36 const OPTION = 'hashform_schema_version';
37 const PROGRESS = 'hashform_schema_progress';
38 const LOCK = 'hashform_schema_lock';
39
40 /**
41 * How long a run may hold the lock. Long enough for an ALTER on a large
42 * table, short enough that a fatal does not wedge migrations forever.
43 */
44 const LOCK_TTL = 10 * MINUTE_IN_SECONDS;
45
46 public function __construct() {
47 /*
48 * Schema changes belong to an administrator's request, WP-CLI or
49 * cron - never to a visitor loading a page with a form on it. A
50 * front-end request must not pay for an ALTER, and must not be the
51 * thing that gets killed half way through one.
52 */
53 add_action('admin_init', array(__CLASS__, 'maybe_migrate'));
54
55 if (defined('WP_CLI') && WP_CLI) {
56 add_action('init', array(__CLASS__, 'maybe_migrate'));
57 }
58 }
59
60 /**
61 * Every step, in the order they must run.
62 *
63 * Steps are named rather than handed over as callables: they are internal
64 * to this class, and a callable pointing at a private method only works
65 * because of where it happens to be invoked from. Naming them keeps the
66 * dispatch explicit and the methods properly private.
67 *
68 * @return array key => method name on this class
69 */
70 public static function steps() {
71 return array(
72 // The entries list orders by created_at within a status. Without
73 // this every view of the screen scans the table.
74 'entries_status_created' => 'step_entries_status_created',
75
76 // Filtering to one form, still ordered by date.
77 'entries_form_status_created' => 'step_entries_form_status_created',
78
79 // The unread and starred views.
80 'entries_status_read' => 'step_entries_status_read',
81 'entries_status_starred' => 'step_entries_status_starred',
82
83 // Every read of an answer looks up an entry and a field together,
84 // but the table only had them indexed separately.
85 'entry_meta_item_field' => 'step_entry_meta_item_field',
86
87 // The forms dashboard filters by status.
88 'forms_status' => 'step_forms_status',
89
90 // An IP is at most 45 characters; storing it as text means it
91 // cannot be indexed and costs an off-page read to compare.
92 'entries_ip_varchar' => 'step_entries_ip_varchar',
93
94 // A scheduled event left behind by an older version, which no
95 // code has answered for some time.
96 'clear_stale_payment_cron' => 'step_clear_stale_payment_cron',
97 );
98 }
99
100 /* ---------------------------------------------------------------------
101 * Running
102 * ------------------------------------------------------------------- */
103
104 /**
105 * Run anything outstanding. Cheap and safe to call on every admin page.
106 *
107 * @return bool Whether any step ran.
108 */
109 public static function maybe_migrate() {
110 if ((int) get_option(self::OPTION) === self::VERSION) {
111 return false;
112 }
113
114 /*
115 * Two admins loading wp-admin at the same moment would otherwise both
116 * start migrating. The steps are idempotent, so the worst case is
117 * wasted work rather than damage, but an ALTER running twice on a
118 * large table is worth avoiding.
119 */
120 if (get_transient(self::LOCK)) {
121 return false;
122 }
123
124 set_transient(self::LOCK, 1, self::LOCK_TTL);
125
126 try {
127 $ran = self::run();
128 } catch (Exception $e) {
129 self::log('migration aborted: ' . $e->getMessage());
130 $ran = false;
131 }
132
133 delete_transient(self::LOCK);
134
135 return $ran;
136 }
137
138 /**
139 * Run every step not already recorded as done.
140 *
141 * A step that fails stops the run without recording itself, so the next
142 * request picks up from there rather than skipping past a change the rest
143 * may depend on.
144 *
145 * @return bool Whether every outstanding step completed.
146 */
147 public static function run() {
148 $done = self::completed();
149 $all_ok = true;
150
151 foreach (self::steps() as $key => $method) {
152 if (in_array($key, $done, true)) {
153 continue;
154 }
155
156 if (!method_exists(__CLASS__, $method)) {
157 self::log('step names a method that does not exist: ' . $key);
158 $all_ok = false;
159 break;
160 }
161
162 $result = self::$method();
163
164 if (false === $result) {
165 self::log('step failed, stopping: ' . $key);
166 $all_ok = false;
167 break;
168 }
169
170 // Recorded immediately. If the request dies on the next step,
171 // this one is not repeated.
172 $done[] = $key;
173 update_option(self::PROGRESS, $done, false);
174 }
175
176 if ($all_ok) {
177 update_option(self::OPTION, self::VERSION, true);
178 }
179
180 return $all_ok;
181 }
182
183 /**
184 * Steps recorded as complete.
185 *
186 * @return array
187 */
188 public static function completed() {
189 $done = get_option(self::PROGRESS, array());
190
191 return is_array($done) ? $done : array();
192 }
193
194 /**
195 * Steps still to run.
196 *
197 * @return array
198 */
199 public static function outstanding() {
200 return array_values(array_diff(array_keys(self::steps()), self::completed()));
201 }
202
203 /* ---------------------------------------------------------------------
204 * The steps
205 * ------------------------------------------------------------------- */
206
207 private static function step_entries_status_created() {
208 return self::add_index('hashform_entries', 'hf_status_created', array('status(20)', 'created_at'));
209 }
210
211 private static function step_entries_form_status_created() {
212 return self::add_index('hashform_entries', 'hf_form_status_created', array('form_id', 'status(20)', 'created_at'));
213 }
214
215 private static function step_entries_status_read() {
216 return self::add_index('hashform_entries', 'hf_status_read', array('status(20)', 'is_read'));
217 }
218
219 private static function step_entries_status_starred() {
220 return self::add_index('hashform_entries', 'hf_status_starred', array('status(20)', 'is_starred'));
221 }
222
223 private static function step_entry_meta_item_field() {
224 return self::add_index('hashform_entry_meta', 'hf_item_field', array('item_id', 'field_id'));
225 }
226
227 private static function step_forms_status() {
228 return self::add_index('hashform_forms', 'hf_status', array('status(20)'));
229 }
230
231 /**
232 * Narrow entries.ip from text to varchar(45).
233 *
234 * Refuses rather than truncates: if anything stored is longer than an
235 * IPv6 address can be, that is a sign the column is being used for
236 * something else and silently cutting it would destroy data.
237 */
238 private static function step_entries_ip_varchar() {
239 global $wpdb;
240
241 $table = $wpdb->prefix . 'hashform_entries';
242 $type = self::column_type('hashform_entries', 'ip');
243
244 if (null === $type) {
245 // No column, nothing to narrow. Not a failure.
246 return true;
247 }
248
249 if (0 === stripos($type, 'varchar')) {
250 return true;
251 }
252
253 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table is $wpdb->prefix plus a literal; the query carries no values.
254 $too_long = (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$table}` WHERE CHAR_LENGTH(ip) > 45");
255
256 if ($too_long > 0) {
257 self::log(sprintf('entries.ip left as %s: %d row(s) longer than 45 characters', $type, $too_long));
258
259 // Deliberately a success: the database is in a shape this step
260 // will not improve, and retrying forever helps nobody.
261 return true;
262 }
263
264 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange -- $table is $wpdb->prefix plus a literal. A schema change is the whole point of a migration step.
265 $wpdb->query("ALTER TABLE `{$table}` MODIFY `ip` VARCHAR(45) DEFAULT NULL");
266
267 return 'varchar' === strtolower(substr((string) self::column_type('hashform_entries', 'ip'), 0, 7));
268 }
269
270 /**
271 * Remove a cron event nothing listens for any more.
272 *
273 * hashform_pro_payments_maintenance is scheduled on sites that ran an
274 * earlier build, but no code registers or handles it: it fires daily,
275 * finds no callback, and reschedules itself forever. Clearing it is not a
276 * schema change, but it is exactly the kind of one-off tidy-up that needs
277 * to happen once, in order, and be recorded - which is what this is for.
278 *
279 * @return bool
280 */
281 private static function step_clear_stale_payment_cron() {
282 $hook = 'hashform_pro_payments_maintenance';
283
284 if (has_action($hook)) {
285 // Something answers it after all; leave it alone.
286 return true;
287 }
288
289 wp_clear_scheduled_hook($hook);
290
291 return true;
292 }
293
294 /* ---------------------------------------------------------------------
295 * Schema helpers
296 * ------------------------------------------------------------------- */
297
298 /**
299 * Add an index if it is not already there.
300 *
301 * Identifiers cannot be passed through $wpdb->prepare, so every one is
302 * checked against a strict pattern before it reaches a query. They are
303 * all literals defined in this file, but a typo should fail loudly here
304 * rather than become part of a statement.
305 *
306 * @param string $table Unprefixed table name.
307 * @param string $name Index name.
308 * @param array $columns Column names, optionally with a "(20)" prefix length.
309 * @return bool
310 */
311 public static function add_index($table, $name, array $columns) {
312 global $wpdb;
313
314 if (!self::table_exists($table)) {
315 self::log('index skipped, no such table: ' . $table);
316
317 return true;
318 }
319
320 if (!self::valid_identifier($name)) {
321 self::log('refused an index name that is not an identifier: ' . $name);
322
323 return false;
324 }
325
326 if (self::index_exists($table, $name)) {
327 return true;
328 }
329
330 $parts = array();
331
332 foreach ($columns as $column) {
333 if (!preg_match('/^([A-Za-z0-9_]+)(?:\((\d+)\))?$/', $column, $m)) {
334 self::log('refused a column definition that is not an identifier: ' . $column);
335
336 return false;
337 }
338
339 $parts[] = isset($m[2]) ? '`' . $m[1] . '`(' . (int) $m[2] . ')' : '`' . $m[1] . '`';
340 }
341
342 $full = $wpdb->prefix . $table;
343
344 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.SchemaChange -- $name passed valid_identifier(), and every entry in $parts was rebuilt from a matched [A-Za-z0-9_] group with a cast length. A schema change is the whole point of a migration step.
345 $wpdb->query("ALTER TABLE `{$full}` ADD INDEX `{$name}` (" . implode(', ', $parts) . ')');
346
347 if (!self::index_exists($table, $name)) {
348 self::log('index was not created: ' . $table . '.' . $name . ' - ' . $wpdb->last_error);
349
350 return false;
351 }
352
353 return true;
354 }
355
356 /**
357 * @param string $table Unprefixed table name.
358 * @param string $name
359 * @return bool
360 */
361 public static function index_exists($table, $name) {
362 global $wpdb;
363
364 if (!self::valid_identifier($name) || !self::table_exists($table)) {
365 return false;
366 }
367
368 $full = $wpdb->prefix . $table;
369
370 // SHOW INDEX takes the table as an identifier but the key name as a
371 // value, so that half is prepared.
372 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $full is $wpdb->prefix plus a table name already checked by table_exists().
373 $found = $wpdb->get_results("SHOW INDEX FROM `{$full}`", ARRAY_A);
374
375 foreach ((array) $found as $row) {
376 if (isset($row['Key_name']) && $row['Key_name'] === $name) {
377 return true;
378 }
379 }
380
381 return false;
382 }
383
384 /**
385 * The declared type of a column, or null when it does not exist.
386 *
387 * @param string $table Unprefixed table name.
388 * @param string $column
389 * @return string|null
390 */
391 public static function column_type($table, $column) {
392 global $wpdb;
393
394 if (!self::table_exists($table)) {
395 return null;
396 }
397
398 $full = $wpdb->prefix . $table;
399
400 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $full is $wpdb->prefix plus a table name already checked by table_exists().
401 $rows = $wpdb->get_results("SHOW COLUMNS FROM `{$full}`", ARRAY_A);
402
403 foreach ((array) $rows as $row) {
404 if (isset($row['Field']) && $row['Field'] === $column) {
405 return isset($row['Type']) ? $row['Type'] : '';
406 }
407 }
408
409 return null;
410 }
411
412 /**
413 * @param string $table Unprefixed table name.
414 * @return bool
415 */
416 public static function table_exists($table) {
417 global $wpdb;
418
419 if (!self::valid_identifier($table)) {
420 return false;
421 }
422
423 $full = $wpdb->prefix . $table;
424
425 return (bool) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $full));
426 }
427
428 private static function valid_identifier($name) {
429 return (bool) preg_match('/^[A-Za-z0-9_]+$/', (string) $name);
430 }
431
432 private static function log($message) {
433 if (class_exists('HashFormHelper') && method_exists('HashFormHelper', 'log')) {
434 HashFormHelper::log($message, 'hash-form/schema');
435 }
436 }
437
438 /* ---------------------------------------------------------------------
439 * Testing seam
440 * ------------------------------------------------------------------- */
441
442 /**
443 * Forget that migrations have run, without touching the schema.
444 *
445 * Only for tests and for a support case where a step has to be re-run;
446 * every step checks the database before changing it, so re-running is
447 * safe by construction.
448 */
449 public static function reset_progress() {
450 delete_option(self::PROGRESS);
451 delete_option(self::OPTION);
452 delete_transient(self::LOCK);
453 }
454
455 }
456
457 new HashFormMigrations();
458