Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions src/php/Core/DB.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,27 @@ public static function table_exists( string $table_name, bool $refresh = false )

/**
* Create the snippet tables, or upgrade them if they already exist
*
* @return bool Whether every table was created or upgraded.
*/
public function create_or_upgrade_tables() {
if ( is_multisite() ) {
$this->create_table( $this->ms_table );
}
public function create_or_upgrade_tables(): bool {
$network_okay = ! is_multisite() || $this->create_table( $this->ms_table );
return $this->create_table( $this->table ) && $network_okay;
}

/**
* Upgrade a table that a write has just failed on because it lacks a column.
*
* A table that missed an upgrade fails every save that writes a column it does not have.
*
* @param string $table_name Table the write failed on.
*
* @return bool Whether the table was upgraded, so the write can be retried.
*/
public static function upgrade_after_unknown_column( string $table_name ): bool {
global $wpdb;

$this->create_table( $this->table );
return false !== stripos( (string) $wpdb->last_error, 'Unknown column' ) && self::create_table( $table_name );
}

/**
Expand Down Expand Up @@ -322,7 +336,7 @@ private static function fetch_snippets_from_table( string $table_name, array $sc
: 'AND active <> -1';

$snippets = $wpdb->get_results(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$wpdb->prepare(
"
SELECT id, code, scope, active, priority
Expand Down
59 changes: 34 additions & 25 deletions src/php/Settings/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ function register_plugin_settings() {
// accessible name. Checkboxes render their own labels, and
// callback/action fields have no single control to point at.
$labelable_types = [ 'text', 'number', 'select' ];
$field_args = [];
$field_args = [];

if ( isset( $field['type'] ) && in_array( $field['type'], $labelable_types, true ) ) {
$field_args['label_for'] = $field_object->element_id;
Expand Down Expand Up @@ -259,7 +259,7 @@ function register_plugin_settings() {
*
* @param array<string, mixed> $field Field definition.
* @param array<string, array<string,mixed>> $settings Current settings values.
* @param array<string, array<string,mixed>>|null $input Optional raw input values.
* @param array<string, array<string,mixed>>|null $input Optional raw input values.
*
* @return bool
*/
Expand Down Expand Up @@ -365,7 +365,7 @@ function process_settings_actions( array $input ): ?array {
add_settings_error(
OPTION_NAME,
'settings_reset',
__( 'All settings have been reset to their defaults.', 'code-snippets' ),
esc_html__( 'All settings have been reset to their defaults.', 'code-snippets' ),
'updated'
);

Expand All @@ -374,14 +374,25 @@ function process_settings_actions( array $input ): ?array {
}

if ( isset( $input['debug']['database_update'] ) ) {
code_snippets()->db->create_or_upgrade_tables();
if ( code_snippets()->db->create_or_upgrade_tables() ) {
add_settings_error(
OPTION_NAME,
'database_update_done',
esc_html__( 'Successfully performed database table upgrade.', 'code-snippets' ),
'updated'
);
} else {
global $wpdb;

add_settings_error(
OPTION_NAME,
'database_update_done',
__( 'Successfully performed database table upgrade.', 'code-snippets' ),
'updated'
);
/* translators: %s: database error message */
$error_text = __( 'The database table upgrade did not complete: %s', 'code-snippets' );

add_settings_error(
OPTION_NAME,
'database_update_failed',
esc_html( sprintf( $error_text, $wpdb->last_error ) ),
);
}
}

if ( isset( $input['debug']['reset_caches'] ) ) {
Expand All @@ -406,29 +417,27 @@ function process_settings_actions( array $input ): ?array {
$refresh_failure = Version_Switch::get_refresh_failure();

if ( $refresh_failure ) {
/* translators: %s: reason the plugin versions could not be listed. */
$message = __( 'Successfully reset snippets caches, but the available plugin versions could not be listed: %s', 'code-snippets' );

add_settings_error(
OPTION_NAME,
'snippet_caches_reset',
sprintf(
/* translators: %s: reason the plugin versions could not be listed. */
__( 'Successfully reset snippets caches, but the available plugin versions could not be listed: %s', 'code-snippets' ),
$refresh_failure
)
esc_html( sprintf( $message, $refresh_failure ) ),
);
} else {
/* translators: %d: number of plugin versions available to install. */
$message = _n(
'Successfully reset snippets caches. %d plugin version is available to install.',
'Successfully reset snippets caches. %d plugin versions are available to install.',
count( $available_versions ),
'code-snippets'
);

add_settings_error(
OPTION_NAME,
'snippet_caches_reset',
sprintf(
/* translators: %d: number of plugin versions available to install. */
_n(
'Successfully reset snippets caches. %d plugin version is available to install.',
'Successfully reset snippets caches. %d plugin versions are available to install.',
count( $available_versions ),
'code-snippets'
),
count( $available_versions )
),
esc_html( sprintf( $message, count( $available_versions ) ) ),
'updated'
);
}
Expand Down
23 changes: 21 additions & 2 deletions src/php/snippet-ops.php
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,14 @@ function save_snippet( $snippet ): ?Snippet {
// Create a new snippet if the ID is not set.
if ( 0 === $snippet->id ) {
$data['created_by'] = $author_id;
$result = $wpdb->insert( $table, $data, '%s' );
$format = '%s';

$result = $wpdb->insert( $table, $data, $format );

if ( false === $result && Core\DB::upgrade_after_unknown_column( $table ) ) {
$result = $wpdb->insert( $table, $data, $format );
}

if ( false === $result ) {
return null;
}
Expand All @@ -828,8 +835,20 @@ function save_snippet( $snippet ): ?Snippet {
// Otherwise, update the snippet data.
$existing = get_snippet( $snippet->id, $snippet->network );

$where = [ 'id' => $snippet->id ];
$where_format = [ '%d' ];

$result = $wpdb->update( $table, $data, $where, null, $where_format );

if ( false === $result && Core\DB::upgrade_after_unknown_column( $table ) ) {
$result = $wpdb->update( $table, $data, $where, null, $where_format );
}

if ( false === $result ) {
return null;
}

set_snippet_locked( $snippet->id, $snippet->locked, $snippet->network );
$wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] );

// The row has changed, so the cached list no longer describes it. It is
// dropped before the snippet is read back, because everything below —
Expand Down
158 changes: 158 additions & 0 deletions tests/unit/Core/DB_Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

namespace Code_Snippets\Core;

use Code_Snippets\Model\Snippet;
use Code_Snippets\UnitTestCase;
use function Code_Snippets\code_snippets;
use function Code_Snippets\get_snippet;
use function Code_Snippets\save_snippet;

// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL -- tests stand in and inspect the snippets table directly.

/**
* Tests for the snippets table and writes to it.
*/
class DB_Test extends UnitTestCase {

/**
* Whether a test replaced the snippets table with one from before the authorship columns.
*
* @var bool
*/
private bool $shadowed = false;

/**
* Remove the replacement table.
*
* @return void
*/
public function tear_down() {
global $wpdb;

if ( $this->shadowed ) {
$wpdb->query( 'DROP TEMPORARY TABLE IF EXISTS `' . code_snippets()->db->table . '`' );
$this->shadowed = false;
}

remove_all_filters( 'query' );
parent::tear_down();
}

/**
* Stand in a snippets table that missed the upgrade adding the authorship columns.
*
* A temporary table hides the real one of the same name for this connection only.
*
* @return void
*/
private function shadow_table_without_authorship(): void {
global $wpdb;

$table = code_snippets()->db->table;

$wpdb->query(
"CREATE TEMPORARY TABLE `$table` (
id BIGINT(20) NOT NULL AUTO_INCREMENT,
name TINYTEXT NOT NULL,
description TEXT NOT NULL,
code LONGTEXT NOT NULL,
tags LONGTEXT NOT NULL,
scope VARCHAR(15) NOT NULL DEFAULT 'global',
condition_id BIGINT(20) NOT NULL DEFAULT 0,
priority SMALLINT NOT NULL DEFAULT 10,
active TINYINT(1) NOT NULL DEFAULT 0,
modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
revision BIGINT(20) NOT NULL DEFAULT 1,
cloud_id VARCHAR(255) NULL,
PRIMARY KEY (id)
)"
);

$this->shadowed = true;
DB::table_exists( $table, true );
}

/**
* The names of the columns the current snippets table has.
*
* @return string[]
*/
private function columns(): array {
global $wpdb;

return $wpdb->get_col( 'SHOW COLUMNS FROM `' . code_snippets()->db->table . '`' );
}

/**
* A table that missed the authorship upgrade is upgraded by the first save, which then succeeds.
*
* @return void
*/
public function test_saving_upgrades_a_table_missing_a_column(): void {
$this->shadow_table_without_authorship();
$this->assertNotContains( 'updated_by', $this->columns() );

$suppressed = $GLOBALS['wpdb']->suppress_errors();
$created = save_snippet(
new Snippet(
[
'name' => 'Created on an old table',
'code' => '// one',
]
)
);
$GLOBALS['wpdb']->suppress_errors( $suppressed );

$this->assertNotNull( $created );
$this->assertGreaterThan( 0, $created->id );
$this->assertContains( 'created_by', $this->columns() );
$this->assertContains( 'updated_by', $this->columns() );

$created->code = '// two';
$this->assertNotNull( save_snippet( $created ) );
$this->assertSame( '// two', get_snippet( $created->id )->code );
}

/**
* An edit that cannot be written is reported as failed rather than returning the unchanged snippet.
*
* @return void
*/
public function test_failed_update_returns_null(): void {
$snippet = save_snippet(
new Snippet(
[
'name' => 'Cannot be updated',
'code' => '// before',
]
)
);
$table = code_snippets()->db->table;

add_filter(
'query',
static fn( string $query ): string => 0 === strpos( $query, "UPDATE `$table`" ) ? 'UPDATE `cs_missing_table` SET id = 1' : $query
);

$snippet->code = '// after';

$suppressed = $GLOBALS['wpdb']->suppress_errors();
$saved = save_snippet( $snippet );
$GLOBALS['wpdb']->suppress_errors( $suppressed );

$this->assertNull( $saved );

remove_all_filters( 'query' );
$this->assertSame( '// before', get_snippet( $snippet->id )->code );
}

/**
* Upgrading the tables reports success when they match the current schema.
*
* @return void
*/
public function test_create_or_upgrade_tables_reports_success(): void {
$this->assertTrue( code_snippets()->db->create_or_upgrade_tables() );
}
}
Loading