Merge pull request #32034 from woocommerce/cot/31766

Add migration to move orders from wp_posts to custom tables
This commit is contained in:
Jorge A. Torres 2022-04-11 14:30:02 -03:00 committed by GitHub
commit e9e382adf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 2002 additions and 7 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Generic migration support for migration from posts + postsmeta table to any custom table. Additionaly, implement migrations to various COT tables using this generic support.

View File

@ -640,7 +640,7 @@ abstract class WC_Data {
}
}
if ( ! empty( $this->cache_group ) ) {
$cache_key = WC_Cache_Helper::get_cache_prefix( $this->cache_group ) . WC_Cache_Helper::get_cache_prefix( 'object_' . $this->get_id() ) . 'object_meta_' . $this->get_id();
$cache_key = self::generate_meta_cache_key( $this->get_id(), $this->cache_group );
wp_cache_delete( $cache_key, $this->cache_group );
}
}

View File

@ -6,6 +6,7 @@
namespace Automattic\WooCommerce;
use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\COTMigrationServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersDataStoreServiceProvider;
@ -47,6 +48,7 @@ final class Container implements \Psr\Container\ContainerInterface {
ProxiesServiceProvider::class,
RestockRefundedItemsAdjusterServiceProvider::class,
UtilsClassesServiceProvider::class,
COTMigrationServiceProvider::class,
);
/**

View File

@ -0,0 +1,586 @@
<?php
/**
* Generic migration class to move any entity, entity_meta table combination to custom table.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\MigrationHelper;
/**
* Class MetaToCustomTableMigrator.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
abstract class MetaToCustomTableMigrator {
/**
* Config for tables being migrated and migrated from. See __construct() for detailed config.
*
* @var array
*/
protected $schema_config;
/**
* Meta config, see __construct for detailed config.
*
* @var array
*/
protected $meta_column_mapping;
/**
* Column mapping from source table to destination custom table. See __construct for detailed config.
*
* @var array
*/
protected $core_column_mapping;
/**
* Store errors along with entity IDs from migrations.
*
* @var array
*/
protected $errors;
/**
* MetaToCustomTableMigrator constructor.
*/
public function __construct() {
$this->schema_config = MigrationHelper::escape_schema_for_backtick( $this->get_schema_config() );
$this->meta_column_mapping = $this->get_meta_column_config();
$this->core_column_mapping = $this->get_core_column_mapping();
$this->errors = array();
}
/**
* Specify schema config the source and destination table.
*
* @return array Schema, must of the form:
* array(
'source' => array(
'entity' => array(
'table_name' => $source_table_name,
'meta_rel_column' => $column_meta, Name of column in source table which is referenced by meta table.
'destination_rel_column' => $column_dest, Name of column in source table which is refenced by destination table,
'primary_key' => $primary_key, Primary key of the source table
),
'meta' => array(
'table' => $meta_table_name,
'meta_key_column' => $meta_key_column_name,
'meta_value_column' => $meta_value_column_name,
'entity_id_column' => $entity_id_column, Name of the column having entity IDs.
),
),
'destination' => array(
'table_name' => $table_name, Name of destination table,
'source_rel_column' => $column_source_id, Name of the column in destination table which is referenced by source table.
'primary_key' => $table_primary_key,
'primary_key_type' => $type bool|int|string|decimal
)
*/
abstract public function get_schema_config();
/**
* Specify column config from the source table.
*
* @return array Config, must be of the form:
* array(
* '$source_column_name_1' => array( // $source_column_name_1 is column name in source table, or a select statement.
* 'type' => 'type of value, could be string/int/date/float.',
* 'destination' => 'name of the column in column name where this data should be inserted in.',
* ),
* '$source_column_name_2' => array(
* ......
* ),
* ....
* ).
*/
abstract public function get_core_column_mapping();
/**
* Specify meta keys config from source meta table.
*
* @return array Config, must be of the form.
* array(
* '$meta_key_1' => array( // $meta_key_1 is the name of meta_key in source meta table.
* 'type' => 'type of value, could be string/int/date/float',
* 'destination' => 'name of the column in column name where this data should be inserted in.',
* ),
* '$meta_key_2' => array(
* ......
* ),
* ....
* ).
*/
abstract public function get_meta_column_config();
/**
* Generate SQL for data insertion.
*
* @param array $batch Data to generate queries for. Will be 'data' array returned by `$this->fetch_data_for_migration_for_ids()` method.
*
* @return string Generated queries for insertion for this batch, would be of the form:
* INSERT IGNORE INTO $table_name ($columns) values
* ($value for row 1)
* ($value for row 2)
* ...
*/
public function generate_insert_sql_for_batch( $batch ) {
$table = $this->schema_config['destination']['table_name'];
list( $value_sql, $column_sql ) = $this->generate_column_clauses( array_merge( $this->core_column_mapping, $this->meta_column_mapping ), $batch );
return "INSERT IGNORE INTO $table (`$column_sql`) VALUES $value_sql;"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, -- $insert_query is hardcoded, $value_sql is already escaped.
}
/**
* Generate SQL for data updating.
*
* @param array $batch Data to generate queries for. Will be `data` array returned by fetch_data_for_migration_for_ids() method.
*
* @param array $entity_row_mapping Maps rows to update data with their original IDs. Will be returned by `generate_update_sql_for_batch`.
*
* @return string Generated queries for batch update. Would be of the form:
* INSERT INTO $table ( $columns ) VALUES
* ($value for row 1)
* ($valye for row 2)
* ...
* ON DUPLICATE KEY UPDATE
* $column1 = VALUES($column1)
* $column2 = VALUES($column2)
* ...
*/
public function generate_update_sql_for_batch( $batch, $entity_row_mapping ) {
$table = $this->schema_config['destination']['table_name'];
$destination_primary_id_schema = $this->get_destination_table_primary_id_schema();
foreach ( $batch as $entity_id => $row ) {
$batch[ $entity_id ][ $destination_primary_id_schema['destination_primary_key']['destination'] ] = $entity_row_mapping[ $entity_id ]->destination_id;
}
list( $value_sql, $column_sql, $columns ) = $this->generate_column_clauses(
array_merge( $destination_primary_id_schema, $this->core_column_mapping, $this->meta_column_mapping ),
$batch
);
$duplicate_update_key_statement = $this->generate_on_duplicate_statement_clause( $columns );
return "INSERT INTO $table (`$column_sql`) VALUES $value_sql $duplicate_update_key_statement;";
}
/**
* Generate schema for primary ID column of destination table.
*
* @return array[] Schema for primary ID column.
*/
protected function get_destination_table_primary_id_schema() {
return array(
'destination_primary_key' => array(
'destination' => $this->schema_config['destination']['primary_key'],
'type' => $this->schema_config['destination']['primary_key_type'],
),
);
}
/**
* Generate values and columns clauses to be used in INSERT and INSERT..ON DUPLICATE KEY UPDATE statements.
*
* @param array $columns_schema Columns config for destination table.
* @param array $batch Actual data to migrate as returned by `data` in `fetch_data_for_migration_for_ids` method.
*
* @return array SQL clause for values, columns placeholders, and columns.
*/
protected function generate_column_clauses( $columns_schema, $batch ) {
global $wpdb;
$columns = array();
$placeholders = array();
foreach ( $columns_schema as $prev_column => $schema ) {
$columns[] = $schema['destination'];
$placeholders[] = MigrationHelper::get_wpdb_placeholder_for_type( $schema['type'] );
}
$placeholders = "'" . implode( "', '", $placeholders ) . "'";
$values = array();
foreach ( array_values( $batch ) as $row ) {
$query_params = array();
foreach ( $columns as $column ) {
$query_params[] = $row[ $column ] ?? null;
}
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $placeholders can only contain combination of placeholders described in MigrationHelper::get_wpdb_placeholder_for_type
$value_string = '(' . $wpdb->prepare( $placeholders, $query_params ) . ')';
$values[] = $value_string;
}
$value_sql = implode( ',', $values );
$column_sql = implode( '`, `', $columns );
return array( $value_sql, $column_sql, $columns );
}
/**
* Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
*
* @param array $columns List of column names.
*
* @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
*/
private function generate_on_duplicate_statement_clause( $columns ) {
$update_value_statements = array();
foreach ( $columns as $column ) {
$update_value_statements[] = "$column = VALUES( $column )";
}
$update_value_clause = implode( ', ', $update_value_statements );
return "ON DUPLICATE KEY UPDATE $update_value_clause";
}
/**
* Process next migration batch, uses option `wc_cot_migration` to checkpoints of what have been processed so far.
*
* @param array $entity_ids List of entity IDs to perform migrations for.
*
* @return array List of errors happened during migration.
*/
public function process_migration_batch_for_ids( $entity_ids ) {
$data = $this->fetch_data_for_migration_for_ids( $entity_ids );
foreach ( $data['errors'] as $entity_id => $error ) {
$this->errors[ $entity_id ] = "Error in importing post id $entity_id: " . $error->get_message();
}
if ( count( $data['data'] ) === 0 ) {
return array();
}
$entity_ids = array_keys( $data['data'] );
$already_migrated = $this->get_already_migrated_records( $entity_ids );
$to_insert = array_diff_key( $data['data'], $already_migrated );
$this->process_insert_batch( $to_insert );
$to_update = array_intersect_key( $data['data'], $already_migrated );
$this->process_update_batch( $to_update, $already_migrated );
return array(
'errors' => $this->errors,
);
}
/**
* Process batch for insertion into destination table.
*
* @param array $batch Data to insert, will be of the form as returned by `data` in `fetch_data_for_migration_for_ids`.
*/
protected function process_insert_batch( $batch ) {
global $wpdb;
if ( 0 === count( $batch ) ) {
return;
}
$queries = $this->generate_insert_sql_for_batch( $batch );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Queries should already be prepared.
$result = $wpdb->query( $queries );
$wpdb->query( 'COMMIT;' ); // For some reason, this seems necessary on some hosts? Maybe a MySQL configuration?
if ( count( $batch ) !== $result ) {
$this->errors[] = 'Error with batch: ' . $wpdb->last_error;
}
}
/**
* Process batch for update into destination table.
*
* @param array $batch Data to insert, will be of the form as returned by `data` in `fetch_data_for_migration_for_ids`.
* @param array $already_migrated Maps rows to update data with their original IDs.
*/
protected function process_update_batch( $batch, $already_migrated ) {
global $wpdb;
if ( 0 === count( $batch ) ) {
return;
}
$queries = $this->generate_update_sql_for_batch( $batch, $already_migrated );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Queries should already be prepared.
$result = $wpdb->query( $queries );
$wpdb->query( 'COMMIT;' ); // For some reason, this seems necessary on some hosts? Maybe a MySQL configuration?
if ( count( $batch ) !== $result ) {
$this->errors[] = 'Error with batch: ' . $wpdb->last_error;
}
}
/**
* Fetch data for migration.
*
* @param array $entity_ids Entity IDs to fetch data for.
*
* @return array[] Data along with errors (if any), will of the form:
* array(
* 'data' => array(
* 'id_1' => array( 'column1' => value1, 'column2' => value2, ...),
* ...,
* ),
* 'errors' => array(
* 'id_1' => array( 'column1' => error1, 'column2' => value2, ...),
* ...,
* )
*/
public function fetch_data_for_migration_for_ids( $entity_ids ) {
global $wpdb;
if ( empty( $entity_ids ) ) {
return array(
'data' => array(),
'errors' => array(),
);
}
$entity_table_query = $this->build_entity_table_query( $entity_ids );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Output of $this->build_entity_table_query is already prepared.
$entity_data = $wpdb->get_results( $entity_table_query );
if ( empty( $entity_data ) ) {
return array(
'data' => array(),
'errors' => array(),
);
}
$entity_meta_rel_ids = array_column( $entity_data, 'entity_meta_rel_id' );
$meta_table_query = $this->build_meta_data_query( $entity_meta_rel_ids );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Output of $this->build_meta_data_query is already prepared.
$meta_data = $wpdb->get_results( $meta_table_query );
return $this->process_and_sanitize_data( $entity_data, $meta_data );
}
/**
* Fetch id mappings for records that are already inserted, or can be considered duplicates.
*
* @param array $entity_ids List of entity IDs to verify.
*
* @return array Already migrated entities, would be of the form
* array(
* '$source_id1' => array(
* 'source_id' => $source_id1,
* 'destination_id' => $destination_id1,
* ),
* ...
* )
*/
public function get_already_migrated_records( $entity_ids ) {
global $wpdb;
$source_table = $this->schema_config['source']['entity']['table_name'];
$source_destination_join_column = $this->schema_config['source']['entity']['destination_rel_column'];
$source_primary_key_column = $this->schema_config['source']['entity']['primary_key'];
$destination_table = $this->schema_config['destination']['table_name'];
$destination_source_join_column = $this->schema_config['destination']['source_rel_column'];
$destination_primary_key_column = $this->schema_config['destination']['primary_key'];
$entity_id_placeholder = implode( ',', array_fill( 0, count( $entity_ids ), '%d' ) );
$already_migrated_entity_ids = $wpdb->get_results(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- All columns and table names are hardcoded.
"
SELECT source.`$source_primary_key_column` as source_id, destination.`$destination_primary_key_column` as destination_id
FROM `$destination_table` destination
JOIN `$source_table` source ON source.`$source_destination_join_column` = destination.`$destination_source_join_column`
WHERE source.`$source_primary_key_column` IN ( $entity_id_placeholder )
",
$entity_ids
)
// phpcs:enable
);
return array_column( $already_migrated_entity_ids, null, 'source_id' );
}
/**
* Helper method to build query used to fetch data from core source table.
*
* @param array $entity_ids List of entity IDs to fetch.
*
* @return string Query that can be used to fetch data.
*/
protected function build_entity_table_query( $entity_ids ) {
global $wpdb;
$source_entity_table = $this->schema_config['source']['entity']['table_name'];
$source_meta_rel_id_column = "`$source_entity_table`.`{$this->schema_config['source']['entity']['meta_rel_column']}`";
$source_primary_key_column = "`$source_entity_table`.`{$this->schema_config['source']['entity']['primary_key']}`";
$where_clause = "$source_primary_key_column IN (" . implode( ',', array_fill( 0, count( $entity_ids ), '%d' ) ) . ')';
$entity_keys = array();
foreach ( $this->core_column_mapping as $column_name => $column_schema ) {
if ( isset( $column_schema['select_clause'] ) ) {
$select_clause = $column_schema['select_clause'];
$entity_keys[] = "$select_clause AS $column_name";
} else {
$entity_keys[] = "$source_entity_table.$column_name";
}
}
$entity_column_string = implode( ', ', $entity_keys );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $source_meta_rel_id_column, $source_destination_rel_id_column etc is escaped for backticks. $where clause and $order_by should already be escaped.
$query = $wpdb->prepare(
"
SELECT
$source_meta_rel_id_column as entity_meta_rel_id,
$entity_column_string
FROM `$source_entity_table`
WHERE $where_clause;
",
$entity_ids
);
// phpcs:enable
return $query;
}
/**
* Helper method to build query that will be used to fetch data from source meta table.
*
* @param array $entity_ids List of IDs to fetch metadata for.
*
* @return string Query for fetching meta data.
*/
protected function build_meta_data_query( $entity_ids ) {
global $wpdb;
$meta_table = $this->schema_config['source']['meta']['table_name'];
$meta_keys = array_keys( $this->meta_column_mapping );
$meta_key_column = $this->schema_config['source']['meta']['meta_key_column'];
$meta_value_column = $this->schema_config['source']['meta']['meta_value_column'];
$meta_table_relational_key = $this->schema_config['source']['meta']['entity_id_column'];
$meta_column_string = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );
$entity_id_string = implode( ', ', array_fill( 0, count( $entity_ids ), '%d' ) );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $meta_table_relational_key, $meta_key_column, $meta_value_column and $meta_table is escaped for backticks. $entity_id_string and $meta_column_string are placeholders.
$query = $wpdb->prepare(
"
SELECT `$meta_table_relational_key` as entity_id, `$meta_key_column` as meta_key, `$meta_value_column` as meta_value
FROM `$meta_table`
WHERE
`$meta_table_relational_key` IN ( $entity_id_string )
AND `$meta_key_column` IN ( $meta_column_string );
",
array_merge(
$entity_ids,
$meta_keys
)
);
// phpcs:enable
return $query;
}
/**
* Helper function to validate and combine data before we try to insert.
*
* @param array $entity_data Data from source table.
* @param array $meta_data Data from meta table.
*
* @return array[] Validated and combined data with errors.
*/
private function process_and_sanitize_data( $entity_data, $meta_data ) {
$sanitized_entity_data = array();
$error_records = array();
$this->process_and_sanitize_entity_data( $sanitized_entity_data, $error_records, $entity_data );
$this->processs_and_sanitize_meta_data( $sanitized_entity_data, $error_records, $meta_data );
return array(
'data' => $sanitized_entity_data,
'errors' => $error_records,
);
}
/**
* Helper method to sanitize core source table.
*
* @param array $sanitized_entity_data Array containing sanitized data for insertion.
* @param array $error_records Error records.
* @param array $entity_data Original source data.
*/
private function process_and_sanitize_entity_data( &$sanitized_entity_data, &$error_records, $entity_data ) {
foreach ( $entity_data as $entity ) {
$row_data = array();
foreach ( $this->core_column_mapping as $column_name => $schema ) {
$custom_table_column_name = $schema['destination'] ?? $column_name;
$value = $entity->$column_name;
$value = $this->validate_data( $value, $schema['type'] );
if ( is_wp_error( $value ) ) {
$error_records[ $entity->primary_key_id ][ $custom_table_column_name ] = $value->get_error_message();
} else {
$row_data[ $custom_table_column_name ] = $value;
}
}
$sanitized_entity_data[ $entity->entity_meta_rel_id ] = $row_data;
}
}
/**
* Helper method to sanitize soure meta data.
*
* @param array $sanitized_entity_data Array containing sanitized data for insertion.
* @param array $error_records Error records.
* @param array $meta_data Original source data.
*/
private function processs_and_sanitize_meta_data( &$sanitized_entity_data, &$error_records, $meta_data ) {
foreach ( $meta_data as $datum ) {
$column_schema = $this->meta_column_mapping[ $datum->meta_key ];
$value = $this->validate_data( $datum->meta_value, $column_schema['type'] );
if ( is_wp_error( $value ) ) {
$error_records[ $datum->entity_id ][ $column_schema['destination'] ] = "{$value->get_error_code()}: {$value->get_error_message()}";
} else {
$sanitized_entity_data[ $datum->entity_id ][ $column_schema['destination'] ] = $value;
}
}
}
/**
* Validate and transform data so that we catch as many errors as possible before inserting.
*
* @param mixed $value Actual data value.
* @param string $type Type of data, could be decimal, int, date, string.
*
* @return float|int|mixed|string|\WP_Error
*/
private function validate_data( $value, $type ) {
switch ( $type ) {
case 'decimal':
$value = (float) $value;
break;
case 'int':
$value = (int) $value;
break;
case 'bool':
$value = wc_string_to_bool( $value );
break;
case 'date':
try {
if ( '' === $value ) {
$value = null;
} else {
$value = ( new \DateTime( $value ) )->format( 'Y-m-d H:i:s' );
}
} catch ( \Exception $e ) {
return new \WP_Error( $e->getMessage() );
}
break;
case 'date_epoch':
try {
if ( '' === $value ) {
$value = null;
} else {
$value = ( new \DateTime( "@$value" ) )->format( 'Y-m-d H:i:s' );
}
} catch ( \Exception $e ) {
return new \WP_Error( $e->getMessage() );
}
break;
}
return $value;
}
}

View File

@ -0,0 +1,161 @@
<?php
/**
* Generic Migration class to move any meta data associated to an entity, to a different meta table associated with a custom entity table.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\MigrationHelper;
/**
* Class MetaToMetaTableMigrator.
*
* Generic class for powering migrations from one meta table to another table.
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class MetaToMetaTableMigrator {
/**
* Schema config, see __construct for more details.
*
* @var array
*/
private $schema_config;
/**
* MetaToMetaTableMigrator constructor.
*
* @param array $schema_config This parameters provides general but essential information about tables under migrations. Must be of the form-
* TODO: Add structure.
*/
public function __construct( $schema_config ) {
// TODO: Validate params.
$this->schema_config = $schema_config;
}
/**
* Generate insert sql queries for batches.
*
* @param array $batch Data to generate queries for.
* @param string $insert_switch Insert switch to use.
*
* @return string
*/
public function generate_insert_sql_for_batch( $batch, $insert_switch ) {
global $wpdb;
$insert_query = MigrationHelper::get_insert_switch( $insert_switch );
$meta_key_column = $this->schema_config['destination']['meta']['meta_key_column'];
$meta_value_column = $this->schema_config['destination']['meta']['meta_value_column'];
$entity_id_column = $this->schema_config['destination']['meta']['entity_id_column'];
$column_sql = "(`$entity_id_column`, `$meta_key_column`, `$meta_value_column`)";
$table = $this->schema_config['destination']['meta']['table_name'];
$entity_id_column_placeholder = MigrationHelper::get_wpdb_placeholder_for_type( $this->schema_config['destination']['meta']['entity_id_type'] );
$placeholder_string = "$entity_id_column_placeholder, %s, %s";
$values = array();
foreach ( array_values( $batch ) as $row ) {
$query_params = array(
$row->destination_entity_id,
$row->meta_key,
$row->meta_value,
);
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholder_string is hardcoded.
$value_sql = $wpdb->prepare( "$placeholder_string", $query_params );
$values[] = $value_sql;
}
$values_sql = implode( '), (', $values );
return "$insert_query INTO $table $column_sql VALUES ($values_sql)";
}
/**
* Fetch data for migration.
*
* @param array $order_post_ids Array of IDs to fetch data for.
*
* @return array[] Data along with errors (if any), will of the form:
* array(
* 'data' => array(
* 'id_1' => array( 'column1' => value1, 'column2' => value2, ...),
* ...,
* ),
* 'errors' => array(
* 'id_1' => array( 'column1' => error1, 'column2' => value2, ...),
* ...,
* )
*/
public function fetch_data_for_migration_for_ids( $order_post_ids ) {
global $wpdb;
if ( empty( $order_post_ids ) ) {
return array(
'data' => array(),
'errors' => array(),
);
}
$meta_query = $this->build_meta_table_query( $order_post_ids );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Meta query has interpolated variables, but they should all be escaped for backticks.
$meta_data_rows = $wpdb->get_results( $meta_query );
if ( empty( $meta_data_rows ) ) {
return array(
'data' => array(),
'errors' => array(),
);
}
return array(
'data' => $meta_data_rows,
'errors' => array(),
);
}
/**
* Helper method to build query used to fetch data from source meta table.
*
* @param string $entity_ids List of entity IDs to build meta query for.
*
* @return string Query that can be used to fetch data.
*/
private function build_meta_table_query( $entity_ids ) {
global $wpdb;
$source_meta_table = $this->schema_config['source']['meta']['table_name'];
$source_meta_key_column = $this->schema_config['source']['meta']['meta_key_column'];
$source_meta_value_column = $this->schema_config['source']['meta']['meta_value_column'];
$source_entity_id_column = $this->schema_config['source']['meta']['entity_id_column'];
$order_by = "$source_entity_id_column ASC";
$where_clause = "$source_entity_id_column IN (" . implode( ', ', array_fill( 0, count( $entity_ids ), '%d' ) ) . ')';
$destination_entity_table = $this->schema_config['destination']['entity']['table_name'];
$destination_entity_id_column = $this->schema_config['destination']['entity']['id_column'];
$destination_source_id_mapping_column = $this->schema_config['destination']['entity']['source_id_column'];
if ( $this->schema_config['source']['excluded_keys'] ) {
$key_placeholder = implode( ',', array_fill( 0, count( $this->schema_config['source']['excluded_keys'] ), '%s' ) );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $source_meta_key_column is escated for backticks, $key_placeholder is hardcoded.
$exclude_clause = $wpdb->prepare( "source.$source_meta_key_column NOT IN ( $key_placeholder )", $this->schema_config['source']['excluded_keys'] );
$where_clause = "$where_clause AND $exclude_clause";
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->prepare(
"
SELECT
source.`$source_entity_id_column` as source_entity_id,
destination.`$destination_entity_id_column` as destination_entity_id,
source.`$source_meta_key_column` as meta_key,
source.`$source_meta_value_column` as meta_value
FROM `$source_meta_table` source
JOIN `$destination_entity_table` destination ON destination.`$destination_source_id_mapping_column` = source.`$source_entity_id_column`
WHERE $where_clause ORDER BY $order_by
",
$entity_ids
);
// phpcs:enable
}
}

View File

@ -0,0 +1,232 @@
<?php
/**
* Class for implementing migration from wp_posts and wp_postmeta to custom order tables.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
use Automattic\WooCommerce\Database\Migrations\MigrationErrorLogger;
/**
* Class WPPostToCOTMigrator
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class WPPostToCOTMigrator {
/**
* Error logger for migration errors.
*
* @var MigrationErrorLogger $error_logger
*/
private $error_logger;
/**
* Migrator instance to migrate data into wc_order table.
*
* @var WPPostToOrderTableMigrator
*/
private $order_table_migrator;
/**
* Migrator instance to migrate billing data into address table.
*
* @var WPPostToOrderAddressTableMigrator
*/
private $billing_address_table_migrator;
/**
* Migrator instance to migrate shipping data into address table.
*
* @var WPPostToOrderAddressTableMigrator
*/
private $shipping_address_table_migrator;
/**
* Migrator instance to migrate operational data.
*
* @var WPPostToOrderOpTableMigrator
*/
private $operation_data_table_migrator;
/**
* Migrator instance to migrate meta data.
*
* @var MetaToMetaTableMigrator
*/
private $meta_table_migrator;
/**
* WPPostToCOTMigrator constructor.
*/
public function __construct() {
$this->order_table_migrator = new WPPostToOrderTableMigrator();
$this->billing_address_table_migrator = new WPPostToOrderAddressTableMigrator( 'billing' );
$this->shipping_address_table_migrator = new WPPostToOrderAddressTableMigrator( 'shipping' );
$this->operation_data_table_migrator = new WPPostToOrderOpTableMigrator();
$meta_data_config = $this->get_config_for_meta_table();
$this->meta_table_migrator = new MetaToMetaTableMigrator( $meta_data_config );
$this->error_logger = new MigrationErrorLogger();
}
/**
* Generate config for meta data migration.
*
* @return array Meta data migration config.
*/
private function get_config_for_meta_table() {
global $wpdb;
// TODO: Remove hardcoding.
$this->table_names = array(
'orders' => $wpdb->prefix . 'wc_orders',
'addresses' => $wpdb->prefix . 'wc_order_addresses',
'op_data' => $wpdb->prefix . 'wc_order_operational_data',
'meta' => $wpdb->prefix . 'wc_orders_meta',
);
$excluded_columns = array_keys( $this->order_table_migrator->get_meta_column_config() );
$excluded_columns = array_merge( $excluded_columns, array_keys( $this->billing_address_table_migrator->get_meta_column_config() ) );
$excluded_columns = array_merge( $excluded_columns, array_keys( $this->shipping_address_table_migrator->get_meta_column_config() ) );
$excluded_columns = array_merge( $excluded_columns, array_keys( $this->operation_data_table_migrator->get_meta_column_config() ) );
return array(
'source' => array(
'meta' => array(
'table_name' => $wpdb->postmeta,
'entity_id_column' => 'post_id',
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
),
'excluded_keys' => $excluded_columns,
),
'destination' => array(
'meta' => array(
'table_name' => $this->table_names['meta'],
'entity_id_column' => 'order_id',
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_type' => 'int',
),
'entity' => array(
'table_name' => $this->table_names['orders'],
'source_id_column' => 'post_id',
'id_column' => 'id',
),
),
);
}
/**
* Process next migration batch, uses option `wc_cot_migration` to checkpoints of what have been processed so far.
*
* @param int $batch_size Batch size of records to migrate.
*
* @return bool True if migration is completed, false if there are still records to process.
*/
public function process_next_migration_batch( $batch_size = 100 ) {
$order_post_ids = $this->get_next_batch_ids( $batch_size );
if ( 0 === count( $order_post_ids ) ) {
return true;
}
$this->process_migration_for_ids( $order_post_ids );
$last_post_migrated = max( $order_post_ids );
$this->update_checkpoint( $last_post_migrated );
return false;
}
/**
* Process migration for specific order post IDs.
*
* @param array $order_post_ids List of post IDs to migrate.
*/
public function process_migration_for_ids( $order_post_ids ) {
$this->order_table_migrator->process_migration_batch_for_ids( $order_post_ids );
$this->billing_address_table_migrator->process_migration_batch_for_ids( $order_post_ids );
$this->shipping_address_table_migrator->process_migration_batch_for_ids( $order_post_ids );
$this->operation_data_table_migrator->process_migration_batch_for_ids( $order_post_ids );
// TODO: Add resilience for meta migrations.
// $this->process_meta_migration( $order_post_ids );
// TODO: Return merged error array.
}
/**
* Process migration for metadata for given post ids.
*
* @param array $order_post_ids Post IDs.
*/
private function process_meta_migration( $order_post_ids ) {
global $wpdb;
$data_to_migrate = $this->meta_table_migrator->fetch_data_for_migration_for_ids( $order_post_ids );
$insert_queries = $this->meta_table_migrator->generate_insert_sql_for_batch( $data_to_migrate['data'], 'insert' );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $insert_queries should already be escaped in the generating function.
$result = $wpdb->query( $insert_queries );
if ( count( $data_to_migrate['data'] ) !== $result ) {
// TODO: Find and log entity ids that were not inserted.
echo 'error';
}
}
/**
* Method to migrate single record.
*
* @param int $post_id Post ID of record to migrate.
*/
public function process_single( $post_id ) {
$this->process_migration_for_ids( array( $post_id ) );
// TODO: Return error.
}
/**
* Helper function to get where clause to send to MetaToCustomTableMigrator instance.
*
* @param int $batch_size Number of orders in batch.
*
* @return array List of IDs in the current patch.
*/
private function get_next_batch_ids( $batch_size ) {
global $wpdb;
$checkpoint = $this->get_checkpoint();
$post_ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT ID FROM $wpdb->posts WHERE ID > %d AND post_type = %s ORDER BY ID ASC LIMIT %d ",
$checkpoint['id'],
'shop_order',
$batch_size
)
);
return $post_ids;
}
/**
* Current checkpoint status.
*
* @return false|mixed|void
*/
private function get_checkpoint() {
return get_option( 'wc_cot_migration', array( 'id' => 0 ) );
}
/**
* Updates current checkpoint
*
* @param int $id Order ID.
*/
public function update_checkpoint( $id ) {
return update_option( 'wc_cot_migration', array( 'id' => $id ), false );
}
/**
* Remove checkpoint.
*
* @return bool Whether checkpoint was removed.
*/
public function delete_checkpoint() {
return delete_option( 'wp_cot_migration' );
}
}

View File

@ -0,0 +1,191 @@
<?php
/**
* Class for WPPost to wc_order_address table migrator.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
/**
* Class WPPostToOrderAddressTableMigrator
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class WPPostToOrderAddressTableMigrator extends MetaToCustomTableMigrator {
/**
* Type of addresses being migrated, could be billing|shipping.
*
* @var $type
*/
protected $type;
/**
* WPPostToOrderAddressTableMigrator constructor.
*
* @param string $type Type of addresses being migrated, could be billing|shipping.
*/
public function __construct( $type ) {
$this->type = $type;
parent::__construct();
}
/**
* Get schema config for wp_posts and wc_order_address table.
*
* @return array Config.
*/
public function get_schema_config() {
global $wpdb;
// TODO: Remove hardcoding.
$this->table_names = array(
'orders' => $wpdb->prefix . 'wc_orders',
'addresses' => $wpdb->prefix . 'wc_order_addresses',
'op_data' => $wpdb->prefix . 'wc_order_operational_data',
'meta' => $wpdb->prefix . 'wc_orders_meta',
);
return array(
'source' => array(
'entity' => array(
'table_name' => $this->table_names['orders'],
'meta_rel_column' => 'post_id',
'destination_rel_column' => 'id',
'primary_key' => 'post_id',
),
'meta' => array(
'table_name' => $wpdb->postmeta,
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_column' => 'post_id',
),
),
'destination' => array(
'table_name' => $this->table_names['addresses'],
'source_rel_column' => 'order_id',
'primary_key' => 'id',
'primary_key_type' => 'int',
),
);
}
/**
* Get columns config.
*
* @return \string[][] Config.
*/
public function get_core_column_mapping() {
$type = $this->type;
return array(
'id' => array(
'type' => 'int',
'destination' => 'order_id',
),
'type' => array(
'type' => 'string',
'destination' => 'address_type',
'select_clause' => "'$type'",
),
);
}
/**
* Get meta data config.
*
* @return \string[][] Config.
*/
public function get_meta_column_config() {
$type = $this->type;
return array(
"_{$type}_first_name" => array(
'type' => 'string',
'destination' => 'first_name',
),
"_{$type}_last_name" => array(
'type' => 'string',
'destination' => 'last_name',
),
"_{$type}_company" => array(
'type' => 'string',
'destination' => 'company',
),
"_{$type}_address_1" => array(
'type' => 'string',
'destination' => 'address_1',
),
"_{$type}_address_2" => array(
'type' => 'string',
'destination' => 'address_2',
),
"_{$type}_city" => array(
'type' => 'string',
'destination' => 'city',
),
"_{$type}_state" => array(
'type' => 'string',
'destination' => 'state',
),
"_{$type}_postcode" => array(
'type' => 'string',
'destination' => 'postcode',
),
"_{$type}_country" => array(
'type' => 'string',
'destination' => 'country',
),
"_{$type}_email" => array(
'type' => 'string',
'destination' => 'email',
),
"_{$type}_phone" => array(
'type' => 'string',
'destination' => 'phone',
),
);
}
/**
* We overwrite this method to add a subclause to only fetch address of current type.
*
* @param array $entity_ids List of entity IDs to verify.
*
* @return array Already migrated entities, would be of the form
* array(
* '$source_id1' => array(
* 'source_id' => $source_id1,
* 'destination_id' => $destination_id1,
* ),
* ...
* )
*/
public function get_already_migrated_records( $entity_ids ) {
global $wpdb;
$source_table = $this->schema_config['source']['entity']['table_name'];
$source_destination_join_column = $this->schema_config['source']['entity']['destination_rel_column'];
$source_primary_key_column = $this->schema_config['source']['entity']['primary_key'];
$destination_table = $this->schema_config['destination']['table_name'];
$destination_source_join_column = $this->schema_config['destination']['source_rel_column'];
$destination_primary_key_column = $this->schema_config['destination']['primary_key'];
$address_type = $this->type;
$entity_id_placeholder = implode( ',', array_fill( 0, count( $entity_ids ), '%d' ) );
$already_migrated_entity_ids = $wpdb->get_results(
$wpdb->prepare(
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- All columns and table names are hardcoded.
"
SELECT source.`$source_primary_key_column` as source_id, destination.`$destination_primary_key_column` as destination_id
FROM `$destination_table` destination
JOIN `$source_table` source ON source.`$source_destination_join_column` = destination.`$destination_source_join_column`
WHERE source.`$source_primary_key_column` IN ( $entity_id_placeholder ) AND destination.`address_type` = '$address_type'
",
$entity_ids
)
// phpcs:enable
);
return array_column( $already_migrated_entity_ids, null, 'source_id' );
}
}

View File

@ -0,0 +1,139 @@
<?php
/**
* Class for WPPost to wc_order_operational_details migrator.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
/**
* Class WPPostToOrderOpTableMigrator
*
* @package Automattic\WooCommerce\Database\Migrations\CustomOrderTable
*/
class WPPostToOrderOpTableMigrator extends MetaToCustomTableMigrator {
/**
* Get schema config for wp_posts and wc_order_operational_detail table.
*
* @return array Config.
*/
public function get_schema_config() {
global $wpdb;
// TODO: Remove hardcoding.
$this->table_names = array(
'orders' => $wpdb->prefix . 'wc_orders',
'addresses' => $wpdb->prefix . 'wc_order_addresses',
'op_data' => $wpdb->prefix . 'wc_order_operational_data',
'meta' => $wpdb->prefix . 'wc_orders_meta',
);
return array(
'source' => array(
'entity' => array(
'table_name' => $this->table_names['orders'],
'meta_rel_column' => 'post_id',
'destination_rel_column' => 'id',
'primary_key' => 'post_id',
),
'meta' => array(
'table_name' => $wpdb->postmeta,
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_column' => 'post_id',
),
),
'destination' => array(
'table_name' => $this->table_names['op_data'],
'source_rel_column' => 'order_id',
'primary_key' => 'id',
'primary_key_type' => 'int',
),
);
}
/**
* Get columns config.
*
* @return \string[][] Config.
*/
public function get_core_column_mapping() {
return array(
'id' => array(
'type' => 'int',
'destination' => 'order_id',
),
);
}
/**
* Get meta data config.
*
* @return \string[][] Config.
*/
public function get_meta_column_config() {
return array(
'_created_via' => array(
'type' => 'string',
'destination' => 'created_via',
),
'_order_version' => array(
'type' => 'string',
'destination' => 'woocommerce_version',
),
'_prices_include_tax' => array(
'type' => 'bool',
'destination' => 'prices_include_tax',
),
'_recorded_coupon_usage_counts' => array(
'type' => 'bool',
'destination' => 'coupon_usages_are_counted',
),
'_download_permissions_granted' => array(
'type' => 'bool',
'destination' => 'download_permission_granted',
),
'_cart_hash' => array(
'type' => 'string',
'destination' => 'cart_hash',
),
'_new_order_email_sent' => array(
'type' => 'bool',
'destination' => 'new_order_email_sent',
),
'_order_key' => array(
'type' => 'string',
'destination' => 'order_key',
),
'_order_stock_reduced' => array(
'type' => 'bool',
'destination' => 'order_stock_reduced',
),
'_date_paid' => array(
'type' => 'date_epoch',
'destination' => 'date_paid_gmt',
),
'_date_completed' => array(
'type' => 'date_epoch',
'destination' => 'date_completed_gmt',
),
'_order_shipping_tax' => array(
'type' => 'decimal',
'destination' => 'shipping_tax_amount',
),
'_order_shipping' => array(
'type' => 'decimal',
'destination' => 'shipping_total_amount',
),
'_cart_discount_tax' => array(
'type' => 'decimal',
'destination' => 'discount_tax_amount',
),
'_cart_discount' => array(
'type' => 'decimal',
'destination' => 'discount_total_amount',
),
);
}
}

View File

@ -0,0 +1,132 @@
<?php
/**
* Class for WPPost To order table migrator.
*/
namespace Automattic\WooCommerce\Database\Migrations\CustomOrderTable;
/**
* Class WPPostToOrderTableMigrator.
*/
class WPPostToOrderTableMigrator extends MetaToCustomTableMigrator {
/**
* Get schema config for wp_posts and wc_order table.
*
* @return array Config.
*/
public function get_schema_config() {
global $wpdb;
// TODO: Remove hardcoding.
$this->table_names = array(
'orders' => $wpdb->prefix . 'wc_orders',
'addresses' => $wpdb->prefix . 'wc_order_addresses',
'op_data' => $wpdb->prefix . 'wc_order_operational_data',
'meta' => $wpdb->prefix . 'wc_orders_meta',
);
return array(
'source' => array(
'entity' => array(
'table_name' => $wpdb->posts,
'meta_rel_column' => 'ID',
'destination_rel_column' => 'ID',
'primary_key' => 'ID',
),
'meta' => array(
'table_name' => $wpdb->postmeta,
'meta_key_column' => 'meta_key',
'meta_value_column' => 'meta_value',
'entity_id_column' => 'post_id',
),
),
'destination' => array(
'table_name' => $this->table_names['orders'],
'source_rel_column' => 'post_id',
'primary_key' => 'id',
'primary_key_type' => 'int',
),
);
}
/**
* Get columns config.
*
* @return \string[][] Config.
*/
public function get_core_column_mapping() {
return array(
'ID' => array(
'type' => 'int',
'destination' => 'post_id',
),
'post_status' => array(
'type' => 'string',
'destination' => 'status',
),
'post_date_gmt' => array(
'type' => 'date',
'destination' => 'date_created_gmt',
),
'post_modified_gmt' => array(
'type' => 'date',
'destination' => 'date_updated_gmt',
),
'post_parent' => array(
'type' => 'int',
'destination' => 'parent_order_id',
),
);
}
/**
* Get meta data config.
*
* @return \string[][] Config.
*/
public function get_meta_column_config() {
return array(
'_order_currency' => array(
'type' => 'string',
'destination' => 'currency',
),
'_order_tax' => array(
'type' => 'decimal',
'destination' => 'tax_amount',
),
'_order_total' => array(
'type' => 'decimal',
'destination' => 'total_amount',
),
'_customer_user' => array(
'type' => 'int',
'destination' => 'customer_id',
),
'_billing_email' => array(
'type' => 'string',
'destination' => 'billing_email',
),
'_payment_method' => array(
'type' => 'string',
'destination' => 'payment_method',
),
'_payment_method_title' => array(
'type' => 'string',
'destination' => 'payment_method_title',
),
'_customer_ip_address' => array(
'type' => 'string',
'destination' => 'ip_address',
),
'_customer_user_agent' => array(
'type' => 'string',
'destination' => 'user_agent',
),
'_transaction_id' => array(
'type' => 'string',
'destination' => 'transaction_id',
),
);
}
}

View File

@ -0,0 +1,17 @@
<?php
/**
* Error logger for custom table migrations.
*/
namespace Automattic\WooCommerce\Database\Migrations;
/**
* Class MigrationErrorLogger.
*
* Error logging for custom table migrations.
*
* @package Automattic\WooCommerce\Database\Migrations
*/
class MigrationErrorLogger extends \WC_Logger {
}

View File

@ -0,0 +1,92 @@
<?php
/**
* Helper class with utility functions for migrations.
*/
namespace Automattic\WooCommerce\Database\Migrations;
/**
* Class MigrationHelper.
*
* Helper class to assist with migration related operations.
*/
class MigrationHelper {
/**
* Placeholders that we will use in building $wpdb queries.
*
* @var string[]
*/
private static $wpdb_placeholder_for_type = array(
'int' => '%d',
'decimal' => '%f',
'string' => '%s',
'date' => '%s',
'date_epoch' => '%s',
'bool' => '%d',
);
/**
* Get insert clause for appropriate switch.
*
* @param string $switch Name of the switch to use.
*
* @return string Insert clause.
*/
public static function get_insert_switch( $switch ) {
switch ( $switch ) {
case 'insert_ignore':
$insert_query = 'INSERT IGNORE';
break;
case 'replace': // delete and then insert.
$insert_query = 'REPLACE';
break;
case 'update':
$insert_query = 'UPDATE';
break;
case 'insert':
default:
$insert_query = 'INSERT';
}
return $insert_query;
}
/**
* Helper method to escape backtick in various schema fields.
*
* @param array $schema_config Schema config.
*
* @return array Schema config escaped for backtick.
*/
public static function escape_schema_for_backtick( $schema_config ) {
array_walk( $schema_config['source']['entity'], array( self::class, 'escape_and_add_backtick' ) );
array_walk( $schema_config['source']['meta'], array( self::class, 'escape_and_add_backtick' ) );
array_walk( $schema_config['destination'], array( self::class, 'escape_and_add_backtick' ) );
return $schema_config;
}
/**
* Helper method to escape backtick in column and table names.
* WP does not provide a method to escape table/columns names yet, but hopefully soon in @link https://core.trac.wordpress.org/ticket/52506
*
* @param string|array $identifier Column or table name.
*
* @return array|string|string[] Escaped identifier.
*/
public static function escape_and_add_backtick( $identifier ) {
return '`' . str_replace( '`', '``', $identifier ) . '`';
}
/**
* Return $wpdb->prepare placeholder for data type.
*
* @param string $type Data type.
*
* @return string $wpdb placeholder.
*/
public static function get_wpdb_placeholder_for_type( $type ) {
return self::$wpdb_placeholder_for_type[ $type ];
}
}

View File

@ -17,7 +17,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
*
* @return string The custom orders table name.
*/
public function get_orders_table_name() {
public static function get_orders_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders';
}
@ -27,7 +27,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
*
* @return string The order addresses table name.
*/
public function get_addresses_table_name() {
public static function get_addresses_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_addresses';
}
@ -37,11 +37,21 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
*
* @return string The orders operational data table name.
*/
public function get_operational_data_table_name() {
public static function get_operational_data_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_order_operational_data';
}
/**
* Get the orders meta data table name.
*
* @return string Name of order meta data table.
*/
public static function get_meta_table_name() {
global $wpdb;
return $wpdb->prefix . 'wc_orders_meta';
}
/**
* Get the names of all the tables involved in the custom orders table feature.
*
@ -52,6 +62,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
$this->get_orders_table_name(),
$this->get_addresses_table_name(),
$this->get_operational_data_table_name(),
$this->get_meta_table_name(),
);
}
@ -185,6 +196,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
$orders_table_name = $this->get_orders_table_name();
$addresses_table_name = $this->get_addresses_table_name();
$operational_data_table_name = $this->get_operational_data_table_name();
$meta_table = $this->get_meta_table_name();
$sql = "
CREATE TABLE $orders_table_name (
@ -234,7 +246,7 @@ CREATE TABLE $operational_data_table_name (
woocommerce_version varchar(20) NULL,
prices_include_tax tinyint(1) NULL,
coupon_usages_are_counted tinyint(1) NULL,
download_permissionis_granted tinyint(1) NULL,
download_permission_granted tinyint(1) NULL,
cart_hash varchar(100) NULL,
new_order_email_sent tinyint(1) NULL,
order_key varchar(100) NULL,
@ -242,12 +254,21 @@ CREATE TABLE $operational_data_table_name (
date_paid_gmt datetime NULL,
date_completed_gmt datetime NULL,
shipping_tax_amount decimal(26, 8) NULL,
shopping_total_amount decimal(26, 8) NULL,
shipping_total_amount decimal(26, 8) NULL,
discount_tax_amount decimal(26, 8) NULL,
discount_total_amount decimal(26, 8) NULL,
KEY order_id (order_id),
KEY order_key (order_key)
);";
);
CREATE TABLE $meta_table (
id bigint(20) unsigned auto_increment primary key,
order_id bigint(20) unsigned null,
meta_key varchar(255),
meta_value text null,
KEY meta_key_value (meta_key, meta_value(100))
);
";
return $sql;
}
}

View File

@ -0,0 +1,37 @@
<?php
/**
* Service provider for COTMigration.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\WPPostToCOTMigrator;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
/**
* Class COTMigrationServiceProvider
*
* @package Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders
*/
class COTMigrationServiceProvider extends AbstractServiceProvider {
/**
* Services provided by this provider.
*
* @var string[]
*/
protected $provides = array(
WPPostToCOTMigrator::class,
);
/**
* Use the register method to register items with the container via the
* protected $this->leagueContainer property or the `getLeagueContainer` method
* from the ContainerAwareTrait.
*
* @return void
*/
public function register() {
$this->share( WPPostToCOTMigrator::class );
}
}

View File

@ -0,0 +1,381 @@
<?php
/**
* Tests for WPPostToCOTMigrator class.
*/
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\WPPostToCOTMigrator;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\DataStores\Orders\OrdersTableDataStore;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\CustomerHelper;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\ShippingHelper;
/**
* Class WPPostToCOTMigratorTest.
*/
class WPPostToCOTMigratorTest extends WC_Unit_Test_Case {
/**
* @var DataSynchronizer
*/
private $synchronizer;
/**
* @var WPPostToCOTMigrator
*/
private $sut;
/**
* @var OrdersTableDataStore;
*/
private $data_store;
/**
* Setup data_store and sut.
*/
public function setUp(): void {
parent::setUp();
$this->create_order_custom_table_if_not_exist();
$this->data_store = wc_get_container()->get( OrdersTableDataStore::class );
$this->sut = wc_get_container()->get( WPPostToCOTMigrator::class );
}
/**
* Test that migration for a normal order happens as expected.
*/
public function test_process_next_migration_batch_normal_order() {
$order = wc_get_order( $this->create_complex_wp_post_order() );
$this->clear_all_orders_and_reset_checkpoint();
$this->sut->process_next_migration_batch( 100 );
$this->assert_core_data_is_migrated( $order );
$this->assert_order_addresses_are_migrated( $order );
$this->assert_order_op_data_is_migrated( $order );
}
/**
* Test that already migrated order isn't migrated twice.
*/
public function test_process_next_migration_batch_already_migrated_order() {
global $wpdb;
$order = wc_get_order( $this->create_complex_wp_post_order() );
$this->clear_all_orders_and_reset_checkpoint();
// Run the migration once.
$this->sut->process_next_migration_batch( 100 );
// Delete checkpoint and run migration again, assert there are still no duplicates.
$this->sut->update_checkpoint( 0 );
$this->sut->process_next_migration_batch( 100 );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->assertEquals(
1,
$wpdb->get_var(
"
SELECT COUNT(*) FROM {$this->data_store::get_orders_table_name()}
WHERE post_id = {$order->get_id()}"
),
'Order record is duplicated.'
);
$order_id = $wpdb->get_var( "SELECT id FROM {$this->data_store::get_orders_table_name()} WHERE post_id = {$order->get_id()}" );
$this->assertEquals(
1,
$wpdb->get_var(
"
SELECT COUNT(*) FROM {$this->data_store::get_addresses_table_name()}
WHERE order_id = {$order_id} AND address_type = 'billing'
"
)
);
$this->assertEquals(
1,
$wpdb->get_var(
"
SELECT COUNT(*) FROM {$this->data_store::get_addresses_table_name()}
WHERE order_id = {$order_id} AND address_type = 'shipping'
"
)
);
$this->assertEquals(
1,
$wpdb->get_var(
"
SELECT COUNT(*) FROM {$this->data_store::get_operational_data_table_name()}
WHERE order_id = {$order_id}
"
)
);
// phpcs:enable
}
/**
* Test that when an order is partially migrated, it can still be resumed as expected.
*/
public function test_process_next_migration_batch_interrupted_migrating_order() {
$this->markTestSkipped();
}
/**
* Test that invalid order data is not migrated but logged.
*/
public function test_process_next_migration_batch_invalid_order_data() {
$this->markTestSkipped();
}
/**
* Test when one order is invalid but other one is valid in a migration batch.
*/
public function test_process_next_migration_batch_invalid_valid_order_combo() {
$this->markTestSkipped();
}
/**
* Helper method to get order object from COT.
*
* @param WP_Post $post_order Post object for order.
*
* @return array|object|void|null DB object from COT.
*/
private function get_order_from_cot( $post_order ) {
global $wpdb;
$order_table = $this->data_store::get_orders_table_name();
$query = "SELECT * FROM $order_table WHERE post_id = {$post_order->get_id()};";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $wpdb->get_row( $query );
}
/**
* Helper method to get address details from DB.
*
* @param int $order_id Order ID.
* @param string $address_type Address Type.
*
* @return array|object|void|null DB object.
*/
private function get_address_details_from_cot( $order_id, $address_type ) {
global $wpdb;
$address_table = $this->data_store::get_addresses_table_name();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_row( "SELECT * FROM $address_table WHERE order_id = $order_id AND address_type = '$address_type';" );
}
/**
* Helper method to get operational details from COT.
*
* @param int $order_id Order ID.
*
* @return array|object|void|null DB Object.
*/
private function get_order_operational_data_from_cot( $order_id ) {
global $wpdb;
$operational_data_table = $this->data_store::get_operational_data_table_name();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->get_row( "SELECT * FROM $operational_data_table WHERE order_id = $order_id;" );
}
/**
* Helper method to create complex wp_post based order.
*
* @return int Order ID
*/
private function create_complex_wp_post_order() {
update_option( 'woocommerce_prices_include_tax', 'yes' );
update_option( 'woocommerce_calc_taxes', 'yes' );
$uniq_cust_id = wp_generate_password( 10, false );
$customer = CustomerHelper::create_customer( "user$uniq_cust_id", $uniq_cust_id, "user$uniq_cust_id@example.com" );
$tax_rate = array(
'tax_rate_country' => '',
'tax_rate_state' => '',
'tax_rate' => '15.0000',
'tax_rate_name' => 'tax',
'tax_rate_priority' => '1',
'tax_rate_order' => '1',
'tax_rate_shipping' => '1',
);
WC_Tax::_insert_tax_rate( $tax_rate );
ShippingHelper::create_simple_flat_rate();
$order = OrderHelper::create_order();
// Make sure this is a wp_post order.
$post = get_post( $order->get_id() );
$this->assertNotNull( $post, 'Order is not created in wp_post table.' );
$this->assertEquals( 'shop_order', $post->post_type, 'Order is not created in wp_post table.' );
$order->save();
$order->set_status( 'completed' );
$order->set_currency( 'INR' );
$order->set_customer_id( $customer->get_id() );
$order->set_billing_email( $customer->get_billing_email() );
$payment_gateway = new WC_Mock_Payment_Gateway();
$order->set_payment_method( 'mock' );
$order->set_transaction_id( 'mock1' );
$order->set_customer_ip_address( '1.1.1.1' );
$order->set_customer_user_agent( 'wc_unit_tests' );
$order->save();
$order->set_shipping_first_name( 'Albert' );
$order->set_shipping_last_name( 'Einstein' );
$order->set_shipping_company( 'The Olympia Academy' );
$order->set_shipping_address_1( '112 Mercer Street' );
$order->set_shipping_address_2( 'Princeton' );
$order->set_shipping_city( 'New Jersey' );
$order->set_shipping_postcode( '08544' );
$order->set_shipping_phone( '299792458' );
$order->set_shipping_country( 'US' );
$order->set_created_via( 'unit_tests' );
$order->set_version( '0.0.2' );
$order->set_prices_include_tax( true );
wc_update_coupon_usage_counts( $order->get_id() );
$order->get_data_store()->set_download_permissions_granted( $order, true );
$order->set_cart_hash( '1234' );
$order->update_meta_data( '_new_order_email_sent', 'true' );
$order->update_meta_data( '_order_stock_reduced', 'true' );
$order->set_date_paid( time() );
$order->set_date_completed( time() );
$order->calculate_shipping();
$order->save();
$order->save_meta_data();
return $order->get_id();
}
/**
* Helper method to assert core data is migrated.
*
* @param WC_Order $order Order object.
*/
private function assert_core_data_is_migrated( $order ) {
$db_order = $this->get_order_from_cot( $order );
// Verify core data.
$this->assertEquals( $order->get_id(), $db_order->post_id );
$this->assertEquals( 'wc-' . $order->get_status(), $db_order->status );
$this->assertEquals( 'INR', $db_order->currency );
$this->assertEquals( $order->get_customer_id(), $db_order->customer_id );
$this->assertEquals( $order->get_billing_email(), $db_order->billing_email );
$this->assertEquals( $order->get_payment_method(), $db_order->payment_method );
$this->assertEquals(
$order->get_date_created()->date( DATE_ISO8601 ),
( new WC_DateTime( $db_order->date_created_gmt ) )->date( DATE_ISO8601 )
);
$this->assertEquals( $order->get_date_modified()->date( DATE_ISO8601 ), ( new WC_DateTime( $db_order->date_updated_gmt ) )->date( DATE_ISO8601 ) );
$this->assertEquals( $order->get_parent_id(), $db_order->parent_order_id );
$this->assertEquals( $order->get_payment_method_title(), $db_order->payment_method_title );
$this->assertEquals( $order->get_transaction_id(), $db_order->transaction_id );
$this->assertEquals( $order->get_customer_ip_address(), $db_order->ip_address );
$this->assertEquals( $order->get_customer_user_agent(), $db_order->user_agent );
}
/**
* Helper method to assert addresses are migrated.
*
* @param WC_Order $order Order object.
*/
private function assert_order_addresses_are_migrated( $order ) {
$db_order = $this->get_order_from_cot( $order );
// Verify order billing address.
$db_order_address = $this->get_address_details_from_cot( $db_order->id, 'billing' );
$this->assertEquals( $order->get_billing_first_name(), $db_order_address->first_name );
$this->assertEquals( $order->get_billing_last_name(), $db_order_address->last_name );
$this->assertEquals( $order->get_billing_company(), $db_order_address->company );
$this->assertEquals( $order->get_billing_address_1(), $db_order_address->address_1 );
$this->assertEquals( $order->get_billing_address_2(), $db_order_address->address_2 );
$this->assertEquals( $order->get_billing_city(), $db_order_address->city );
$this->assertEquals( $order->get_billing_postcode(), $db_order_address->postcode );
$this->assertEquals( $order->get_billing_country(), $db_order_address->country );
$this->assertEquals( $order->get_billing_email(), $db_order_address->email );
$this->assertEquals( $order->get_billing_phone(), $db_order_address->phone );
// Verify order shipping address.
$db_order_address = $this->get_address_details_from_cot( $db_order->id, 'shipping' );
$this->assertEquals( $order->get_shipping_first_name(), $db_order_address->first_name );
$this->assertEquals( $order->get_shipping_last_name(), $db_order_address->last_name );
$this->assertEquals( $order->get_shipping_company(), $db_order_address->company );
$this->assertEquals( $order->get_shipping_address_1(), $db_order_address->address_1 );
$this->assertEquals( $order->get_shipping_address_2(), $db_order_address->address_2 );
$this->assertEquals( $order->get_shipping_city(), $db_order_address->city );
$this->assertEquals( $order->get_shipping_postcode(), $db_order_address->postcode );
$this->assertEquals( $order->get_shipping_country(), $db_order_address->country );
$this->assertEquals( $order->get_shipping_phone(), $db_order_address->phone );
}
/**
* Helper method to assert operational data is migrated.
*
* @param WC_Order $order Order object.
*/
private function assert_order_op_data_is_migrated( $order ) {
$db_order = $this->get_order_from_cot( $order );
// Verify order operational data.
$db_order_op_data = $this->get_order_operational_data_from_cot( $db_order->id );
$this->assertEquals( $order->get_created_via(), $db_order_op_data->created_via );
$this->assertEquals( $order->get_version(), $db_order_op_data->woocommerce_version );
$this->assertEquals( $order->get_prices_include_tax(), $db_order_op_data->prices_include_tax );
$this->assertEquals(
wc_string_to_bool( $order->get_data_store()->get_recorded_coupon_usage_counts( $order ) ),
$db_order_op_data->coupon_usages_are_counted
);
$this->assertEquals(
wc_string_to_bool( $order->get_data_store()->get_download_permissions_granted( $order ) ),
$db_order_op_data->download_permission_granted
);
$this->assertEquals( $order->get_cart_hash(), $db_order_op_data->cart_hash );
$this->assertEquals(
wc_string_to_bool( $order->get_meta( '_new_order_email_sent' ) ),
$db_order_op_data->new_order_email_sent
);
$this->assertEquals( $order->get_order_key(), $db_order_op_data->order_key );
$this->assertEquals( $order->get_data_store()->get_stock_reduced( $order ), $db_order_op_data->order_stock_reduced );
$this->assertEquals(
$order->get_date_paid()->date( DATE_ISO8601 ),
( new WC_DateTime( $db_order_op_data->date_paid_gmt ) )->date( DATE_ISO8601 )
);
$this->assertEquals(
$order->get_date_completed()->date( DATE_ISO8601 ),
( new WC_DateTime( $db_order_op_data->date_completed_gmt ) )->date( DATE_ISO8601 )
);
$this->assertEquals( (float) $order->get_shipping_tax(), (float) $db_order_op_data->shipping_tax_amount );
$this->assertEquals( (float) $order->get_shipping_total(), (float) $db_order_op_data->shipping_total_amount );
$this->assertEquals( (float) $order->get_discount_tax(), (float) $db_order_op_data->discount_tax_amount );
$this->assertEquals( (float) $order->get_discount_total(), (float) $db_order_op_data->discount_total_amount );
}
/**
* Helper method to clear checkout and truncate order tables.
*/
private function clear_all_orders_and_reset_checkpoint() {
global $wpdb;
$order_tables = $this->data_store->get_all_table_names();
foreach ( $order_tables as $table ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "TRUNCATE table $table;" );
}
$this->sut->delete_checkpoint();
}
/**
* Helper method to create custom tables if not present.
*/
private function create_order_custom_table_if_not_exist() {
$order_table_controller = wc_get_container()
->get( 'Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController' );
$order_table_controller->show_feature();
$this->synchronizer = wc_get_container()
->get( DataSynchronizer::class );
if ( ! $this->synchronizer->check_orders_table_exists() ) {
$this->synchronizer->create_database_tables();
}
}
}