get_method(), [ 'POST', 'PUT', 'PATCH' ], true ) ) { return $result; } if ( ! preg_match( '#^/' . preg_quote( ACADEMY_PLUGIN_SLUG, '#' ) . '/v1/academy_quiz(?:/\d+)?$#', $request->get_route() ) ) { return $result; } $meta = $request->get_param( 'meta' ); if ( ! is_array( $meta ) ) { return $result; } $integer_keys = [ 'academy_quiz_time', 'academy_quiz_passing_grade', 'academy_quiz_max_questions_for_answer', 'academy_quiz_max_attempts_allowed', 'academy_quiz_short_answer_characters_limit', ]; foreach ( $integer_keys as $key ) { if ( array_key_exists( $key, $meta ) && ( '' === $meta[ $key ] || null === $meta[ $key ] ) ) { $meta[ $key ] = 0; } } $request->set_param( 'meta', $meta ); return $result; } public static function maybe_sync_schema() { if ( self::SCHEMA_VERSION === \Academy\Options::get( \Academy\Options::DB_VERSIONS, self::SCHEMA_VERSION_KEY ) ) { return; } self::sync_schema(); } /** * Bring the quiz tables in line with their definitions. * * WordPress's dbDelta() can't do this on its own: the definitions use * `CREATE TABLE`, which dbDelta reads as a table named "IF", * so it only ever creates missing tables and never alters existing ones. * Columns (and the attempt/question unique key) are therefore added here, * idempotently, from the same definitions. */ public static function sync_schema() { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; global $wpdb; $prefix = $wpdb->prefix; $charset_collate = $wpdb->get_charset_collate(); self::create_initial_custom_table(); $tables = array( Database\CreateQuizQuestionsTable::class, Database\CreateQuizAnswersTable::class, Database\CreateQuizAttemptsTable::class, Database\CreateQuizAttemptAnswersTable::class, ); $ok = true; foreach ( $tables as $table ) { $ok = self::add_missing_columns( $table::table_name( $prefix ), $table::schema( $prefix, $charset_collate ) ) && $ok; } $ok = self::add_attempt_question_unique_key( Database\CreateQuizAttemptAnswersTable::table_name( $prefix ) ) && $ok; if ( $ok ) { self::extract_embedded_question_audio( Database\CreateQuizQuestionsTable::table_name( $prefix ) ); \Academy\Options::set( \Academy\Options::DB_VERSIONS, self::SCHEMA_VERSION_KEY, self::SCHEMA_VERSION ); } } /** * Column definitions (name => SQL) from a CREATE TABLE statement, in order. * * @param string $create_sql CREATE TABLE statement. * @return array */ private static function parse_columns( $create_sql ) { $columns = array(); $body = substr( $create_sql, strpos( $create_sql, '(' ) + 1 ); $body = substr( $body, 0, strrpos( $body, ')' ) ); foreach ( preg_split( '/\R/', $body ) as $line ) { $line = trim( rtrim( trim( $line ), ',' ) ); if ( '' === $line || preg_match( '/^(PRIMARY|UNIQUE|KEY|INDEX|FULLTEXT|CONSTRAINT)\b/i', $line ) ) { continue; } $name = strtok( $line, " \t" ); $columns[ $name ] = $line; } return $columns; } /** * Add every column the definition has and the table lacks, each placed * after its predecessor in the definition. * * @param string $table_name * @param mixed $create_sql * * @return bool False if the table is missing or an ALTER failed. */ private static function add_missing_columns( $table_name, $create_sql ) { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- versioned schema sync; only the $wpdb-prefixed table name and column definitions from our own CREATE TABLE are interpolated. $existing = $wpdb->get_col( "SHOW COLUMNS FROM `{$table_name}`" ); if ( empty( $existing ) ) { return false; } $ok = true; $previous = null; foreach ( self::parse_columns( $create_sql ) as $name => $definition ) { if ( ! in_array( $name, $existing, true ) ) { $after = $previous ? " AFTER `{$previous}`" : ' FIRST'; $result = $wpdb->query( "ALTER TABLE `{$table_name}` ADD COLUMN {$definition}{$after}" ); if ( false === $result ) { $ok = false; continue; } $existing[] = $name; } $previous = $name; } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared return $ok; } /** * One answer row per attempt + question. An older final-submit bug could * write duplicates, which would make the key fail — so those collapse to * the newest row first. * * @param string $table_name * * @return bool */ private static function add_attempt_question_unique_key( $table_name ) { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- versioned schema sync; only the $wpdb-prefixed table name is interpolated. $has_key = $wpdb->get_var( "SHOW INDEX FROM `{$table_name}` WHERE Key_name = 'attempt_question'" ); if ( $has_key ) { return true; } // Single pass over the primary key, newest first, in chunks. The first row seen for an // attempt + question is the newest one, so any later hit is a duplicate to delete. No // self-join, so it stays linear without needing an index on attempt_id / question_id. $chunk_size = 2000; $cursor = (int) $wpdb->get_var( "SELECT MAX(attempt_answer_id) + 1 FROM `{$table_name}`" ); $seen = []; while ( $cursor > 0 ) { $rows = $wpdb->get_results( "SELECT attempt_answer_id, attempt_id, question_id FROM `{$table_name}` WHERE attempt_answer_id < {$cursor} ORDER BY attempt_answer_id DESC LIMIT {$chunk_size}" ); if ( empty( $rows ) ) { break; } $delete_ids = []; foreach ( $rows as $row ) { $cursor = (int) $row->attempt_answer_id; if ( null === $row->attempt_id || null === $row->question_id ) { continue; } $key = $row->attempt_id . ':' . $row->question_id; if ( isset( $seen[ $key ] ) ) { $delete_ids[] = (int) $row->attempt_answer_id; } else { $seen[ $key ] = true; } } if ( $delete_ids ) { $placeholders = implode( ', ', array_fill( 0, count( $delete_ids ), '%d' ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM `{$table_name}` WHERE attempt_answer_id IN ( {$placeholders} )", $delete_ids ) ); } }//end while unset( $seen ); $result = $wpdb->query( "ALTER TABLE `{$table_name}` ADD UNIQUE KEY attempt_question (attempt_id, question_id)" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared return false !== $result; } /** * The Description field used to let admins embed an