Merged in feature/MAW-855-import-code-into-aws (pull request #2)

code import from pantheon

* code import from pantheon
This commit is contained in:
Tony Volpe
2023-12-04 23:08:14 +00:00
parent 8c9b1312bc
commit 8f4b5efda6
4766 changed files with 185592 additions and 239967 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template registry.
*/
final class BlockTemplateRegistry {
/**
* Class instance.
*
* @var BlockTemplateRegistry|null
*/
private static $instance = null;
/**
* Templates.
*
* @var array
*/
protected $templates = array();
/**
* Get the instance of the class.
*/
public static function get_instance(): BlockTemplateRegistry {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register a single template.
*
* @param BlockTemplateInterface $template Template to register.
*
* @throws \ValueError If a template with the same ID already exists.
*/
public function register( BlockTemplateInterface $template ) {
$id = $template->get_id();
if ( isset( $this->templates[ $id ] ) ) {
throw new \ValueError( 'A template with the specified ID already exists in the registry.' );
}
/**
* Fires when a template is registered.
*
* @param BlockTemplateInterface $template Template that was registered.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_register', $template );
$this->templates[ $id ] = $template;
}
/**
* Get the registered templates.
*/
public function get_all_registered(): array {
return $this->templates;
}
/**
* Get a single registered template.
*
* @param string $id ID of the template.
*/
public function get_registered( $id ): BlockTemplateInterface {
return isset( $this->templates[ $id ] ) ? $this->templates[ $id ] : null;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
/**
* Block template controller.
*/
class BlockTemplatesController {
/**
* Block template registry
*
* @var BlockTemplateRegistry
*/
private $block_template_registry;
/**
* Block template transformer.
*
* @var TemplateTransformer
*/
private $template_transformer;
/**
* Init.
*/
public function init( $block_template_registry, $template_transformer ) {
$this->block_template_registry = $block_template_registry;
$this->template_transformer = $template_transformer;
add_action( 'rest_api_init', array( $this, 'register_templates' ) );
}
/**
* Register templates in the blocks endpoint.
*/
public function register_templates() {
$templates = $this->block_template_registry->get_all_registered();
foreach ( $templates as $template ) {
add_filter( 'pre_get_block_templates', function( $query_result, $query, $template_type ) use( $template ) {
if ( ! isset( $query['area'] ) || $query['area'] !== $template->get_area() ) {
return $query_result;
}
$wp_block_template = $this->template_transformer->transform( $template );
$query_result[] = $wp_block_template;
return $query_result;
}, 10, 3 );
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Template transformer.
*/
class TemplateTransformer {
/**
* Transform the WooCommerceBlockTemplate to a WP_Block_Template.
*
* @param object $block_template The product template.
*/
public function transform( BlockTemplateInterface $block_template ): \WP_Block_Template {
$template = new \WP_Block_Template();
$template->id = $block_template->get_id();
$template->theme = 'woocommerce/woocommerce';
$template->content = $block_template->get_formatted_template();
$template->source = 'plugin';
$template->slug = $block_template->get_id();
$template->type = 'wp_template';
$template->title = $block_template->get_title();
$template->description = $block_template->get_description();
$template->status = 'publish';
$template->has_theme_file = true;
$template->origin = 'plugin';
$template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are.
$template->post_types = array(); // Don't appear in any Edit Post template selector dropdown.
$template->area = $block_template->get_area();
return $template;
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Block configuration used to specify blocks in BlockTemplate.
*/
class AbstractBlock implements BlockInterface {
use BlockFormattedTemplateTrait;
/**
* The block name.
*
* @var string
*/
private $name;
/**
* The block ID.
*
* @var string
*/
private $id;
/**
* The block order.
*
* @var int
*/
private $order = 10;
/**
* The block attributes.
*
* @var array
*/
private $attributes = [];
/**
* The block hide conditions.
*
* @var array
*/
private $hide_conditions = [];
/**
* The block hide conditions counter.
*
* @var int
*/
private $hide_conditions_counter = 0;
/**
* The block template that this block belongs to.
*
* @var BlockTemplate
*/
private $root_template;
/**
* The parent container.
*
* @var ContainerInterface
*/
private $parent;
/**
* Block constructor.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param BlockContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
*/
public function __construct( array $config, BlockTemplateInterface &$root_template, ContainerInterface &$parent = null ) {
$this->validate( $config, $root_template, $parent );
$this->root_template = $root_template;
$this->parent = is_null( $parent ) ? $root_template : $parent;
$this->name = $config[ self::NAME_KEY ];
if ( ! isset( $config[ self::ID_KEY ] ) ) {
$this->id = $this->root_template->generate_block_id( $this->get_name() );
} else {
$this->id = $config[ self::ID_KEY ];
}
if ( isset( $config[ self::ORDER_KEY ] ) ) {
$this->order = $config[ self::ORDER_KEY ];
}
if ( isset( $config[ self::ATTRIBUTES_KEY ] ) ) {
$this->attributes = $config[ self::ATTRIBUTES_KEY ];
}
if ( isset( $config[ self::HIDE_CONDITIONS_KEY ] ) ) {
foreach ( $config[ self::HIDE_CONDITIONS_KEY ] as $hide_condition ) {
$this->add_hide_condition( $hide_condition['expression'] );
}
}
}
/**
* Validate block configuration.
*
* @param array $config The block configuration.
* @param BlockTemplateInterface $root_template The block template that this block belongs to.
* @param ContainerInterface|null $parent The parent block container.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If the parent block container does not belong to the same template as the block.
*/
protected function validate( array $config, BlockTemplateInterface &$root_template, ContainerInterface &$parent = null ) {
if ( isset( $parent ) && ( $parent->get_root_template() !== $root_template ) ) {
throw new \ValueError( 'The parent block must belong to the same template as the block.' );
}
if ( ! isset( $config[ self::NAME_KEY ] ) || ! is_string( $config[ self::NAME_KEY ] ) ) {
throw new \ValueError( 'The block name must be specified.' );
}
if ( isset( $config[ self::ORDER_KEY ] ) && ! is_int( $config[ self::ORDER_KEY ] ) ) {
throw new \ValueError( 'The block order must be an integer.' );
}
if ( isset( $config[ self::ATTRIBUTES_KEY ] ) && ! is_array( $config[ self::ATTRIBUTES_KEY ] ) ) {
throw new \ValueError( 'The block attributes must be an array.' );
}
}
/**
* Get the block name.
*/
public function get_name(): string {
return $this->name;
}
/**
* Get the block ID.
*/
public function get_id(): string {
return $this->id;
}
/**
* Get the block order.
*/
public function get_order(): int {
return $this->order;
}
/**
* Set the block order.
*
* @param int $order The block order.
*/
public function set_order( int $order ) {
$this->order = $order;
}
/**
* Get the block attributes.
*/
public function get_attributes(): array {
return $this->attributes;
}
/**
* Set the block attributes.
*
* @param array $attributes The block attributes.
*/
public function set_attributes( array $attributes ) {
$this->attributes = $attributes;
}
/**
* Get the template that this block belongs to.
*/
public function &get_root_template(): BlockTemplateInterface {
return $this->root_template;
}
/**
* Get the parent block container.
*/
public function &get_parent(): ContainerInterface {
return $this->parent;
}
/**
* Remove the block from its parent.
*/
public function remove() {
$this->parent->remove_block( $this->id );
}
/**
* Check if the block is detached from its parent block container or the template it belongs to.
*
* @return bool True if the block is detached from its parent block container or the template it belongs to.
*/
public function is_detached(): bool {
$is_in_parent = $this->parent->get_block( $this->id ) === $this;
$is_in_root_template = $this->get_root_template()->get_block( $this->id ) === $this;
return ! ( $is_in_parent && $is_in_root_template );
}
/**
* Add a hide condition to the block.
*
* The hide condition is a JavaScript-like expression that will be evaluated on the client to determine if the block should be hidden.
* See [@woocommerce/expression-evaluation](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/expression-evaluation/README.md) for more details.
*
* @param string $expression An expression, which if true, will hide the block.
*/
public function add_hide_condition( string $expression ): string {
$key = 'k' . $this->hide_conditions_counter;
$this->hide_conditions_counter++;
// Storing the expression in an array to allow for future expansion
// (such as adding the plugin that added the condition).
$this->hide_conditions[ $key ] = [
'expression' => $expression,
];
return $key;
}
/**
* Remove a hide condition from the block.
*
* @param string $key The key of the hide condition to remove.
*/
public function remove_hide_condition( string $key ) {
unset( $this->hide_conditions[ $key ] );
}
/**
* Get the hide conditions of the block.
*/
public function get_hide_conditions(): array {
return $this->hide_conditions;
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template class.
*/
abstract class AbstractBlockTemplate implements BlockTemplateInterface {
use BlockContainerTrait;
/**
* Get the template ID.
*/
abstract public function get_id(): string;
/**
* Get the template title.
*/
public function get_title(): string {
return '';
}
/**
* Get the template description.
*/
public function get_description(): string {
return '';
}
/**
* Get the template area.
*/
public function get_area(): string {
return 'uncategorized';
}
/**
* The block cache.
*
* @var BlockInterface[]
*/
private $block_cache = [];
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface {
return $this->block_cache[ $block_id ] ?? null;
}
/**
* Caches a block in the template. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's add_inner_block() method.
*
* @param BlockInterface $block The block to cache.
*
* @throws \ValueError If a block with the specified ID already exists in the template.
* @throws \ValueError If the block template that the block belongs to is not this template.
*
* @ignore
*/
public function cache_block( BlockInterface &$block ) {
$id = $block->get_id();
if ( isset( $this->block_cache[ $id ] ) ) {
throw new \ValueError( 'A block with the specified ID already exists in the template.' );
}
if ( $block->get_root_template() !== $this ) {
throw new \ValueError( 'The block template that the block belongs to must be the same as this template.' );
}
$this->block_cache[ $id ] = $block;
}
/**
* Uncaches a block in the template. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's remove_block() method.
*
* @param string $block_id The block ID.
*
* @ignore
*/
public function uncache_block( string $block_id ) {
if ( isset( $this->block_cache[ $block_id ] ) ) {
unset( $this->block_cache[ $block_id ] );
}
}
/**
* Generate a block ID based on a base.
*
* @param string $id_base The base to use when generating an ID.
* @return string
*/
public function generate_block_id( string $id_base ): string {
$instance_count = 0;
do {
$instance_count++;
$block_id = $id_base . '-' . $instance_count;
} while ( isset( $this->block_cache[ $block_id ] ) );
return $block_id;
}
/**
* Get the root template.
*/
public function &get_root_template(): BlockTemplateInterface {
return $this;
}
/**
* Get the inner blocks as a formatted template.
*/
public function get_formatted_template(): array {
$inner_blocks = $this->get_inner_blocks_sorted_by_order();
$inner_blocks_formatted_template = array_map(
function( BlockInterface $block ) {
return $block->get_formatted_template();
},
$inner_blocks
);
return $inner_blocks_formatted_template;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Generic block with container properties to be used in BlockTemplate.
*/
class Block extends AbstractBlock implements BlockContainerInterface {
use BlockContainerTrait;
/**
* Add an inner block to this block.
*
* @param array $block_config The block data.
*/
public function &add_block( array $block_config ): BlockInterface {
$block = new Block( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}

View File

@@ -0,0 +1,356 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
/**
* Trait for block containers.
*/
trait BlockContainerTrait {
use BlockFormattedTemplateTrait {
get_formatted_template as get_block_formatted_template;
}
/**
* The inner blocks.
*
* @var BlockInterface[]
*/
private $inner_blocks = [];
// phpcs doesn't take into account exceptions thrown by called methods.
// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Add a block to the block container.
*
* @param BlockInterface $block The block.
*
* @throws \ValueError If the block configuration is invalid.
* @throws \ValueError If a block with the specified ID already exists in the template.
* @throws \UnexpectedValueException If the block container is not the parent of the block.
* @throws \UnexpectedValueException If the block container's root template is not the same as the block's root template.
*/
protected function &add_inner_block( BlockInterface $block ): BlockInterface {
if ( $block->get_parent() !== $this ) {
throw new \UnexpectedValueException( 'The block container is not the parent of the block.' );
}
if ( $block->get_root_template() !== $this->get_root_template() ) {
throw new \UnexpectedValueException( 'The block container\'s root template is not the same as the block\'s root template.' );
}
$is_detached = method_exists( $this, 'is_detached' ) && $this->is_detached();
if ( $is_detached ) {
BlockTemplateLogger::get_instance()->warning(
'Block added to detached container. Block will not be included in the template, since the container will not be included in the template.',
[
'block' => $block,
'container' => $this,
'template' => $this->get_root_template(),
]
);
} else {
$this->get_root_template()->cache_block( $block );
}
$this->inner_blocks[] = &$block;
$this->do_after_add_block_action( $block );
$this->do_after_add_specific_block_action( $block );
return $block;
}
// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.WrongNumber
/**
* Checks if a block is a descendant of the block container.
*
* @param BlockInterface $block The block.
*/
private function is_block_descendant( BlockInterface $block ): bool {
$parent = $block->get_parent();
if ( $parent === $this ) {
return true;
}
if ( ! $parent instanceof BlockInterface ) {
return false;
}
return $this->is_block_descendant( $parent );
}
/**
* Get a block by ID.
*
* @param string $block_id The block ID.
*/
public function get_block( string $block_id ): ?BlockInterface {
foreach ( $this->inner_blocks as $block ) {
if ( $block->get_id() === $block_id ) {
return $block;
}
}
foreach ( $this->inner_blocks as $block ) {
if ( $block instanceof ContainerInterface ) {
$block = $block->get_block( $block_id );
if ( $block ) {
return $block;
}
}
}
return null;
}
/**
* Remove a block from the block container.
*
* @param string $block_id The block ID.
*
* @throws \UnexpectedValueException If the block container is not an ancestor of the block.
*/
public function remove_block( string $block_id ) {
$root_template = $this->get_root_template();
$block = $root_template->get_block( $block_id );
if ( ! $block ) {
return;
}
if ( ! $this->is_block_descendant( $block ) ) {
throw new \UnexpectedValueException( 'The block container is not an ancestor of the block.' );
}
// If the block is a container, remove all of its blocks.
if ( $block instanceof ContainerInterface ) {
$block->remove_blocks();
}
$parent = $block->get_parent();
$parent->remove_inner_block( $block );
}
/**
* Remove all blocks from the block container.
*/
public function remove_blocks() {
array_map(
function ( BlockInterface $block ) {
$this->remove_block( $block->get_id() );
},
$this->inner_blocks
);
}
/**
* Remove a block from the block container's inner blocks. This is an internal method and should not be called directly
* except for from the BlockContainerTrait's remove_block() method.
*
* @param BlockInterface $block The block.
*/
public function remove_inner_block( BlockInterface $block ) {
// Remove block from root template's cache.
$root_template = $this->get_root_template();
$root_template->uncache_block( $block->get_id() );
$this->inner_blocks = array_filter(
$this->inner_blocks,
function ( BlockInterface $inner_block ) use ( $block ) {
return $inner_block !== $block;
}
);
BlockTemplateLogger::get_instance()->info(
'Block removed from template.',
[
'block' => $block,
'template' => $root_template,
]
);
$this->do_after_remove_block_action( $block );
$this->do_after_remove_specific_block_action( $block );
}
/**
* Get the inner blocks sorted by order.
*/
private function get_inner_blocks_sorted_by_order(): array {
$sorted_inner_blocks = $this->inner_blocks;
usort(
$sorted_inner_blocks,
function( BlockInterface $a, BlockInterface $b ) {
return $a->get_order() <=> $b->get_order();
}
);
return $sorted_inner_blocks;
}
/**
* Get the inner blocks as a formatted template.
*/
public function get_formatted_template(): array {
$arr = $this->get_block_formatted_template();
$inner_blocks = $this->get_inner_blocks_sorted_by_order();
if ( ! empty( $inner_blocks ) ) {
$arr[] = array_map(
function( BlockInterface $block ) {
return $block->get_formatted_template();
},
$inner_blocks
);
}
return $arr;
}
/**
* Do the `woocommerce_block_template_after_add_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is added to a block container.
*
* This action can be used to perform actions after a block is added to the block container,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_add_block', $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after adding block to template.',
'woocommerce_block_template_after_add_block',
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_add_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_add_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is added to a template with a specific area.
*
* This action can be used to perform actions after a specific block is added to a template with a specific area,
* such as adding a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after adding block to template.',
"woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_add_block_{$block->get_id()}",
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_after_remove_block` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_block_action( BlockInterface $block ) {
try {
/**
* Action called after a block is removed from a block container.
*
* This action can be used to perform actions after a block is removed from the block container,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( 'woocommerce_block_template_after_remove_block', $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after removing block from template.',
'woocommerce_block_template_after_remove_block',
$block,
$e
);
}
}
/**
* Do the `woocommerce_block_template_area_{template_area}_after_remove_block_{block_id}` action.
* Handle exceptions thrown by the action.
*
* @param BlockInterface $block The block.
*/
private function do_after_remove_specific_block_action( BlockInterface $block ) {
try {
/**
* Action called after a specific block is removed from a template with a specific area.
*
* This action can be used to perform actions after a specific block is removed from a template with a specific area,
* such as removing a dependent block.
*
* @param BlockInterface $block The block.
*
* @since 8.2.0
*/
do_action( "woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}", $block );
} catch ( \Exception $e ) {
$this->handle_exception_doing_action(
'Error after removing block from template.',
"woocommerce_block_template_area_{$this->get_root_template()->get_area()}_after_remove_block_{$block->get_id()}",
$block,
$e
);
}
}
/**
* Handle an exception thrown by an action.
*
* @param string $message The message.
* @param string $action_tag The action tag.
* @param BlockInterface $block The block.
* @param \Exception $e The exception.
*/
private function handle_exception_doing_action( string $message, string $action_tag, BlockInterface $block, \Exception $e ) {
BlockTemplateLogger::get_instance()->error(
$message,
[
'exception' => $e,
'action' => $action_tag,
'container' => $this,
'block' => $block,
'template' => $this->get_root_template(),
],
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
/**
* Trait for block formatted template.
*/
trait BlockFormattedTemplateTrait {
/**
* Get the block configuration as a formatted template.
*
* @return array The block configuration as a formatted template.
*/
public function get_formatted_template(): array {
$arr = [
$this->get_name(),
array_merge(
$this->get_attributes(),
[
'_templateBlockId' => $this->get_id(),
'_templateBlockOrder' => $this->get_order(),
],
! empty( $this->get_hide_conditions() ) ? [
'_templateBlockHideConditions' => $this->get_formatted_hide_conditions(),
] : []
),
];
return $arr;
}
/**
* Get the block hide conditions formatted for inclusion in a formatted template.
*/
private function get_formatted_hide_conditions(): array {
$formatted_hide_conditions = array_map(
function( $hide_condition ) {
return [
'expression' => $hide_condition['expression'],
];
},
array_values( $this->get_hide_conditions() )
);
return $formatted_hide_conditions;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\ContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Block template class.
*/
class BlockTemplate extends AbstractBlockTemplate {
/**
* Get the template ID.
*/
public function get_id(): string {
return 'woocommerce-block-template';
}
/**
* Add an inner block to this template.
*
* @param array $block_config The block data.
*/
public function add_block( array $block_config ): BlockInterface {
$block = new Block( $block_config, $this->get_root_template(), $this );
return $this->add_inner_block( $block );
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace Automattic\WooCommerce\Internal\Admin\BlockTemplates;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockContainerInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockInterface;
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
/**
* Logger for block template modifications.
*/
class BlockTemplateLogger {
/**
* Singleton instance.
*
* @var BlockTemplateLogger
*/
protected static $instance = null;
/**
* Logger instance.
*
* @var \WC_Logger
*/
protected $logger = null;
/**
* Get the singleton instance.
*/
public static function get_instance(): BlockTemplateLogger {
if ( ! self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
protected function __construct() {
$this->logger = wc_get_logger();
}
/**
* Log an informational message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function info( string $message, array $info = [] ) {
$this->logger->info(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Log a warning message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function warning( string $message, array $info = [] ) {
$this->logger->warning(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Log an error message.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
public function error( string $message, array $info = [] ) {
$this->logger->error(
$this->format_message( $message, $info ),
[ 'source' => 'block_template' ]
);
}
/**
* Format a message for logging.
*
* @param string $message Message to log.
* @param array $info Additional info to log.
*/
private function format_message( string $message, array $info = [] ): string {
$formatted_message = sprintf(
"%s\n%s",
$message,
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
print_r( $this->format_info( $info ), true ),
);
return $formatted_message;
}
/**
* Format info for logging.
*
* @param array $info Info to log.
*/
private function format_info( array $info ): array {
$formatted_info = $info;
if ( isset( $info['exception'] ) && $info['exception'] instanceof \Exception ) {
$formatted_info['exception'] = $this->format_exception( $info['exception'] );
}
if ( isset( $info['container'] ) ) {
if ( $info['container'] instanceof BlockContainerInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
} elseif ( $info['container'] instanceof BlockTemplateInterface ) {
$formatted_info['container'] = $this->format_template( $info['container'] );
} elseif ( $info['container'] instanceof BlockInterface ) {
$formatted_info['container'] = $this->format_block( $info['container'] );
}
}
if ( isset( $info['block'] ) && $info['block'] instanceof BlockInterface ) {
$formatted_info['block'] = $this->format_block( $info['block'] );
}
if ( isset( $info['template'] ) && $info['template'] instanceof BlockTemplateInterface ) {
$formatted_info['template'] = $this->format_template( $info['template'] );
}
return $formatted_info;
}
/**
* Format an exception for logging.
*
* @param \Exception $exception Exception to format.
*/
private function format_exception( \Exception $exception ): array {
return [
'message' => $exception->getMessage(),
'source' => "{$exception->getFile()}: {$exception->getLine()}",
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r
'trace' => print_r( $this->format_exception_trace( $exception->getTrace() ), true ),
];
}
/**
* Format an exception trace for logging.
*
* @param array $trace Exception trace to format.
*/
private function format_exception_trace( array $trace ): array {
$formatted_trace = [];
foreach ( $trace as $source ) {
$formatted_trace[] = "{$source['file']}: {$source['line']}";
}
return $formatted_trace;
}
/**
* Format a block template for logging.
*
* @param BlockTemplateInterface $template Template to format.
*/
private function format_template( BlockTemplateInterface $template ): string {
return "{$template->get_id()} (area: {$template->get_area()})";
}
/**
* Format a block for logging.
*
* @param BlockInterface $block Block to format.
*/
private function format_block( BlockInterface $block ): string {
return "{$block->get_id()} (name: {$block->get_name()})";
}
}

View File

@@ -36,7 +36,6 @@ use Automattic\WooCommerce\Internal\Admin\Notes\PerformanceOnMobile;
use Automattic\WooCommerce\Internal\Admin\Notes\PersonalizeStore;
use Automattic\WooCommerce\Internal\Admin\Notes\RealTimeOrderAlerts;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\TestCheckout;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\UnsecuredReportFiles;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
@@ -92,7 +91,6 @@ class Events {
PerformanceOnMobile::class,
PersonalizeStore::class,
RealTimeOrderAlerts::class,
TestCheckout::class,
TrackingOptIn::class,
WooCommercePayments::class,
WooCommerceSubscriptions::class,
@@ -156,7 +154,7 @@ class Events {
MerchantEmailNotifications::run();
}
if ( Features::is_enabled( 'onboarding' ) ) {
if ( Features::is_enabled( 'core-profiler' ) ) {
( new MailchimpScheduler() )->run();
}
}
@@ -180,7 +178,14 @@ class Events {
$note = clone $note_from_db;
$note->set_title( $note_from_class->get_title() );
$note->set_content( $note_from_class->get_content() );
$note->set_actions( $note_from_class->get_actions() );
$actions = $note_from_class->get_actions();
foreach ( $actions as $action ) {
$matching_action = $note->get_action( $action->name );
if ( $matching_action && $matching_action->id ) {
$action->id = $matching_action->id;
}
}
$note->set_actions( $actions );
return $note;
}
break;

View File

@@ -14,7 +14,6 @@ use Automattic\WooCommerce\Internal\Admin\Notes\WooSubscriptionsNotes;
use Automattic\WooCommerce\Internal\Admin\Notes\TrackingOptIn;
use Automattic\WooCommerce\Internal\Admin\Notes\WooCommercePayments;
use Automattic\WooCommerce\Internal\Admin\Notes\InstallJPAndWCSPlugins;
use Automattic\WooCommerce\Internal\Admin\Notes\TestCheckout;
use Automattic\WooCommerce\Internal\Admin\Notes\SellingOnlineCourses;
use Automattic\WooCommerce\Internal\Admin\Notes\MerchantEmailNotifications;
use Automattic\WooCommerce\Internal\Admin\Notes\MagentoMigration;
@@ -177,7 +176,6 @@ class FeaturePlugin {
new TrackingOptIn();
new WooCommercePayments();
new InstallJPAndWCSPlugins();
new TestCheckout();
new SellingOnlineCourses();
new MagentoMigration();

View File

@@ -8,6 +8,7 @@ namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplatesController;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews;
use Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsCommentsOverrides;
use Automattic\WooCommerce\Internal\Admin\Settings;
@@ -72,6 +73,7 @@ class Loader {
wc_get_container()->get( Reviews::class );
wc_get_container()->get( ReviewsCommentsOverrides::class );
wc_get_container()->get( BlockTemplatesController::class );
add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_classes' ) );
add_filter( 'admin_title', array( __CLASS__, 'update_admin_title' ) );
@@ -91,6 +93,8 @@ class Loader {
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
add_action( 'admin_init', array( __CLASS__, 'deactivate_wc_admin_plugin' ) );
add_action( 'load-themes.php', array( __CLASS__, 'add_appearance_theme_view_tracks_event' ) );
}
/**
@@ -173,7 +177,7 @@ class Loader {
}
$classes = explode( ' ', trim( $admin_body_class ) );
$classes[] = 'woocommerce-page';
$classes[] = 'woocommerce-admin-page';
if ( PageController::is_embed_page() ) {
$classes[] = 'woocommerce-embed-page';
}
@@ -332,9 +336,7 @@ class Loader {
}
$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
if ( class_exists( 'Jetpack' ) ) {
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
}
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
if ( ! empty( $preload_data_endpoints ) ) {
$preload_data = array_reduce(
array_values( $preload_data_endpoints ),
@@ -569,4 +571,11 @@ class Loader {
delete_option( 'woocommerce_onboarding_homepage_post_id' );
}
}
/**
* Adds the appearance_theme_view Tracks event.
*/
public static function add_appearance_theme_view_tracks_event() {
wc_admin_record_tracks_event( 'appearance_theme_view', array() );
}
}

View File

@@ -136,36 +136,27 @@ class MarketingSpecs {
/**
* Load knowledge base posts from WooCommerce.com
*
* @param string|null $category Category of posts to retrieve.
* @param string|null $topic The topic of marketing knowledgebase to retrieve.
* @return array
*/
public function get_knowledge_base_posts( ?string $category ): array {
$kb_transient = self::KNOWLEDGE_BASE_TRANSIENT;
$categories = array(
'marketing' => 1744,
'coupons' => 25202,
);
// Default to marketing category (if no category set on the kb component).
if ( ! empty( $category ) && array_key_exists( $category, $categories ) ) {
$category_id = $categories[ $category ];
$kb_transient = $kb_transient . '_' . strtolower( $category );
} else {
$category_id = $categories['marketing'];
public function get_knowledge_base_posts( ?string $topic ): array {
// Default to the marketing topic (if no topic is set on the kb component).
if ( empty( $topic ) ) {
$topic = 'marketing';
}
$kb_transient = self::KNOWLEDGE_BASE_TRANSIENT . '_' . strtolower( $topic );
$posts = get_transient( $kb_transient );
if ( false === $posts ) {
$request_url = add_query_arg(
array(
'categories' => $category_id,
'page' => 1,
'per_page' => 8,
'_embed' => 1,
'page' => 1,
'per_page' => 8,
'_embed' => 1,
),
'https://woocommerce.com/wp-json/wp/v2/posts?utm_medium=product'
'https://woocommerce.com/wp-json/wccom/marketing-knowledgebase/v1/posts/' . $topic
);
$request = wp_remote_get(

View File

@@ -0,0 +1,56 @@
<?php
/**
* WooCommerce Marketplace.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
/**
* Contains backend logic for the Marketplace feature.
*/
class Marketplace {
/**
* Class initialization, to be executed when the class is resolved by the container.
*/
final public function init() {
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
}
}
/**
* Registers report pages.
*/
public function register_pages() {
$marketplace_pages = self::get_marketplace_pages();
foreach ( $marketplace_pages as $marketplace_page ) {
if ( ! is_null( $marketplace_page ) ) {
wc_admin_register_page( $marketplace_page );
}
}
}
/**
* Get report pages.
*/
public static function get_marketplace_pages() {
$marketplace_pages = array(
array(
'id' => 'woocommerce-marketplace',
'parent' => 'woocommerce',
'title' => __( 'Extensions', 'woocommerce' ),
'path' => '/extensions',
),
);
/**
* The marketplace items used in the menu.
*
* @since 8.0
*/
return apply_filters( 'woocommerce_marketplace_menu_items', $marketplace_pages );
}
}

View File

@@ -1,104 +0,0 @@
<?php
/**
* WooCommerce Admin Test Checkout.
*
* Adds a note to remind the user to test their store checkout.
*
* @package WooCommerce\Admin
*/
namespace Automattic\WooCommerce\Internal\Admin\Notes;
defined( 'ABSPATH' ) || exit;
use Automattic\WooCommerce\Admin\Notes\Note;
use Automattic\WooCommerce\Admin\Notes\NoteTraits;
/**
* Test_Checkout
*/
class TestCheckout {
/**
* Note traits.
*/
use NoteTraits;
/**
* Name of the note for use in the database.
*/
const NOTE_NAME = 'wc-admin-test-checkout';
/**
* Completed tasks option name.
*/
const TASK_LIST_TRACKED_TASKS = 'woocommerce_task_list_tracked_completed_tasks';
/**
* Constructor.
*/
public function __construct() {
add_action( 'update_option_' . self::TASK_LIST_TRACKED_TASKS, array( $this, 'possibly_add_note' ) );
}
/**
* Get the note.
*
* @return Note|null
*/
public static function get_note() {
$onboarding_profile = get_option( 'woocommerce_onboarding_profile', array() );
// Confirm that $onboarding_profile is set.
if ( empty( $onboarding_profile ) ) {
return;
}
// Make sure that the person who filled out the OBW was not setting up
// the store for their customer/client.
if (
! isset( $onboarding_profile['setup_client'] ) ||
$onboarding_profile['setup_client']
) {
return;
}
// Make sure payments task was completed.
$completed_tasks = get_option( self::TASK_LIST_TRACKED_TASKS, array() );
if ( ! in_array( 'payments', $completed_tasks, true ) ) {
return;
}
// Make sure that products were added within the previous 1/2 hour.
$query = new \WC_Product_Query(
array(
'limit' => 1,
'status' => 'publish',
'orderby' => 'date',
'order' => 'ASC',
)
);
$products = $query->get_products();
if ( 0 === count( $products ) ) {
return;
}
$oldest_product_timestamp = $products[0]->get_date_created()->getTimestamp();
$half_hour_in_seconds = 30 * MINUTE_IN_SECONDS;
if ( ( time() - $oldest_product_timestamp ) > $half_hour_in_seconds ) {
return;
}
$content = __( 'Make sure that your checkout is working properly before you launch your store. Go through your checkout process in its entirety: from adding a product to your cart, choosing a shipping location, and making a payment.', 'woocommerce' );
$note = new Note();
$note->set_title( __( 'Don\'t forget to test your checkout', 'woocommerce' ) );
$note->set_content( $content );
$note->set_content_data( (object) array() );
$note->set_type( Note::E_WC_ADMIN_NOTE_INFORMATIONAL );
$note->set_name( self::NOTE_NAME );
$note->set_source( 'woocommerce-admin' );
$note->add_action( 'test-checkout', __( 'Test checkout', 'woocommerce' ), wc_get_page_permalink( 'shop' ) );
return $note;
}
}

View File

@@ -58,10 +58,10 @@ class COTRedirectionController {
$params['_wpnonce'] = wp_create_nonce( 'bulk-posts' );
}
// If an `order` array parameter is present, rename as `post`.
if ( isset( $params['order'] ) && is_array( $params['order'] ) ) {
$params['post'] = $params['order'];
unset( $params['order'] );
// If an `id` array parameter is present, rename as `post`.
if ( isset( $params['id'] ) && is_array( $params['id'] ) ) {
$params['post'] = $params['id'];
unset( $params['id'] );
}
$params['post_type'] = 'shop_order';

View File

@@ -208,7 +208,6 @@ class Edit {
* @return void
*/
public function handle_order_update() {
global $theorder;
if ( ! isset( $this->order ) ) {
return;
}
@@ -233,6 +232,8 @@ class Edit {
*/
do_action( 'woocommerce_process_shop_order_meta', $this->order->get_id(), $this->order );
$this->custom_meta_box->handle_metadata_changes($this->order);
// Order updated message.
$this->message = 1;
@@ -388,6 +389,7 @@ class Edit {
<input type="hidden" id="hiddenaction" name="action" value="<?php echo esc_attr( $form_action ); ?>"/>
<input type="hidden" id="original_order_status" name="original_order_status" value="<?php echo esc_attr( $this->order->get_status() ); ?>"/>
<input type="hidden" id="referredby" name="referredby" value="<?php echo $referer ? esc_url( $referer ) : ''; ?>"/>
<input type="hidden" id="post_ID" name="post_ID" value="<?php echo esc_attr( $this->order->get_id() ); ?>"/>
<div id="poststuff">
<div id="post-body"
class="metabox-holder columns-<?php echo ( 1 === get_current_screen()->get_columns() ) ? '1' : '2'; ?>">

View File

@@ -99,8 +99,10 @@ class EditLock {
return $response;
}
unset( $response['wp-refresh-post-lock'] );
$order = wc_get_order( $order_id );
if ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) {
if ( ! $order || ( ! current_user_can( get_post_type_object( $order->get_type() )->cap->edit_post, $order->get_id() ) && ! current_user_can( 'manage_woocommerce' ) ) ) {
return $response;
}

View File

@@ -230,6 +230,17 @@ class ListTable extends WP_List_Table {
$title = esc_html( $post_type->labels->name );
$add_new = esc_html( $post_type->labels->add_new );
$new_page_link = $this->page_controller->get_new_page_url( $this->order_type );
$search_label = '';
if ( ! empty( $this->order_query_args['s'] ) ) {
$search_label = '<span class="subtitle">';
$search_label .= sprintf(
/* translators: %s: Search query. */
__( 'Search results for: %s', 'woocommerce' ),
'<strong>' . esc_html( $this->order_query_args['s'] ) . '</strong>'
);
$search_label .= '</span>';
}
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo wp_kses_post(
@@ -237,6 +248,7 @@ class ListTable extends WP_List_Table {
<div class='wrap'>
<h1 class='wp-heading-inline'>{$title}</h1>
<a href='" . esc_url( $new_page_link ) . "' class='page-title-action'>{$add_new}</a>
{$search_label}
<hr class='wp-header-end'>"
);
@@ -711,20 +723,15 @@ class ListTable extends WP_List_Table {
global $wpdb;
$orders_table = esc_sql( OrdersTableDataStore::get_orders_table_name() );
$utc_offset = wc_timezone_offset();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$order_dates = $wpdb->get_results(
"
SELECT DISTINCT YEAR( date_created_gmt ) AS year,
MONTH( date_created_gmt ) AS month
FROM $orders_table
WHERE status NOT IN (
'trash'
)
ORDER BY year DESC, month DESC;
SELECT DISTINCT YEAR( t.date_created_local ) AS year,
MONTH( t.date_created_local ) AS month
FROM ( SELECT DATE_ADD( date_created_gmt, INTERVAL $utc_offset SECOND ) AS date_created_local FROM $orders_table WHERE status != 'trash' ) t
ORDER BY year DESC, month DESC
"
);
@@ -867,7 +874,7 @@ class ListTable extends WP_List_Table {
public function column_cb( $item ) {
ob_start();
?>
<input id="cb-select-<?php echo esc_attr( $item->get_id() ); ?>" type="checkbox" name="<?php echo esc_attr( $this->_args['singular'] ); ?>[]" value="<?php echo esc_attr( $item->get_id() ); ?>" />
<input id="cb-select-<?php echo esc_attr( $item->get_id() ); ?>" type="checkbox" name="id[]" value="<?php echo esc_attr( $item->get_id() ); ?>" />
<div class="locked-indicator">
<span class="locked-indicator-icon" aria-hidden="true"></span>
@@ -1197,7 +1204,7 @@ class ListTable extends WP_List_Table {
$action = 'delete';
} else {
$ids = isset( $_REQUEST['order'] ) ? array_reverse( array_map( 'absint', (array) $_REQUEST['order'] ) ) : array();
$ids = isset( $_REQUEST['id'] ) ? array_reverse( array_map( 'absint', (array) $_REQUEST['id'] ) ) : array();
}
/**
@@ -1340,13 +1347,11 @@ class ListTable extends WP_List_Table {
* @return int Number of orders that were trashed.
*/
private function do_delete( array $ids, bool $force_delete = false ): int {
$orders_store = wc_get_container()->get( OrdersTableDataStore::class );
$delete_args = $force_delete ? array( 'force_delete' => true ) : array();
$changed = 0;
foreach ( $ids as $id ) {
$order = wc_get_order( $id );
$orders_store->delete( $order, $delete_args );
$order->delete( $force_delete );
$updated_order = wc_get_order( $id );
if ( ( $force_delete && false === $updated_order ) || ( ! $force_delete && $updated_order->get_status() === 'trash' ) ) {

View File

@@ -242,21 +242,22 @@ class CustomMetaBox {
* @return void
*/
private function handle_add_meta( WC_Order $order, string $meta_key, string $meta_value ) {
$order_data_store = WC_Data_Store::load( 'order' );
$count = 0;
$count = 0;
if ( is_protected_meta( $meta_key ) ) {
wp_send_json_error( 'protected_meta' );
wp_die();
}
$meta_id = $order_data_store->add_meta(
$order,
new WC_Meta_Data(
array(
'key' => $meta_key,
'value' => $meta_value,
)
)
);
$metas_for_current_key = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
$meta_ids = wp_list_pluck( $metas_for_current_key, 'id' );
$order->add_meta_data( $meta_key, $meta_value );
$order->save_meta_data();
$metas_for_current_key_with_new = wp_list_filter( $order->get_meta_data(), array( 'key' => $meta_key ) );
$meta_id = 0;
$new_meta_ids = wp_list_pluck( $metas_for_current_key_with_new, 'id' );
$new_meta_ids = array_values( array_diff( $new_meta_ids, $meta_ids ) );
if ( count( $new_meta_ids ) > 0 ) {
$meta_id = $new_meta_ids[0];
}
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
@@ -305,16 +306,9 @@ class CustomMetaBox {
wp_die();
}
$order_data_store = WC_Data_Store::load( 'order' );
$count = 0;
$meta_object = new WC_Meta_Data(
array(
'id' => $mid,
'key' => $key,
'value' => $value,
)
);
$order_data_store->update_meta( $order, $meta_object );
$count = 0;
$order->update_meta_data( $key, $value, $mid );
$order->save_meta_data();
$response = new WP_Ajax_Response(
array(
'what' => 'meta',
@@ -415,4 +409,52 @@ class CustomMetaBox {
}
wp_die( 0 );
}
/**
* Handle the possible changes in order metadata coming from an order edit page in admin
* (labeled "custom fields" in the UI).
*
* This method expects the $_POST array to contain a 'meta' key that is an associative
* array of [meta item id => [ 'key' => meta item name, 'value' => meta item value ];
* and also to contain (possibly empty) 'metakeyinput' and 'metavalue' keys.
*
* @param WC_Order $order The order to handle.
*/
public function handle_metadata_changes( $order ) {
$has_meta_changes = false;
$order_meta = $order->get_meta_data();
$order_meta =
array_combine(
array_map( fn( $meta ) => $meta->id, $order_meta ),
$order_meta
);
// phpcs:disable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
foreach ( ( $_POST['meta'] ?? array() ) as $request_meta_id => $request_meta_data ) {
$request_meta_id = wp_unslash( $request_meta_id );
$request_meta_key = wp_unslash( $request_meta_data['key'] );
$request_meta_value = wp_unslash( $request_meta_data['value'] );
if ( array_key_exists( $request_meta_id, $order_meta ) &&
( $order_meta[ $request_meta_id ]->key !== $request_meta_key || $order_meta[ $request_meta_id ]->value !== $request_meta_value ) ) {
$order->update_meta_data( $request_meta_key, $request_meta_value, $request_meta_id );
$has_meta_changes = true;
}
}
$request_new_key = wp_unslash( $_POST['metakeyinput'] ?? '' );
$request_new_value = wp_unslash( $_POST['metavalue'] ?? '' );
if ( '' !== $request_new_key ) {
$order->add_meta_data( $request_new_key, $request_new_value );
$has_meta_changes = true;
}
// phpcs:enable WordPress.Security.ValidatedSanitizedInput, WordPress.Security.NonceVerification.Missing
if ( $has_meta_changes ) {
$order->save();
}
}
}

View File

@@ -290,11 +290,6 @@ class PageController {
switch ( $this->current_action ) {
case 'edit_order':
case 'new_order':
if ( ! isset( $this->order_edit_form ) ) {
$this->order_edit_form = new Edit();
$this->order_edit_form->setup( $this->order );
}
$this->order_edit_form->set_current_action( $this->current_action );
$this->order_edit_form->display();
break;
case 'list_orders':
@@ -340,6 +335,22 @@ class PageController {
}
}
/**
* Prepares the order edit form for creating or editing an order.
*
* @see \Automattic\WooCommerce\Internal\Admin\Orders\Edit.
* @since 8.1.0
*/
private function prepare_order_edit_form(): void {
if ( ! $this->order || ! in_array( $this->current_action, array( 'new_order', 'edit_order' ), true ) ) {
return;
}
$this->order_edit_form = $this->order_edit_form ?? new Edit();
$this->order_edit_form->setup( $this->order );
$this->order_edit_form->set_current_action( $this->current_action );
}
/**
* Handles initialization of the orders edit form.
*
@@ -351,6 +362,8 @@ class PageController {
$this->verify_edit_permission();
$this->handle_edit_lock();
$theorder = $this->order;
$this->prepare_order_edit_form();
}
/**
@@ -371,6 +384,7 @@ class PageController {
$this->order = new $order_class_name();
$this->order->set_object_read( false );
$this->order->set_status( 'auto-draft' );
$this->order->set_created_via( 'admin' );
$this->order->save();
$this->handle_edit_lock();
@@ -380,6 +394,8 @@ class PageController {
}
$theorder = $this->order;
$this->prepare_order_edit_form();
}
/**

View File

@@ -84,7 +84,7 @@ class PostsRedirectionController {
$new_url = add_query_arg(
array(
'action' => $action,
'order' => $posts,
'id' => $posts,
'_wp_http_referer' => $this->page_controller->get_orders_url(),
'_wpnonce' => wp_create_nonce( 'bulk-orders' ),
),

View File

@@ -76,6 +76,7 @@ class DefaultFreeExtensions {
self::get_plugin( 'mailpoet' ),
self::get_plugin( 'google-listings-and-ads' ),
self::get_plugin( 'woocommerce-services:tax' ),
self::get_plugin( 'tiktok-for-business' ),
)
),
),
@@ -400,6 +401,16 @@ class DefaultFreeExtensions {
'value' => 'SE',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'JP',
'operation' => '=',
),
array(
'type' => 'base_location_country',
'value' => 'AE',
'operation' => '=',
),
),
),
DefaultPaymentGateways::get_rules_for_cbd( false ),
@@ -875,9 +886,9 @@ class DefaultFreeExtensions {
'install_priority' => 3,
),
'jetpack' => array(
'label' => __( 'Enhance security with Jetpack', 'woocommerce' ),
'label' => __( 'Boost content creation with Jetpack AI Assistant', 'woocommerce' ),
'image_url' => plugins_url( '/assets/images/core-profiler/logo-jetpack.svg', WC_PLUGIN_FILE ),
'description' => __( 'Get auto real-time backups, malware scans, and spam protection.', 'woocommerce' ),
'description' => __( 'Save time on content creation — unlock high-quality blog posts and pages using AI.', 'woocommerce' ),
'learn_more_link' => 'https://woocommerce.com/products/jetpack',
'install_priority' => 8,
),

View File

@@ -40,7 +40,7 @@ class OrdersScheduler extends ImportScheduler {
// Order and refund data must be run on these hooks to ensure meta data is set.
add_action( 'woocommerce_update_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_filter( 'woocommerce_create_order', array( __CLASS__, 'possibly_schedule_import' ) );
add_action( 'woocommerce_refund_created', array( __CLASS__, 'possibly_schedule_import' ) );
OrdersStatsDataStore::init();
@@ -210,13 +210,15 @@ AND status NOT IN ( 'wc-auto-draft', 'trash', 'auto-draft' )
* @param int $order_id Post ID.
*
* @internal
* @returns int The order id
*/
public static function possibly_schedule_import( $order_id ) {
if ( ! OrderUtil::is_order( $order_id, array( 'shop_order' ) ) && 'woocommerce_refund_created' !== current_filter() ) {
return;
return $order_id;
}
self::schedule_action( 'import', array( $order_id ) );
return $order_id;
}
/**

View File

@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Admin\API\Plugins;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Admin\API\Reports\Orders\DataStore as OrdersDataStore;
use Automattic\WooCommerce\Admin\PluginsHelper;
use Automattic\WooCommerce\Internal\Admin\WCPayPromotion\Init as WCPayPromotionInit;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use WC_Marketplace_Suggestions;
@@ -137,9 +138,7 @@ class Settings {
//phpcs:ignore
$preload_data_endpoints = apply_filters( 'woocommerce_component_settings_preload_endpoints', array() );
if ( class_exists( 'Jetpack' ) ) {
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
}
$preload_data_endpoints['jetpackStatus'] = '/jetpack/v4/connection';
if ( ! empty( $preload_data_endpoints ) ) {
$preload_data = array_reduce(
array_values( $preload_data_endpoints ),
@@ -236,6 +235,8 @@ class Settings {
$settings['features'] = $this->get_features();
$settings['isWooPayEligible'] = WCPayPromotionInit::is_woopay_eligible();
return $settings;
}

View File

@@ -81,6 +81,10 @@ class Translations {
continue;
}
if ( ! isset( $chunk_data['comment']['reference'] ) ) {
continue;
}
$reference_file = $chunk_data['comment']['reference'];
// Only combine "app" files (not scripts registered with WP).

View File

@@ -272,6 +272,7 @@ class WCAdminAssets {
'wc-store-data',
'wc-currency',
'wc-navigation',
'wc-block-templates',
'wc-product-editor',
);
@@ -297,6 +298,23 @@ class WCAdminAssets {
$script_assets_filename = self::get_script_asset_filename( $script_path_name, 'index' );
$script_assets = require WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . $script_path_name . '/' . $script_assets_filename;
global $wp_version;
if ( 'app' === $script_path_name && version_compare( $wp_version, '6.3', '<' ) ) {
// Remove wp-router dependency for WordPress versions < 6.3 because wp-router is not included in those versions. We only use wp-router in customize store pages and the feature is only available in WordPress 6.3+.
// We can remove this once our minimum support is WP 6.3.
$script_assets['dependencies'] = array_diff( $script_assets['dependencies'], array( 'wp-router' ) );
}
// Remove wp-editor dependency if we're not on a customize store page since we don't use wp-editor in other pages.
$is_customize_store_page = (
PageController::is_admin_page() &&
isset( $_GET['path'] ) &&
str_starts_with( wc_clean( wp_unslash( $_GET['path'] ) ), '/customize-store' )
);
if ( ! $is_customize_store_page && WC_ADMIN_APP === $script ) {
$script_assets['dependencies'] = array_diff( $script_assets['dependencies'], array( 'wp-editor' ) );
}
wp_register_script(
$script,
self::get_url( $script_path_name . '/index', 'js' ),
@@ -330,6 +348,14 @@ class WCAdminAssets {
);
wp_style_add_data( 'wc-components', 'rtl', 'replace' );
wp_register_style(
'wc-block-templates',
self::get_url( 'block-templates/style', 'css' ),
array(),
$css_file_version
);
wp_style_add_data( 'wc-block-templates', 'rtl', 'replace' );
wp_register_style(
'wc-product-editor',
self::get_url( 'product-editor/style', 'css' ),
@@ -398,6 +424,7 @@ class WCAdminAssets {
'wc-date',
'wc-components',
'wc-tracks',
'wc-block-templates',
'wc-product-editor',
];
foreach ( $handles_for_injection as $handle ) {

View File

@@ -141,6 +141,15 @@ class Init {
}
/**
* Get merchant WooPay eligibility.
*/
public static function is_woopay_eligible() {
$wcpay_promotion = self::get_wc_pay_promotion_spec();
return $wcpay_promotion && 'woocommerce_payments:woopay' === $wcpay_promotion->id;
}
/**
* Delete the specs transient.
*/

View File

@@ -12,7 +12,8 @@ use Automattic\WooCommerce\Admin\PageController;
* @package Automattic\WooCommerce\Admin\Features
*/
class WcPayWelcomePage {
const TRANSIENT_NAME = 'wcpay_welcome_page_incentive';
const CACHE_TRANSIENT_NAME = 'wcpay_welcome_page_incentive';
const HAD_WCPAY_OPTION_NAME = 'wcpay_was_in_use';
/**
* Plugin instance.
@@ -44,6 +45,7 @@ class WcPayWelcomePage {
add_action( 'admin_menu', [ $this, 'register_payments_welcome_page' ] );
add_filter( 'woocommerce_admin_shared_settings', [ $this, 'shared_settings' ] );
add_filter( 'woocommerce_admin_allowed_promo_notes', [ $this, 'allowed_promo_notes' ] );
add_filter( 'woocommerce_admin_woopayments_onboarding_task_badge', [ $this, 'onboarding_task_badge' ] );
}
/**
@@ -52,6 +54,11 @@ class WcPayWelcomePage {
* @return boolean
*/
public function must_be_visible(): bool {
// The WooPayments plugin must not be active.
if ( $this->is_wcpay_active() ) {
return false;
}
// Suggestions not disabled via a setting.
if ( get_option( 'woocommerce_show_marketplace_suggestions', 'yes' ) === 'no' ) {
return false;
@@ -68,12 +75,7 @@ class WcPayWelcomePage {
return false;
}
// The WooPayments plugin must not be active.
if ( $this->is_wcpay_active() ) {
return false;
}
// Incentive is available.
// An incentive must be available.
if ( empty( $this->get_incentive() ) ) {
return false;
}
@@ -133,11 +135,17 @@ class WcPayWelcomePage {
}
// Add badge.
$badge = ' <span class="wcpay-menu-badge awaiting-mod count-1"><span class="plugin-count">1</span></span>';
foreach ( $menu as $index => $menu_item ) {
if ( 'wc-admin&path=/wc-pay-welcome-page' === $menu_item[2]
|| 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' === $menu_item[2] ) {
//phpcs:ignore
$menu[ $index ][0] .= ' <span class="wcpay-menu-badge awaiting-mod count-1"><span class="plugin-count">1</span></span>';
// Only add the badge markup if not already present and the menu item is the WooPayments menu item.
if ( false === strpos( $menu_item[0], $badge )
&& ( 'wc-admin&path=/wc-pay-welcome-page' === $menu_item[2]
|| 'admin.php?page=wc-admin&path=/wc-pay-welcome-page' === $menu_item[2] )
) {
$menu[ $index ][0] .= $badge; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
// One menu item with a badge is more than enough.
break;
}
}
}
@@ -154,8 +162,8 @@ class WcPayWelcomePage {
return $settings;
}
// Return early if there is no eligible incentive.
if ( empty( $this->get_incentive() ) ) {
// Return early if the incentive must not be visible.
if ( ! $this->must_be_visible() ) {
return $settings;
}
@@ -171,8 +179,8 @@ class WcPayWelcomePage {
* @return array
*/
public function allowed_promo_notes( $promo_notes = [] ): array {
// Return early if there is no eligible incentive.
if ( empty( $this->get_incentive() ) ) {
// Return early if the incentive must not be visible.
if ( ! $this->must_be_visible() ) {
return $promo_notes;
}
@@ -183,20 +191,50 @@ class WcPayWelcomePage {
}
/**
* Check if the WooPayments payment gateway is active and set up,
* Adds the WooPayments incentive badge to the onboarding task.
*
* @param string $badge Current badge.
*
* @return string
*/
public function onboarding_task_badge( string $badge ): string {
// Return early if the incentive must not be visible.
if ( ! $this->must_be_visible() ) {
return $badge;
}
return $this->get_incentive()['task_badge'] ?? $badge;
}
/**
* Check if the WooPayments payment gateway is active and set up or was at some point,
* or there are orders processed with it, at some moment.
*
* @return boolean
*/
private function has_wcpay(): bool {
// First, get the stored value, if it exists.
// This way we avoid costly DB queries and API calls.
// Basically, we only want to know if WooPayments was in use in the past.
// Since the past can't be changed, neither can this value.
$had_wcpay = get_option( self::HAD_WCPAY_OPTION_NAME );
if ( false !== $had_wcpay ) {
return $had_wcpay === 'yes';
}
// We need to determine the value.
// Start with the assumption that the store didn't have WooPayments in use.
$had_wcpay = false;
// We consider the store to have WooPayments if there is meaningful account data in the WooPayments account cache.
// This implies that WooPayments is or was active at some point and that it was connected.
// This implies that WooPayments was active at some point and that it was connected.
// If WooPayments is active right now, we will not get to this point since the plugin is active check is done first.
if ( $this->has_wcpay_account_data() ) {
return true;
$had_wcpay = true;
}
// If there is at least one order processed with WooPayments, we consider the store to have WooPayments.
if ( ! empty(
if ( false === $had_wcpay && ! empty(
wc_get_orders(
[
'payment_method' => 'woocommerce_payments',
@@ -205,10 +243,13 @@ class WcPayWelcomePage {
]
)
) ) {
return true;
$had_wcpay = true;
}
return false;
// Store the value for future use.
update_option( self::HAD_WCPAY_OPTION_NAME, $had_wcpay ? 'yes' : 'no' );
return $had_wcpay;
}
/**
@@ -240,14 +281,21 @@ class WcPayWelcomePage {
* @return boolean
*/
private function is_incentive_dismissed(): bool {
$dismissed_incentives = get_option( 'wcpay_welcome_page_incentives_dismissed', [] );
// If there are no dismissed incentives, return early.
if ( empty( $dismissed_incentives ) ) {
return false;
}
// Return early if there is no eligible incentive.
if ( empty( $this->get_incentive() ) ) {
$incentive = $this->get_incentive();
if ( empty( $incentive ) ) {
return true;
}
$dismissed_incentives = get_option( 'wcpay_welcome_page_incentives_dismissed', [] );
if ( in_array( $this->get_incentive()['id'], $dismissed_incentives, true ) ) {
// Search the incentive ID in the dismissed incentives list.
if ( in_array( $incentive['id'], $dismissed_incentives, true ) ) {
return true;
}
@@ -265,6 +313,19 @@ class WcPayWelcomePage {
return $this->incentive;
}
// Get the cached data.
$cache = get_transient( self::CACHE_TRANSIENT_NAME );
// If the cached data is not expired and it's a WP_Error,
// it means there was an API error previously and we should not retry just yet.
if ( is_wp_error( $cache ) ) {
// Initialize the in-memory cache and return it.
$this->incentive = [];
return $this->incentive;
}
// Gather the store context data.
$store_context = [
// Store ISO-2 country code, e.g. `US`.
'country' => WC()->countries->get_base_country(),
@@ -293,25 +354,16 @@ class WcPayWelcomePage {
// Use the transient cached incentive if it exists, it is not expired,
// and the store context hasn't changed since we last requested from the WooPayments API (based on context hash).
$transient_cache = get_transient( self::TRANSIENT_NAME );
if ( false !== $transient_cache ) {
if ( is_null( $transient_cache ) ) {
// This means there was an error and we shouldn't retry just yet.
// Initialize the in-memory cache.
$this->incentive = [];
} elseif ( ! empty( $transient_cache['context_hash'] ) && is_string( $transient_cache['context_hash'] )
&& hash_equals( $store_context_hash, $transient_cache['context_hash'] ) ) {
if ( false !== $cache
&& ! empty( $cache['context_hash'] ) && is_string( $cache['context_hash'] )
&& hash_equals( $store_context_hash, $cache['context_hash'] ) ) {
// We have a store context hash and it matches with the current context one.
// We can use the cached incentive data.
// Store the incentive in the in-memory cache.
$this->incentive = $transient_cache['incentive'] ?? [];
}
// We have a store context hash and it matches with the current context one.
// We can use the cached incentive data.
// Store the incentive in the in-memory cache and return it.
$this->incentive = $cache['incentive'] ?? [];
// If the in-memory cache has been set, return it.
if ( isset( $this->incentive ) ) {
return $this->incentive;
}
return $this->incentive;
}
// By this point, we have an expired transient or the store context has changed.
@@ -323,16 +375,22 @@ class WcPayWelcomePage {
$response = wp_remote_get(
$url,
array(
[
'user-agent' => 'WooCommerce/' . WC()->version . '; ' . get_bloginfo( 'url' ),
)
]
);
// Return early if there is an error, waiting 6 hours before the next attempt.
if ( is_wp_error( $response ) ) {
// Store a null value in the transient so we know this is due to an API error.
set_transient( self::TRANSIENT_NAME, null, HOUR_IN_SECONDS * 6 );
// Initialize the in-memory cache.
// Store a trimmed down, lightweight error.
$error = new \WP_Error(
$response->get_error_code(),
$response->get_error_message(),
wp_remote_retrieve_response_code( $response )
);
// Store the error in the transient so we know this is due to an API error.
set_transient( self::CACHE_TRANSIENT_NAME, $error, HOUR_IN_SECONDS * 6 );
// Initialize the in-memory cache and return it.
$this->incentive = [];
return $this->incentive;
@@ -362,19 +420,21 @@ class WcPayWelcomePage {
// Skip transient cache if `cache-for` header equals zero.
if ( '0' === $cache_for ) {
// If we have a transient cache that is not expired, delete it so there are no leftovers.
if ( false !== $transient_cache ) {
delete_transient( self::TRANSIENT_NAME );
if ( false !== $cache ) {
delete_transient( self::CACHE_TRANSIENT_NAME );
}
return $this->incentive;
}
// Store incentive in transient cache (together with the context hash) for the given number of seconds or 24h.
// Store incentive in transient cache (together with the context hash) for the given number of seconds
// or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched.
set_transient(
self::TRANSIENT_NAME,
self::CACHE_TRANSIENT_NAME,
[
'incentive' => $this->incentive,
'context_hash' => $store_context_hash,
'timestamp' => time(),
],
! empty( $cache_for ) ? (int) $cache_for : DAY_IN_SECONDS
);

View File

@@ -99,14 +99,27 @@ class BatchProcessingController {
* @param bool $unique Whether to make the action unique.
*/
private function schedule_watchdog_action( bool $with_delay = false, bool $unique = false ): void {
$time = $with_delay ? time() + HOUR_IN_SECONDS : time();
as_schedule_single_action(
$time,
self::WATCHDOG_ACTION_NAME,
array(),
self::ACTION_GROUP,
$unique
);
$time = time();
if ( $with_delay ) {
/**
* Modify the delay interval for the batch processor's watchdog events.
*
* @since 8.2.0
*
* @param int $delay Time, in seconds, before the watchdog process will run. Defaults to 3600 (1 hour).
*/
$time += apply_filters( 'woocommerce_batch_processor_watchdog_delay_seconds', HOUR_IN_SECONDS );
}
if ( ! as_has_scheduled_action( self::WATCHDOG_ACTION_NAME ) ) {
as_schedule_single_action(
$time,
self::WATCHDOG_ACTION_NAME,
array(),
self::ACTION_GROUP,
$unique
);
}
}
/**

View File

@@ -81,7 +81,7 @@ abstract class CustomMetaDataStore {
*
* @return bool
*/
public function delete_meta( &$object, $meta ) {
public function delete_meta( &$object, $meta ) : bool {
global $wpdb;
if ( ! isset( $meta->id ) ) {
@@ -99,7 +99,8 @@ abstract class CustomMetaDataStore {
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->key and ->value).
* @return int meta ID
*
* @return int|false meta ID
*/
public function add_meta( &$object, $meta ) {
global $wpdb;
@@ -132,7 +133,7 @@ abstract class CustomMetaDataStore {
*
* @return bool
*/
public function update_meta( &$object, $meta ) {
public function update_meta( &$object, $meta ) : bool {
global $wpdb;
if ( ! isset( $meta->id ) || empty( $meta->key ) ) {
@@ -194,4 +195,41 @@ abstract class CustomMetaDataStore {
return $meta;
}
/**
* Retrieves metadata by meta key.
*
* @param \WC_Abstract_Order $object Object ID.
* @param string $meta_key Meta key.
*
* @return \stdClass|bool Metadata object or FALSE if not found.
*/
public function get_metadata_by_key( &$object, string $meta_key ) {
global $wpdb;
$db_info = $this->get_db_info();
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$meta = $wpdb->get_results(
$wpdb->prepare(
"SELECT {$db_info['meta_id_field']}, meta_key, meta_value, {$db_info['object_id_field']} FROM {$db_info['table']} WHERE meta_key = %s AND {$db_info['object_id_field']} = %d",
$meta_key,
$object->get_id(),
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( empty( $meta ) ) {
return false;
}
foreach ( $meta as $row ) {
if ( isset( $row->meta_value ) ) {
$row->meta_value = maybe_unserialize( $row->meta_value );
}
}
return $meta;
}
}

View File

@@ -5,20 +5,19 @@
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Utilities\PluginUtil;
use ActionScheduler;
defined( 'ABSPATH' ) || exit;
/**
* This is the main class that controls the custom orders tables feature. Its responsibilities are:
*
* - Allowing to enable and disable the feature while it's in development (show_feature method)
* - Displaying UI components (entries in the tools page and in settings)
* - Providing the proper data store for orders via 'woocommerce_order_data_store' hook
*
@@ -28,6 +27,8 @@ class CustomOrdersTableController {
use AccessiblePrivateMethods;
private const SYNC_QUERY_ARG = 'wc_hpos_sync_now';
/**
* The name of the option for enabling the usage of the custom orders tables
*/
@@ -117,10 +118,10 @@ class CustomOrdersTableController {
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_initiate_regeneration_entry_to_tools_array' ), 999, 1 );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_data_sync_option_changed' ), 10, 1 );
self::add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
self::add_action( FeaturesController::FEATURE_ENABLED_CHANGED_ACTION, array( $this, 'handle_feature_enabled_changed' ), 10, 2 );
self::add_action( 'woocommerce_feature_setting', array( $this, 'get_hpos_feature_setting' ), 10, 2 );
self::add_action( 'woocommerce_sections_advanced', array( $this, 'sync_now' ) );
self::add_filter( 'removable_query_args', array( $this, 'register_removable_query_arg' ) );
self::add_action( 'woocommerce_register_feature_definitions', array( $this, 'add_feature_definition' ) );
}
/**
@@ -156,33 +157,6 @@ class CustomOrdersTableController {
$this->plugin_util = $plugin_util;
}
/**
* Checks if the feature is visible (so that dedicated entries will be added to the debug tools page).
*
* @return bool True if the feature is visible.
*/
public function is_feature_visible(): bool {
return true;
}
/**
* Makes the feature visible, so that dedicated entries will be added to the debug tools page.
*
* This method shouldn't be used anymore, see the FeaturesController class.
*/
public function show_feature() {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . __FUNCTION__;
wc_doing_it_wrong(
$class_and_method,
sprintf(
// translators: %1$s the name of the class and method used.
__( '%1$s: The visibility of the custom orders table feature is now handled by the WooCommerce features engine. See the FeaturesController class, or go to WooCommerce - Settings - Advanced - Features.', 'woocommerce' ),
$class_and_method
),
'7.0'
);
}
/**
* Is the custom orders table usage enabled via settings?
* This can be true only if the feature is enabled and a table regeneration has been completed.
@@ -190,7 +164,7 @@ class CustomOrdersTableController {
* @return bool True if the custom orders table usage is enabled
*/
public function custom_orders_table_usage_is_enabled(): bool {
return $this->is_feature_visible() && get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
return get_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION ) === 'yes';
}
/**
@@ -276,23 +250,6 @@ class CustomOrdersTableController {
return $tools_array;
}
/**
* Create the custom orders tables in response to the user pressing the tool button.
*
* @param bool $verify_nonce True to perform the nonce verification, false to skip it.
*
* @throws \Exception Can't create the tables.
*/
private function create_custom_orders_tables( bool $verify_nonce = true ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( $verify_nonce && ( ! isset( $_REQUEST['_wpnonce'] ) || wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) === false ) ) {
throw new \Exception( 'Invalid nonce' );
}
$this->data_synchronizer->create_database_tables();
update_option( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, 'no' );
}
/**
* Delete the custom orders tables and any related options and data in response to the user pressing the tool button.
*
@@ -336,12 +293,19 @@ class CustomOrdersTableController {
return $value;
}
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || $value === $old_value || false === $old_value ) {
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $option || 'no' === $value ) {
return $value;
}
$this->order_cache->flush();
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
$this->data_synchronizer->create_database_tables();
}
$tables_created = get_option( DataSynchronizer::ORDERS_TABLE_CREATED ) === 'yes';
if ( ! $tables_created ) {
return 'no';
}
/**
* Re-enable the following code once the COT to posts table sync is implemented (it's currently commented out to ease testing).
$sync_is_pending = 0 !== $this->data_synchronizer->get_current_orders_pending_sync_count();
@@ -354,47 +318,32 @@ class CustomOrdersTableController {
}
/**
* Handler for the all settings updated hook.
* Callback to trigger a sync immediately by clicking a button on the Features screen.
*
* @param string $feature_id Feature ID.
* @return void
*/
private function handle_data_sync_option_changed( string $feature_id ) {
if ( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION !== $feature_id ) {
private function sync_now() {
$section = filter_input( INPUT_GET, 'section' );
if ( 'features' !== $section ) {
return;
}
$data_sync_is_enabled = $this->data_synchronizer->data_sync_is_enabled();
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
$this->data_synchronizer->create_database_tables();
}
// Enabling/disabling the sync implies starting/stopping it too, if needed.
// We do this check here, and not in process_pre_update_option, so that if for some reason
// the setting is enabled but no sync is in process, sync will start by just saving the
// settings even without modifying them (and the opposite: sync will be stopped if for
// some reason it was ongoing while it was disabled).
if ( $data_sync_is_enabled ) {
if ( filter_input( INPUT_GET, self::SYNC_QUERY_ARG, FILTER_VALIDATE_BOOLEAN ) ) {
$this->batch_processing_controller->enqueue_processor( DataSynchronizer::class );
} else {
$this->batch_processing_controller->remove_processor( DataSynchronizer::class );
}
}
/**
* Handle the 'woocommerce_feature_enabled_changed' action,
* if the custom orders table feature is enabled create the database tables if they don't exist.
* Tell WP Admin to remove the sync query arg from the URL.
*
* @param string $feature_id The id of the feature that is being enabled or disabled.
* @param bool $is_enabled True if the feature is being enabled, false if it's being disabled.
* @param array $query_args The query args that are removable.
*
* @return array
*/
private function handle_feature_enabled_changed( $feature_id, $is_enabled ): void {
if ( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION !== $feature_id || ! $is_enabled ) {
return;
}
private function register_removable_query_arg( $query_args ) {
$query_args[] = self::SYNC_QUERY_ARG;
if ( ! $this->data_synchronizer->check_orders_table_exists() ) {
$this->create_custom_orders_tables( false );
}
return $query_args;
}
/**
@@ -429,105 +378,160 @@ class CustomOrdersTableController {
}
/**
* Returns the HPOS setting for rendering in Features section of the settings page.
* Add the definition for the HPOS feature.
*
* @param array $feature_setting HPOS feature value as defined in the feature controller.
* @param string $feature_id ID of the feature.
* @param FeaturesController $features_controller The instance of FeaturesController.
*
* @return array Feature setting object.
* @return void
*/
private function get_hpos_feature_setting( array $feature_setting, string $feature_id ) {
if ( ! in_array( $feature_id, array( self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION, DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION, 'custom_order_tables' ), true ) ) {
return $feature_setting;
}
private function add_feature_definition( $features_controller ) {
$definition = array(
'option_key' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
'is_experimental' => false,
'enabled_by_default' => false,
'order' => 50,
'setting' => $this->get_hpos_setting_for_feature(),
'additional_settings' => array(
$this->get_hpos_setting_for_sync(),
),
);
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return $feature_setting;
}
$sync_status = $this->data_synchronizer->get_sync_status();
switch ( $feature_id ) {
case self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION:
return $this->get_hpos_setting_for_feature( $sync_status );
case DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION:
return $this->get_hpos_setting_for_sync( $sync_status );
case 'custom_order_tables':
return array();
}
$features_controller->add_feature_definition(
'custom_order_tables',
__( 'High-Performance order storage', 'woocommerce' ),
$definition
);
}
/**
* Returns the HPOS setting for rendering HPOS vs Post setting block in Features section of the settings page.
*
* @param array $sync_status Details of sync status, includes pending count, and count when sync started.
*
* @return array Feature setting object.
*/
private function get_hpos_setting_for_feature( $sync_status ) {
$hpos_enabled = $this->custom_orders_table_usage_is_enabled();
$plugin_info = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
$plugin_incompat_warning = $this->plugin_util->generate_incompatible_plugin_feature_warning( 'custom_order_tables', $plugin_info );
$sync_complete = 0 === $sync_status['current_pending_count'];
$disabled_option = array();
if ( count( array_merge( $plugin_info['uncertain'], $plugin_info['incompatible'] ) ) > 0 ) {
$disabled_option = array( 'yes' );
}
if ( ! $sync_complete ) {
$disabled_option = array( 'yes', 'no' );
private function get_hpos_setting_for_feature() {
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return array();
}
$get_value = function() {
return $this->custom_orders_table_usage_is_enabled() ? 'yes' : 'no';
};
/**
* ⚠The FeaturesController instance must only be accessed from within the callback functions. Otherwise it
* gets called while it's still being instantiated and creates and endless loop.
*/
$get_desc = function() {
$plugin_compatibility = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
return $this->plugin_util->generate_incompatible_plugin_feature_warning( 'custom_order_tables', $plugin_compatibility );
};
$get_disabled = function() {
$plugin_compatibility = $this->features_controller->get_compatible_plugins_for_feature( 'custom_order_tables', true );
$sync_status = $this->data_synchronizer->get_sync_status();
$sync_complete = 0 === $sync_status['current_pending_count'];
$disabled = array();
// Changing something here? might also want to look at `enable|disable` functions in CLIRunner.
if ( count( array_merge( $plugin_compatibility['uncertain'], $plugin_compatibility['incompatible'] ) ) > 0 ) {
$disabled = array( 'yes' );
}
if ( ! $sync_complete ) {
$disabled = array( 'yes', 'no' );
}
return $disabled;
};
return array(
'id' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
'title' => __( 'Data storage for orders', 'woocommerce' ),
'title' => __( 'Order data storage', 'woocommerce' ),
'type' => 'radio',
'options' => array(
'no' => __( 'WordPress post tables', 'woocommerce' ),
'yes' => __( 'High performance order storage (new)', 'woocommerce' ),
'no' => __( 'WordPress posts storage (legacy)', 'woocommerce' ),
'yes' => __( 'High-performance order storage (recommended)', 'woocommerce' ),
),
'value' => $hpos_enabled ? 'yes' : 'no',
'disabled' => $disabled_option,
'desc' => $plugin_incompat_warning,
'value' => $get_value,
'disabled' => $get_disabled,
'desc' => $get_desc,
'desc_at_end' => true,
'row_class' => self::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
);
}
/**
* Returns the setting for rendering sync enabling setting block in Features section of the settings page.
*
* @param array $sync_status Details of sync status, includes pending count, and count when sync started.
*
* @return array Feature setting object.
*/
private function get_hpos_setting_for_sync( $sync_status ) {
$sync_in_progress = $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) );
$sync_enabled = get_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
$sync_message = '';
if ( $sync_in_progress && $sync_status['current_pending_count'] > 0 ) {
$sync_message = sprintf(
// translators: %d: number of pending orders.
__( 'Currently syncing orders... %d pending', 'woocommerce' ),
$sync_status['current_pending_count']
);
} elseif ( $sync_status['current_pending_count'] > 0 ) {
$sync_message = sprintf(
// translators: %d: number of pending orders.
_n(
'Sync %d pending order. You can switch data storage for orders only when posts and orders table are in sync.',
'Sync %d pending orders. You can switch data storage for orders only when posts and orders table are in sync.',
$sync_status['current_pending_count'],
'woocommerce'
),
$sync_status['current_pending_count'],
);
private function get_hpos_setting_for_sync() {
if ( 'yes' === get_transient( 'wc_installing' ) ) {
return array();
}
$get_value = function() {
return get_option( DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION );
};
$get_sync_message = function() {
$sync_status = $this->data_synchronizer->get_sync_status();
$sync_in_progress = $this->batch_processing_controller->is_enqueued( get_class( $this->data_synchronizer ) );
$sync_enabled = $this->data_synchronizer->data_sync_is_enabled();
$sync_message = array();
if ( ! $sync_enabled && $this->data_synchronizer->background_sync_is_enabled() ) {
$sync_message[] = __( 'Background sync is enabled.', 'woocommerce' );
}
if ( $sync_in_progress && $sync_status['current_pending_count'] > 0 ) {
$sync_message[] = sprintf(
// translators: %d: number of pending orders.
__( 'Currently syncing orders... %d pending', 'woocommerce' ),
$sync_status['current_pending_count']
);
} elseif ( $sync_status['current_pending_count'] > 0 ) {
$sync_now_url = add_query_arg(
array(
self::SYNC_QUERY_ARG => true,
),
wc_get_container()->get( FeaturesController::class )->get_features_page_url()
);
$sync_message[] = wp_kses_data(
__(
'You can switch order data storage <strong>only when the posts and orders tables are in sync</strong>.',
'woocommerce'
)
);
$sync_message[] = sprintf(
'<a href="%1$s" class="button button-link">%2$s</a>',
esc_url( $sync_now_url ),
sprintf(
// translators: %d: number of pending orders.
_n(
'Sync %s pending order',
'Sync %s pending orders',
$sync_status['current_pending_count'],
'woocommerce'
),
number_format_i18n( $sync_status['current_pending_count'] )
)
);
}
return implode( '<br />', $sync_message );
};
return array(
'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
'title' => '',
'type' => 'checkbox',
'desc' => __( 'Keep the posts and orders tables in sync (compatibility mode).', 'woocommerce' ),
'value' => $sync_enabled,
'desc_tip' => $sync_message,
'id' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
'title' => '',
'type' => 'checkbox',
'desc' => __( 'Enable compatibility mode (synchronizes orders to the posts table).', 'woocommerce' ),
'value' => $get_value,
'desc_tip' => $get_sync_message,
'row_class' => DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
);
}
}

View File

@@ -8,7 +8,7 @@ namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Database\Migrations\CustomOrderTable\PostsToOrdersMigrationController;
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessorInterface;
use Automattic\WooCommerce\Internal\BatchProcessing\{ BatchProcessingController, BatchProcessorInterface };
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
@@ -45,6 +45,13 @@ class DataSynchronizer implements BatchProcessorInterface {
public const ID_TYPE_DELETED_FROM_ORDERS_TABLE = 3;
public const ID_TYPE_DELETED_FROM_POSTS_TABLE = 4;
public const BACKGROUND_SYNC_MODE_OPTION = 'woocommerce_custom_orders_table_background_sync_mode';
public const BACKGROUND_SYNC_INTERVAL_OPTION = 'woocommerce_custom_orders_table_background_sync_interval';
public const BACKGROUND_SYNC_MODE_INTERVAL = 'interval';
public const BACKGROUND_SYNC_MODE_CONTINUOUS = 'continuous';
public const BACKGROUND_SYNC_MODE_OFF = 'off';
public const BACKGROUND_SYNC_EVENT_HOOK = 'woocommerce_custom_orders_table_background_sync';
/**
* The data store object to use.
*
@@ -80,6 +87,13 @@ class DataSynchronizer implements BatchProcessorInterface {
*/
private $order_cache_controller;
/**
* The batch processing controller.
*
* @var BatchProcessingController
*/
private $batch_processing_controller;
/**
* Class constructor.
*/
@@ -89,6 +103,13 @@ class DataSynchronizer implements BatchProcessorInterface {
self::add_action( 'woocommerce_refund_created', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'woocommerce_update_order', array( $this, 'handle_updated_order' ), 100 );
self::add_action( 'wp_scheduled_auto_draft_delete', array( $this, 'delete_auto_draft_orders' ), 9 );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 2 );
self::add_filter( 'deleted_option', array( $this, 'process_deleted_option' ), 999 );
self::add_action( self::BACKGROUND_SYNC_EVENT_HOOK, array( $this, 'handle_interval_background_sync' ) );
if ( self::BACKGROUND_SYNC_MODE_CONTINUOUS === $this->get_background_sync_mode() ) {
self::add_action( 'shutdown', array( $this, 'handle_continuous_background_sync' ) );
}
self::add_filter( 'woocommerce_feature_description_tip', array( $this, 'handle_feature_description_tip' ), 10, 3 );
}
@@ -101,6 +122,7 @@ class DataSynchronizer implements BatchProcessorInterface {
* @param PostsToOrdersMigrationController $posts_to_cot_migrator The posts to COT migration class to use.
* @param LegacyProxy $legacy_proxy The legacy proxy instance to use.
* @param OrderCacheController $order_cache_controller The order cache controller instance to use.
* @param BatchProcessingController $batch_processing_controller The batch processing controller to use.
* @internal
*/
final public function init(
@@ -108,13 +130,15 @@ class DataSynchronizer implements BatchProcessorInterface {
DatabaseUtil $database_util,
PostsToOrdersMigrationController $posts_to_cot_migrator,
LegacyProxy $legacy_proxy,
OrderCacheController $order_cache_controller
OrderCacheController $order_cache_controller,
BatchProcessingController $batch_processing_controller
) {
$this->data_store = $data_store;
$this->database_util = $database_util;
$this->posts_to_cot_migrator = $posts_to_cot_migrator;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
$this->order_cache_controller = $order_cache_controller;
$this->data_store = $data_store;
$this->database_util = $database_util;
$this->posts_to_cot_migrator = $posts_to_cot_migrator;
$this->error_logger = $legacy_proxy->call_function( 'wc_get_logger' );
$this->order_cache_controller = $order_cache_controller;
$this->batch_processing_controller = $batch_processing_controller;
}
/**
@@ -151,11 +175,19 @@ class DataSynchronizer implements BatchProcessorInterface {
}
/**
* Create the custom orders database tables.
* Create the custom orders database tables and log an error if that's not possible.
*
* @return bool True if all the tables were successfully created, false otherwise.
*/
public function create_database_tables() {
$this->database_util->dbdelta( $this->data_store->get_database_schema() );
$this->check_orders_table_exists();
$success = $this->check_orders_table_exists();
if ( ! $success ) {
$missing_tables = $this->database_util->get_missing_tables( $this->data_store->get_database_schema() );
$missing_tables = implode( ', ', $missing_tables );
$this->error_logger->error( "HPOS tables are missing in the database and couldn't be created. The missing tables are: $missing_tables" );
}
return $success;
}
/**
@@ -171,7 +203,7 @@ class DataSynchronizer implements BatchProcessorInterface {
}
/**
* Is the data sync between old and new tables currently enabled?
* Is the real-time data sync between old and new tables currently enabled?
*
* @return bool
*/
@@ -179,6 +211,181 @@ class DataSynchronizer implements BatchProcessorInterface {
return 'yes' === get_option( self::ORDERS_DATA_SYNC_ENABLED_OPTION );
}
/**
* Get the current background data sync mode.
*
* @return string
*/
public function get_background_sync_mode(): string {
$default = $this->data_sync_is_enabled() ? self::BACKGROUND_SYNC_MODE_INTERVAL : self::BACKGROUND_SYNC_MODE_OFF;
return get_option( self::BACKGROUND_SYNC_MODE_OPTION, $default );
}
/**
* Is the background data sync between old and new tables currently enabled?
*
* @return bool
*/
public function background_sync_is_enabled(): bool {
$enabled_modes = array( self::BACKGROUND_SYNC_MODE_INTERVAL, self::BACKGROUND_SYNC_MODE_CONTINUOUS );
$mode = $this->get_background_sync_mode();
return in_array( $mode, $enabled_modes, true );
}
/**
* Process an option change for specific keys.
*
* @param string $option_key The option key.
* @param string $old_value The previous value.
* @param string $new_value The new value.
*
* @return void
*/
private function process_updated_option( $option_key, $old_value, $new_value ) {
$sync_option_keys = array( self::ORDERS_DATA_SYNC_ENABLED_OPTION, self::BACKGROUND_SYNC_MODE_OPTION );
if ( ! in_array( $option_key, $sync_option_keys, true ) || $new_value === $old_value ) {
return;
}
if ( self::BACKGROUND_SYNC_MODE_OPTION === $option_key ) {
$mode = $new_value;
} else {
$mode = $this->get_background_sync_mode();
}
switch ( $mode ) {
case self::BACKGROUND_SYNC_MODE_INTERVAL:
$this->schedule_background_sync();
break;
case self::BACKGROUND_SYNC_MODE_CONTINUOUS:
case self::BACKGROUND_SYNC_MODE_OFF:
default:
$this->unschedule_background_sync();
break;
}
if ( self::ORDERS_DATA_SYNC_ENABLED_OPTION === $option_key ) {
if ( ! $this->check_orders_table_exists() ) {
$this->create_database_tables();
}
if ( $this->data_sync_is_enabled() ) {
$this->batch_processing_controller->enqueue_processor( self::class );
} else {
$this->batch_processing_controller->remove_processor( self::class );
}
}
}
/**
* Process an option change when the key didn't exist before.
*
* @param string $option_key The option key.
* @param string $value The new value.
*
* @return void
*/
private function process_added_option( $option_key, $value ) {
$this->process_updated_option( $option_key, false, $value );
}
/**
* Process an option deletion for specific keys.
*
* @param string $option_key The option key.
*
* @return void
*/
private function process_deleted_option( $option_key ) {
if ( self::BACKGROUND_SYNC_MODE_OPTION !== $option_key ) {
return;
}
$this->unschedule_background_sync();
$this->batch_processing_controller->remove_processor( self::class );
}
/**
* Get the time interval, in seconds, between background syncs.
*
* @return int
*/
public function get_background_sync_interval(): int {
$interval = filter_var(
get_option( self::BACKGROUND_SYNC_INTERVAL_OPTION, HOUR_IN_SECONDS ),
FILTER_VALIDATE_INT,
array(
'options' => array(
'default' => HOUR_IN_SECONDS,
),
)
);
return $interval;
}
/**
* Schedule an event to run background sync when the mode is set to interval.
*
* @return void
*/
private function schedule_background_sync() {
$interval = $this->get_background_sync_interval();
// Calling Action Scheduler directly because WC_Action_Queue doesn't support the unique parameter yet.
as_schedule_recurring_action(
time() + $interval,
$interval,
self::BACKGROUND_SYNC_EVENT_HOOK,
array(),
'',
true
);
}
/**
* Remove any pending background sync events.
*
* @return void
*/
private function unschedule_background_sync() {
WC()->queue()->cancel_all( self::BACKGROUND_SYNC_EVENT_HOOK );
}
/**
* Callback to check for pending syncs and enqueue the background data sync processor when in interval mode.
*
* @return void
*/
private function handle_interval_background_sync() {
if ( self::BACKGROUND_SYNC_MODE_INTERVAL !== $this->get_background_sync_mode() ) {
$this->unschedule_background_sync();
return;
}
$pending_count = $this->get_total_pending_count();
if ( $pending_count > 0 ) {
$this->batch_processing_controller->enqueue_processor( self::class );
}
}
/**
* Callback to keep the background data sync processor enqueued when in continuous mode.
*
* @return void
*/
private function handle_continuous_background_sync() {
if ( self::BACKGROUND_SYNC_MODE_CONTINUOUS !== $this->get_background_sync_mode() ) {
$this->batch_processing_controller->remove_processor( self::class );
return;
}
// This method already checks if a processor is enqueued before adding it to avoid duplication.
$this->batch_processing_controller->enqueue_processor( self::class );
}
/**
* Get the current sync process status.
* The information is meaningful only if pending_data_sync_is_in_progress return true.
@@ -251,12 +458,15 @@ class DataSynchronizer implements BatchProcessorInterface {
}
if ( $this->custom_orders_table_is_authoritative() ) {
$missing_orders_count_sql = "
$missing_orders_count_sql = $wpdb->prepare(
"
SELECT COUNT(1) FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
";
AND orders.type IN ($order_post_type_placeholder)",
$order_post_types
);
$operator = '>';
} else {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_post_type_placeholder is prepared.
@@ -374,13 +584,16 @@ ORDER BY posts.ID ASC",
// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
break;
case self::ID_TYPE_MISSING_IN_POSTS_TABLE:
$sql = "
$sql = $wpdb->prepare(
"
SELECT posts.ID FROM $wpdb->posts posts
INNER JOIN $orders_table orders ON posts.id=orders.id
WHERE posts.post_type = '" . self::PLACEHOLDER_ORDER_POST_TYPE . "'
AND orders.status not in ( 'auto-draft' )
ORDER BY posts.id ASC
";
AND orders.type IN ($order_post_type_placeholders)
ORDER BY posts.id ASC",
$order_post_types
);
break;
case self::ID_TYPE_DIFFERENT_UPDATE_DATE:
$operator = $this->custom_orders_table_is_authoritative() ? '>' : '<';

View File

@@ -7,6 +7,7 @@ namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Caches\OrderCache;
use Automattic\WooCommerce\Internal\Admin\Orders\EditLock;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -23,12 +24,19 @@ defined( 'ABSPATH' ) || exit;
class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements \WC_Object_Data_Store_Interface, \WC_Order_Data_Store_Interface {
/**
* Order IDs for which we are checking read on sync in the current request.
* Order IDs for which we are checking sync on read in the current request. In WooCommerce, using wc_get_order is a very common pattern, to avoid performance issues, we only sync on read once per request per order. This works because we consider out of sync orders to be an anomaly, so we don't recommend running HPOS with incompatible plugins.
*
* @var array.
*/
private static $reading_order_ids = array();
/**
* Keep track of order IDs that are actively being backfilled. We use this to prevent further read on sync from add_|update_|delete_postmeta etc hooks. If we allow this, then we would end up syncing the same order multiple times as it is being backfilled.
*
* @var array
*/
private static $backfilling_order_ids = array();
/**
* Data stored in meta keys, but not considered "meta" for an order.
*
@@ -509,24 +517,33 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
return $this->all_order_column_mapping;
}
/**
* Helper function to get alias for order table, this is used in select query.
*
* @return string Alias.
*/
private function get_order_table_alias() : string {
return 'o';
}
/**
* Helper function to get alias for op table, this is used in select query.
*
* @return string Alias.
*/
private function get_op_table_alias() : string {
return 'order_operational_data';
return 'p';
}
/**
* Helper function to get alias for address table, this is used in select query.
*
* @param string $type Address type.
* @param string $type Type of address; 'billing' or 'shipping'.
*
* @return string Alias.
*/
private function get_address_table_alias( string $type ) : string {
return "address_$type";
return 'billing' === $type ? 'b' : 's';
}
/**
@@ -562,7 +579,18 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
return;
}
$cpt_data_store->update_order_from_object( $order );
self::$backfilling_order_ids[] = $order->get_id();
$this->update_order_meta_from_object( $order );
$order_class = get_class( $order );
$post_order = new $order_class();
$post_order->set_id( $order->get_id() );
$cpt_data_store->read( $post_order );
// This compares the order data to the post data and set changes array for props that are changed.
$post_order->set_props( $order->get_data() );
$cpt_data_store->update_order_from_object( $post_order );
foreach ( $cpt_data_store->get_internal_data_store_key_getters() as $key => $getter_name ) {
if (
is_callable( array( $cpt_data_store, "set_$getter_name" ) ) &&
@@ -580,6 +608,7 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
);
}
}
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
/**
@@ -762,6 +791,47 @@ class OrdersTableDataStore extends \Abstract_WC_Order_Data_Store_CPT implements
$this->set_stock_reduced( $order, $set );
}
/**
* Get token ids for an order.
*
* @param WC_Order $order Order object.
* @return array
*/
public function get_payment_token_ids( $order ) {
/**
* We don't store _payment_tokens in props to preserve backward compatibility. In CPT data store, `_payment_tokens` is always fetched directly from DB instead of from prop.
*/
$payment_tokens = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
if ( $payment_tokens ) {
$payment_tokens = $payment_tokens[0]->meta_value;
}
if ( ! $payment_tokens && version_compare( $order->get_version(), '8.0.0', '<' ) ) {
// Before 8.0 we were incorrectly storing payment_tokens in the order meta. So we need to check there too.
$payment_tokens = get_post_meta( $order->get_id(), '_payment_tokens', true );
}
return array_filter( (array) $payment_tokens );
}
/**
* Update token ids for an order.
*
* @param WC_Order $order Order object.
* @param array $token_ids Payment token ids.
*/
public function update_payment_token_ids( $order, $token_ids ) {
$meta = new \WC_Meta_Data();
$meta->key = '_payment_tokens';
$meta->value = $token_ids;
$existing_meta = $this->data_store_meta->get_metadata_by_key( $order, '_payment_tokens' );
if ( $existing_meta ) {
$existing_meta = $existing_meta[0];
$meta->id = $existing_meta->id;
$this->data_store_meta->update_meta( $order, $meta );
} else {
$this->data_store_meta->add_meta( $order, $meta );
}
}
/**
* Get amount already refunded.
*
@@ -1051,8 +1121,20 @@ WHERE
}
$data_sync_enabled = $data_synchronizer->data_sync_is_enabled();
$load_posts_for = array_diff( $order_ids, self::$reading_order_ids );
$post_orders = $data_sync_enabled ? $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) ) : array();
if ( $data_sync_enabled ) {
/**
* Allow opportunity to disable sync on read, while keeping sync on write enabled. This adds another step as a large shop progresses from full sync to no sync with HPOS authoritative.
* This filter is only executed if data sync is enabled from settings in the first place as it's meant to be a step between full sync -> no sync, rather than be a control for enabling just the sync on read. Sync on read without sync on write is problematic as any update will reset on the next read, but sync on write without sync on read is fine.
*
* @param bool $read_on_sync_enabled Whether to sync on read.
*
* @since 8.1.0
*/
$data_sync_enabled = apply_filters( 'woocommerce_hpos_enable_sync_on_read', $data_sync_enabled );
}
$load_posts_for = array_diff( $order_ids, array_merge( self::$reading_order_ids, self::$backfilling_order_ids ) );
$post_orders = $data_sync_enabled ? $this->get_post_orders_for_ids( array_intersect_key( $orders, array_flip( $load_posts_for ) ) ) : array();
foreach ( $data as $order_data ) {
$order_id = absint( $order_data->id );
@@ -1446,14 +1528,19 @@ WHERE
* @return \stdClass[]|object|null DB Order objects or error.
*/
protected function get_order_data_for_ids( $ids ) {
if ( ! $ids ) {
global $wpdb;
if ( ! $ids || empty( $ids ) ) {
return array();
}
global $wpdb;
if ( empty( $ids ) ) {
return array();
}
$table_aliases = array(
'orders' => $this->get_order_table_alias(),
'billing_address' => $this->get_address_table_alias( 'billing' ),
'shipping_address' => $this->get_address_table_alias( 'shipping' ),
'operational_data' => $this->get_op_table_alias(),
);
$order_table_alias = $table_aliases['orders'];
$order_table_query = $this->get_order_table_select_statement();
$id_placeholder = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$order_meta_table = self::get_meta_table_name();
@@ -1461,7 +1548,7 @@ WHERE
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $order_table_query is autogenerated and should already be prepared.
$table_data = $wpdb->get_results(
$wpdb->prepare(
"$order_table_query WHERE wc_order.id in ( $id_placeholder )",
"$order_table_query WHERE $order_table_alias.id in ( $id_placeholder )",
$ids
)
);
@@ -1476,9 +1563,27 @@ WHERE
$ids
)
);
foreach ( $table_data as $table_datum ) {
$order_data[ $table_datum->id ] = $table_datum;
$order_data[ $table_datum->id ]->meta_data = array();
$id = $table_datum->{"{$order_table_alias}_id"};
$order_data[ $id ] = new \stdClass();
foreach ( $this->get_all_order_column_mappings() as $table_name => $column_mappings ) {
$table_alias = $table_aliases[ $table_name ];
// This remapping is required to keep the query length small enough to be supported by implementations such as HyperDB (i.e. fetching some tables in join via alias.*, while others via full name). We can revert this commit if HyperDB starts supporting SRTM for query length more than 3076 characters.
foreach ( $column_mappings as $field => $map ) {
$field_name = $map['name'] ?? "{$table_name}_$field";
if ( property_exists( $table_datum, $field_name ) ) {
$field_value = $table_datum->{ $field_name }; // Unique column, field name is different prop name.
} elseif ( property_exists( $table_datum, "{$table_alias}_$field" ) ) {
$field_value = $table_datum->{"{$table_alias}_$field"}; // Non-unique column (billing, shipping etc).
} else {
$field_value = $table_datum->{ $field }; // Unique column, field name is same as prop name.
}
$order_data[ $id ]->{$field_name} = $field_value;
}
}
$order_data[ $id ]->id = $id;
$order_data[ $id ]->meta_data = array();
}
foreach ( $meta_data as $meta_datum ) {
@@ -1500,8 +1605,7 @@ WHERE
*/
private function get_order_table_select_statement() {
$order_table = $this::get_orders_table_name();
$order_table_alias = 'wc_order';
$select_clause = $this->generate_select_clause_for_props( $order_table_alias, $this->order_column_mapping );
$order_table_alias = $this->get_order_table_alias();
$billing_address_table_alias = $this->get_address_table_alias( 'billing' );
$shipping_address_table_alias = $this->get_address_table_alias( 'shipping' );
$op_data_table_alias = $this->get_op_table_alias();
@@ -1509,8 +1613,12 @@ WHERE
$shipping_address_clauses = $this->join_shipping_address_table_to_order_query( $order_table_alias, $shipping_address_table_alias );
$operational_data_clauses = $this->join_operational_data_table_to_order_query( $order_table_alias, $op_data_table_alias );
/**
* We fully spell out address table columns because they have duplicate columns for billing and shipping and would be overwritten if we don't spell them out. There is not such duplication in the operational data table and orders table, so select with `alias`.* is fine.
* We do spell ID columns manually, as they are duplicate.
*/
return "
SELECT $select_clause, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, {$operational_data_clauses['select']}
SELECT $order_table_alias.id as o_id, $op_data_table_alias.id as p_id, $order_table_alias.*, {$billing_address_clauses['select']}, {$shipping_address_clauses['select']}, $op_data_table_alias.*
FROM $order_table $order_table_alias
LEFT JOIN {$billing_address_clauses['join']}
LEFT JOIN {$shipping_address_clauses['join']}
@@ -1558,7 +1666,7 @@ FROM $order_meta_table
/**
* Helper method to generate join and select query for address table.
*
* @param string $address_type Type of address. Typically will be `billing` or `shipping`.
* @param string $address_type Type of address; 'billing' or 'shipping'.
* @param string $order_table_alias Alias of order table to use.
* @param string $address_table_alias Alias for address table to use.
*
@@ -1656,9 +1764,11 @@ FROM $order_meta_table
if ( 'create' === $context ) {
$post_id = wp_insert_post(
array(
'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
'post_status' => 'draft',
'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
'post_type' => $data_sync->data_sync_is_enabled() ? $order->get_type() : $data_sync::PLACEHOLDER_ORDER_POST_TYPE,
'post_status' => 'draft',
'post_parent' => $order->get_changes()['parent_id'] ?? $order->get_data()['parent_id'] ?? 0,
'post_date' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getOffsetTimestamp() ),
'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $order->get_date_created( 'edit' )->getTimestamp() ),
)
);
@@ -1796,8 +1906,20 @@ FROM $order_meta_table
if ( $row ) {
$result[] = array(
'table' => self::get_orders_table_name(),
'data' => array_merge( $row['data'], array( 'id' => $order->get_id() ) ),
'format' => array_merge( $row['format'], array( 'id' => '%d' ) ),
'data' => array_merge(
$row['data'],
array(
'id' => $order->get_id(),
'type' => $order->get_type(),
)
),
'format' => array_merge(
$row['format'],
array(
'id' => '%d',
'type' => '%s',
)
),
);
}
@@ -1866,8 +1988,6 @@ FROM $order_meta_table
protected function get_db_row_from_order( $order, $column_mapping, $only_changes = false ) {
$changes = $only_changes ? $order->get_changes() : array_merge( $order->get_data(), $order->get_changes() );
$changes['type'] = $order->get_type();
// Make sure 'status' is correctly prefixed.
if ( array_key_exists( 'status', $column_mapping ) && array_key_exists( 'status', $changes ) ) {
$changes['status'] = $this->get_post_status( $order );
@@ -2101,16 +2221,6 @@ FROM $order_meta_table
'_wp_trash_meta_time' => time(),
);
foreach ( $trash_metadata as $meta_key => $meta_value ) {
$this->add_meta(
$order,
(object) array(
'key' => $meta_key,
'value' => $meta_value,
)
);
}
$wpdb->update(
self::get_orders_table_name(),
array(
@@ -2124,6 +2234,16 @@ FROM $order_meta_table
$order->set_status( 'trash' );
foreach ( $trash_metadata as $meta_key => $meta_value ) {
$this->add_meta(
$order,
(object) array(
'key' => $meta_key,
'value' => $meta_value,
)
);
}
$data_synchronizer = wc_get_container()->get( DataSynchronizer::class );
if ( $data_synchronizer->data_sync_is_enabled() ) {
wp_trash_post( $order->get_id() );
@@ -2255,6 +2375,11 @@ FROM $order_meta_table
$this->persist_save( $order );
// Do not fire 'woocommerce_new_order' for draft statuses for backwards compatibility.
if ( 'auto-draft' === $order->get_status( 'edit' ) ) {
return;
}
/**
* Fires when a new order is created.
*
@@ -2287,15 +2412,22 @@ FROM $order_meta_table
$order->set_date_created( time() );
}
$this->update_order_meta( $order );
if ( ! $order->get_date_modified( 'edit' ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
}
$this->persist_order_to_db( $order, $force_all_fields );
$this->update_order_meta( $order );
$order->save_meta_data();
$order->apply_changes();
if ( $backfill ) {
$this->maybe_backfill_post_record( $order );
self::$backfilling_order_ids[] = $order->get_id();
$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
$this->maybe_backfill_post_record( $r_order );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
$this->clear_caches( $order );
}
@@ -2306,6 +2438,9 @@ FROM $order_meta_table
* @param \WC_Order $order Order object.
*/
public function update( &$order ) {
$previous_status = ArrayUtil::get_value_or_default( $order->get_data(), 'status' );
$changes = $order->get_changes();
// Before updating, ensure date paid is set if missing.
if (
! $order->get_date_paid( 'edit' )
@@ -2339,6 +2474,18 @@ FROM $order_meta_table
$order->apply_changes();
$this->clear_caches( $order );
// For backwards compatibility, moving an auto-draft order to a valid status triggers the 'woocommerce_new_order' hook.
if ( ! empty( $changes['status'] ) && 'auto-draft' === $previous_status ) {
do_action( 'woocommerce_new_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
return;
}
// For backwards compat with CPT, trashing/untrashing and changing previously datastore-level props does not trigger the update hook.
if ( ( ! empty( $changes['status'] ) && in_array( 'trash', array( $changes['status'], $previous_status ), true ) )
|| ( ! empty( $changes ) && ! array_diff_key( $changes, array_flip( $this->get_post_data_store_for_backfill()->get_internal_data_store_key_getters() ) ) ) ) {
return;
}
do_action( 'woocommerce_update_order', $order->get_id(), $order ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment
}
@@ -2370,19 +2517,33 @@ FROM $order_meta_table
$changes = $order->get_changes();
if ( ! isset( $changes['date_modified'] ) ) {
$order->set_date_modified( time() );
$order->set_date_modified( current_time( 'mysql' ) );
}
$this->persist_order_to_db( $order );
$order->save_meta_data();
if ( $backfill ) {
$this->maybe_backfill_post_record( $order );
self::$backfilling_order_ids[] = $order->get_id();
$this->clear_caches( $order );
$r_order = wc_get_order( $order->get_id() ); // Refresh order to account for DB changes from post hooks.
$this->maybe_backfill_post_record( $r_order );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $order->get_id() ) );
}
return $changes;
}
/**
* Helper method to check whether to backfill post record.
*
* @return bool
*/
private function should_backfill_post_record() {
$data_sync = wc_get_container()->get( DataSynchronizer::class );
return $data_sync->data_sync_is_enabled();
}
/**
* Helper function to decide whether to backfill post record.
*
@@ -2391,8 +2552,7 @@ FROM $order_meta_table
* @return void
*/
private function maybe_backfill_post_record( $order ) {
$data_sync = wc_get_container()->get( DataSynchronizer::class );
if ( $data_sync->data_sync_is_enabled() ) {
if ( $this->should_backfill_post_record() ) {
$this->backfill_post_record( $order );
}
}
@@ -2421,8 +2581,10 @@ FROM $order_meta_table
private function update_address_index_meta( $order, $changes ) {
// If address changed, store concatenated version to make searches faster.
foreach ( array( 'billing', 'shipping' ) as $address_type ) {
if ( isset( $changes[ $address_type ] ) ) {
$order->update_meta_data( "_{$address_type}_address_index", implode( ' ', $order->get_address( $address_type ) ) );
$index_meta_key = "_{$address_type}_address_index";
if ( isset( $changes[ $address_type ] ) || ( is_a( $order, 'WC_Order' ) && empty( $order->get_meta( $index_meta_key ) ) ) ) {
$order->update_meta_data( $index_meta_key, implode( ' ', $order->get_address( $address_type ) ) );
}
}
}
@@ -2584,6 +2746,10 @@ FROM $order_meta_table
$operational_data_table_name = $this->get_operational_data_table_name();
$meta_table = $this->get_meta_table_name();
$max_index_length = $this->database_util->get_max_index_length();
$composite_meta_value_index_length = max( $max_index_length - 8 - 100 - 1, 20 ); // 8 for order_id, 100 for meta_key, 10 minimum for meta_value.
$composite_customer_id_email_length = max( $max_index_length - 20, 20 ); // 8 for customer_id, 20 minimum for email.
$sql = "
CREATE TABLE $orders_table_name (
id bigint(20) unsigned,
@@ -2606,9 +2772,9 @@ CREATE TABLE $orders_table_name (
PRIMARY KEY (id),
KEY status (status),
KEY date_created (date_created_gmt),
KEY customer_id_billing_email (customer_id, billing_email),
KEY billing_email (billing_email),
KEY type_status (type, status),
KEY customer_id_billing_email (customer_id, billing_email({$composite_customer_id_email_length})),
KEY billing_email (billing_email($max_index_length)),
KEY type_status_date (type, status, date_created_gmt),
KEY parent_order_id (parent_order_id),
KEY date_updated (date_updated_gmt)
) $collate;
@@ -2629,7 +2795,7 @@ CREATE TABLE $addresses_table_name (
phone varchar(100) null,
KEY order_id (order_id),
UNIQUE KEY address_type_order_id (address_type, order_id),
KEY email (email),
KEY email (email($max_index_length)),
KEY phone (phone)
) $collate;
CREATE TABLE $operational_data_table_name (
@@ -2646,10 +2812,10 @@ CREATE TABLE $operational_data_table_name (
order_stock_reduced tinyint(1) NULL,
date_paid_gmt datetime NULL,
date_completed_gmt datetime NULL,
shipping_tax_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,
shipping_tax_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,
recorded_sales tinyint(1) NULL,
UNIQUE KEY order_id (order_id),
KEY order_key (order_key)
@@ -2659,8 +2825,8 @@ CREATE TABLE $meta_table (
order_id bigint(20) unsigned null,
meta_key varchar(255),
meta_value text null,
KEY meta_key_value (meta_key, meta_value(100)),
KEY order_id_meta_key_meta_value (order_id, meta_key, meta_value(100))
KEY meta_key_value (meta_key(100), meta_value($composite_meta_value_index_length)),
KEY order_id_meta_key_meta_value (order_id, meta_key(100), meta_value($composite_meta_value_index_length))
) $collate;
";
@@ -2681,16 +2847,28 @@ CREATE TABLE $meta_table (
/**
* Deletes meta based on meta ID.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing at least ->id).
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing at least ->id).
*
* @return bool
*/
public function delete_meta( &$object, $meta ) {
$delete_meta = $this->data_store_meta->delete_meta( $object, $meta );
if ( $this->should_backfill_post_record() && isset( $meta->id ) ) {
// Let's get the actual meta key before its deleted for backfilling. We cannot delete just by ID because meta IDs are different in HPOS and posts tables.
$db_meta = $this->data_store_meta->get_metadata_by_id( $meta->id );
if ( $db_meta ) {
$meta->key = $db_meta->meta_key;
$meta->value = $db_meta->meta_value;
}
}
if ( $object instanceof WC_Abstract_Order ) {
$this->maybe_backfill_post_record( $object );
$delete_meta = $this->data_store_meta->delete_meta( $object, $meta );
$changes_applied = $this->after_meta_change( $object, $meta );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() && isset( $meta->key ) ) {
self::$backfilling_order_ids[] = $object->get_id();
delete_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $delete_meta;
@@ -2699,16 +2877,20 @@ CREATE TABLE $meta_table (
/**
* Add new piece of meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->key and ->value).
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing ->key and ->value).
*
* @return int|bool meta ID or false on failure
*/
public function add_meta( &$object, $meta ) {
$add_meta = $this->data_store_meta->add_meta( $object, $meta );
$add_meta = $this->data_store_meta->add_meta( $object, $meta );
$meta->id = $add_meta;
$changes_applied = $this->after_meta_change( $object, $meta );
if ( $object instanceof WC_Abstract_Order ) {
$this->maybe_backfill_post_record( $object );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
self::$backfilling_order_ids[] = $object->get_id();
add_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $add_meta;
@@ -2717,18 +2899,66 @@ CREATE TABLE $meta_table (
/**
* Update meta.
*
* @param WC_Data $object WC_Data object.
* @param stdClass $meta (containing ->id, ->key and ->value).
* @param WC_Data $object WC_Data object.
* @param \stdClass $meta (containing ->id, ->key and ->value).
*
* @return bool
* @return bool The number of rows updated, or false on error.
*/
public function update_meta( &$object, $meta ) {
$update_meta = $this->data_store_meta->update_meta( $object, $meta );
$update_meta = $this->data_store_meta->update_meta( $object, $meta );
$changes_applied = $this->after_meta_change( $object, $meta );
if ( $object instanceof WC_Abstract_Order ) {
$this->maybe_backfill_post_record( $object );
if ( ! $changes_applied && $object instanceof WC_Abstract_Order && $this->should_backfill_post_record() ) {
self::$backfilling_order_ids[] = $object->get_id();
update_post_meta( $object->get_id(), $meta->key, $meta->value );
self::$backfilling_order_ids = array_diff( self::$backfilling_order_ids, array( $object->get_id() ) );
}
return $update_meta;
}
/**
* Perform after meta change operations, including updating the date_modified field, clearing caches and applying changes.
*
* @param WC_Abstract_Order $order Order object.
* @param \WC_Meta_Data $meta Metadata object.
*
* @return bool True if changes were applied, false otherwise.
*/
protected function after_meta_change( &$order, $meta ) {
method_exists( $meta, 'apply_changes' ) && $meta->apply_changes();
// Prevent this happening multiple time in same request.
if ( $this->should_save_after_meta_change( $order, $meta ) ) {
$order->set_date_modified( current_time( 'mysql' ) );
$order->save();
return true;
} else {
$order_cache = wc_get_container()->get( OrderCache::class );
$order_cache->remove( $order->get_id() );
}
return false;
}
/**
* Helper function to check whether the modified date needs to be updated after a meta save.
*
* This method prevents order->save() call multiple times in the same request after any meta update by checking if:
* 1. Order modified date is already the current date, no updates needed in this case.
* 2. If there are changes already queued for order object, then we don't need to update the modified date as it will be updated ina subsequent save() call.
*
* @param WC_Order $order Order object.
* @param \WC_Meta_Data|null $meta Metadata object.
*
* @return bool Whether the modified date needs to be updated.
*/
private function should_save_after_meta_change( $order, $meta = null ) {
$current_time = $this->legacy_proxy->call_function( 'current_time', 'mysql', 1 );
$current_date_time = new \WC_DateTime( $current_time, new \DateTimeZone( 'GMT' ) );
$skip_for = array(
EditLock::META_KEY_NAME,
);
return $order->get_date_modified() < $current_date_time && empty( $order->get_changes() ) && ( ! is_object( $meta ) || ! in_array( $meta->key, $skip_for, true ) );
}
}

View File

@@ -199,7 +199,57 @@ class OrdersTableQuery {
unset( $this->args['customer_note'], $this->args['name'] );
$this->build_query();
$this->run_query();
if ( ! $this->maybe_override_query() ) {
$this->run_query();
}
}
/**
* Lets the `woocommerce_hpos_pre_query` filter override the query.
*
* @return boolean Whether the query was overridden or not.
*/
private function maybe_override_query(): bool {
/**
* Filters the orders array before the query takes place.
*
* Return a non-null value to bypass the HPOS default order queries.
*
* If the query includes limits via the `limit`, `page`, or `offset` arguments, we
* encourage the `found_orders` and `max_num_pages` properties to also be set.
*
* @since 8.2.0
*
* @param array|null $order_data {
* An array of order data.
* @type int[] $orders Return an array of order IDs data to short-circuit the HPOS query,
* or null to allow HPOS to run its normal query.
* @type int $found_orders The number of orders found.
* @type int $max_num_pages The number of pages.
* }
* @param OrdersTableQuery $query The OrdersTableQuery instance.
* @param string $sql The OrdersTableQuery instance.
*/
$pre_query = apply_filters( 'woocommerce_hpos_pre_query', null, $this, $this->sql );
if ( ! $pre_query || ! isset( $pre_query[0] ) || ! is_array( $pre_query[0] ) ) {
return false;
}
// If the filter set the orders, make sure the others values are set as well and skip running the query.
list( $this->orders, $this->found_orders, $this->max_num_pages ) = $pre_query;
if ( ! is_int( $this->found_orders ) || $this->found_orders < 1 ) {
$this->found_orders = count( $this->orders );
}
if ( ! is_int( $this->max_num_pages ) || $this->max_num_pages < 1 ) {
if ( ! $this->arg_isset( 'limit' ) || ! is_int( $this->args['limit'] ) || $this->args['limit'] < 1 ) {
$this->args['limit'] = 10;
}
$this->max_num_pages = (int) ceil( $this->found_orders / $this->args['limit'] );
}
return true;
}
/**
@@ -287,22 +337,22 @@ class OrdersTableQuery {
* YYYY-MM-DD queries have 'day' precision for backwards compatibility.
*
* @param mixed $date The date. Can be a {@see \WC_DateTime}, a timestamp or a string.
* @param string $timezone The timezone to use for the date.
* @return array An array with keys 'year', 'month', 'day' and possibly 'hour', 'minute' and 'second'.
*/
private function date_to_date_query_arg( $date, $timezone ): array {
private function date_to_date_query_arg( $date ): array {
$result = array(
'year' => '',
'month' => '',
'day' => '',
);
$precision = 'second';
if ( is_numeric( $date ) ) {
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( $timezone ) );
$date = new \WC_DateTime( "@{$date}", new \DateTimeZone( 'UTC' ) );
$precision = 'second';
} elseif ( ! is_a( $date, 'WC_DateTime' ) ) {
// YYYY-MM-DD queries have 'day' precision for backwards compat.
$date = wc_string_to_datetime( $date );
// For backwards compat (see https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date)
// only YYYY-MM-DD is considered for date values. Timestamps do support second precision.
$date = wc_string_to_datetime( date( 'Y-m-d', strtotime( $date ) ) );
$precision = 'day';
}
@@ -319,6 +369,80 @@ class OrdersTableQuery {
return $result;
}
/**
* Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator.
*
* @param array $dates_raw Array of dates (in local time) to use in combination with the operator.
* @param string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=).
* @return array Partial date query arg with relevant dates now UTC-based.
*
* @since 8.2.0
*/
private function local_time_to_gmt_date_query( $dates_raw, $operator ) {
$result = array();
// Convert YYYY-MM-DD to UTC timestamp. Per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date only date is relevant (time is ignored).
foreach ( $dates_raw as &$raw_date ) {
$raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) );
}
$date1 = end( $dates_raw );
switch ( $operator ) {
case '>':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => true,
);
break;
case '>=':
$result = array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
);
break;
case '=':
$result = array(
'relation' => 'AND',
array(
'after' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => true,
),
array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
)
);
break;
case '<=':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ),
'inclusive' => false,
);
break;
case '<':
$result = array(
'before' => $this->date_to_date_query_arg( $date1 ),
'inclusive' => false,
);
break;
case '...':
$result = array(
'relation' => 'AND',
$this->local_time_to_gmt_date_query( array( $dates_raw[1] ), '<=' ),
$this->local_time_to_gmt_date_query( array( $dates_raw[0] ), '>=' ),
);
break;
}
if ( ! $result ) {
throw new \Exception( 'Please specify a valid date shorthand operator.' );
}
return $result;
}
/**
* Processes date-related query args and merges the result into 'date_query'.
*
@@ -347,27 +471,45 @@ class OrdersTableQuery {
$date_keys = array_filter( $valid_date_keys, array( $this, 'arg_isset' ) );
foreach ( $date_keys as $date_key ) {
$is_local = in_array( $date_key, $local_date_keys, true );
$date_value = $this->args[ $date_key ];
$operator = '=';
$dates_raw = array();
$dates = array();
$timezone = in_array( $date_key, $gmt_date_keys, true ) ? '+0000' : wc_timezone_string();
if ( is_string( $date_value ) && preg_match( self::REGEX_SHORTHAND_DATES, $date_value, $matches ) ) {
$operator = in_array( $matches[2], $valid_operators, true ) ? $matches[2] : '';
if ( ! empty( $matches[1] ) ) {
$dates[] = $this->date_to_date_query_arg( $matches[1], $timezone );
$dates_raw[] = $matches[1];
}
$dates[] = $this->date_to_date_query_arg( $matches[3], $timezone );
$dates_raw[] = $matches[3];
} else {
$dates[] = $this->date_to_date_query_arg( $date_value, $timezone );
$dates_raw[] = $date_value;
}
if ( empty( $dates ) || ! $operator || ( '...' === $operator && count( $dates ) < 2 ) ) {
if ( empty( $dates_raw ) || ! $operator || ( '...' === $operator && count( $dates_raw ) < 2 ) ) {
throw new \Exception( 'Invalid date_query' );
}
if ( $is_local ) {
$date_key = $local_to_gmt_date_keys[ $date_key ];
if ( ! is_numeric( $dates_raw[0] ) && ( ! isset( $dates_raw[1] ) || ! is_numeric( $dates_raw[1] ) ) ) {
// Only non-numeric args can be considered local time. Timestamps are assumed to be UTC per https://github.com/woocommerce/woocommerce/wiki/wc_get_orders-and-WC_Order_Query#date.
$date_queries[] = array_merge(
array(
'column' => $date_key,
),
$this->local_time_to_gmt_date_query( $dates_raw, $operator )
);
continue;
}
}
$operator_to_keys = array();
if ( in_array( $operator, array( '>', '>=', '...' ), true ) ) {
@@ -378,7 +520,7 @@ class OrdersTableQuery {
$operator_to_keys[] = 'before';
}
$date_key = in_array( $date_key, $local_date_keys, true ) ? $local_to_gmt_date_keys[ $date_key ] : $date_key;
$dates = array_map( array( $this, 'date_to_date_query_arg' ), $dates_raw );
$date_queries[] = array_merge(
array(
'column' => $date_key,
@@ -470,7 +612,7 @@ class OrdersTableQuery {
$op = isset( $query['after'] ) ? 'after' : 'before';
$date_value_local = $query[ $op ];
$date_value_gmt = wc_string_to_timestamp( get_gmt_from_date( wc_string_to_datetime( $date_value_local ) ) );
$query[ $op ] = $this->date_to_date_query_arg( $date_value_gmt, 'UTC' );
$query[ $op ] = $this->date_to_date_query_arg( $date_value_gmt );
}
return $query;

View File

@@ -6,6 +6,9 @@
namespace Automattic\WooCommerce\Internal\DataStores\Orders;
use \WC_Cache_Helper;
use \WC_Meta_Data;
/**
* Class OrdersTableRefundDataStore.
*/
@@ -75,6 +78,9 @@ class OrdersTableRefundDataStore extends OrdersTableDataStore {
return;
}
$refund_cache_key = WC_Cache_Helper::get_cache_prefix( 'orders' ) . 'refunds' . $refund->get_parent_id();
wp_cache_delete( $refund_cache_key, 'orders' );
$this->delete_order_data_from_custom_order_tables( $refund_id );
$refund->set_id( 0 );
@@ -159,8 +165,17 @@ class OrdersTableRefundDataStore extends OrdersTableDataStore {
$props_to_update = $this->get_props_to_update( $refund, $meta_key_to_props );
foreach ( $props_to_update as $meta_key => $prop ) {
$value = $refund->{"get_$prop"}( 'edit' );
$refund->update_meta_data( $meta_key, $value );
$meta_object = new WC_Meta_Data();
$meta_object->key = $meta_key;
$meta_object->value = $refund->{"get_$prop"}( 'edit' );
$existing_meta = $this->data_store_meta->get_metadata_by_key( $refund, $meta_key );
if ( $existing_meta ) {
$existing_meta = $existing_meta[0];
$meta_object->id = $existing_meta->id;
$this->update_meta( $refund, $meta_object );
} else {
$this->add_meta( $refund, $meta_object );
}
$updated_props[] = $prop;
}

View File

@@ -0,0 +1,42 @@
<?php
/**
* BlockTemplatesServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplatesController;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\BlockTemplateRegistry;
use Automattic\WooCommerce\Internal\Admin\BlockTemplateRegistry\TemplateTransformer;
/**
* Service provider for the block templates controller classes in the Automattic\WooCommerce\Internal\BlockTemplateRegistry namespace.
*/
class BlockTemplatesServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
BlockTemplateRegistry::class,
BlockTemplatesController::class,
TemplateTransformer::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( TemplateTransformer::class );
$this->share( BlockTemplateRegistry::class );
$this->share( BlockTemplatesController::class )->addArguments(
array(
BlockTemplateRegistry::class,
TemplateTransformer::class,
)
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
/**
* MarketplaceServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
/**
* Service provider for the Marketplace namespace.
*/
class MarketplaceServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Marketplace::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Marketplace::class );
}
}

View File

@@ -58,6 +58,7 @@ class OrdersDataStoreServiceProvider extends AbstractServiceProvider {
PostsToOrdersMigrationController::class,
LegacyProxy::class,
OrderCacheController::class,
BatchProcessingController::class,
)
);
$this->share( OrdersTableRefundDataStore::class )->addArguments( array( OrdersTableDataStoreMeta::class, DatabaseUtil::class, LegacyProxy::class ) );

View File

@@ -5,12 +5,10 @@
namespace Automattic\WooCommerce\Internal\Features;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Admin\Features\Navigation\Init;
use Automattic\WooCommerce\Admin\Features\NewProductManagementExperience;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
@@ -34,28 +32,21 @@ class FeaturesController {
*
* @var array[]
*/
private $features;
private $features = array();
/**
* The registered compatibility info for WooCommerce plugins, with plugin names as keys.
*
* @var array
*/
private $compatibility_info_by_plugin;
/**
* Ids of the legacy features (they existed before the features engine was implemented).
*
* @var array
*/
private $legacy_feature_ids;
private $compatibility_info_by_plugin = array();
/**
* The registered compatibility info for WooCommerce plugins, with feature ids as keys.
*
* @var array
*/
private $compatibility_info_by_feature;
private $compatibility_info_by_feature = array();
/**
* The LegacyProxy instance to use.
@@ -91,61 +82,6 @@ class FeaturesController {
* Creates a new instance of the class.
*/
public function __construct() {
$hpos_enable_sync = DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION;
$hpos_authoritative = CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION;
$features = array(
'analytics' => array(
'name' => __( 'Analytics', 'woocommerce' ),
'description' => __( 'Enables WooCommerce Analytics', 'woocommerce' ),
'is_experimental' => false,
'enabled_by_default' => true,
'disable_ui' => false,
),
'new_navigation' => array(
'name' => __( 'Navigation', 'woocommerce' ),
'description' => __( 'Adds the new WooCommerce navigation experience to the dashboard', 'woocommerce' ),
'is_experimental' => false,
'disable_ui' => false,
),
'product_block_editor' => array(
'name' => __( 'New product editor', 'woocommerce' ),
'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ),
'is_experimental' => true,
'disable_ui' => false,
),
// Options for HPOS features are added in CustomOrdersTableController to keep the logic in same place.
'custom_order_tables' => array( // This exists for back-compat only, otherwise it's value is superseded by $hpos_authoritative option.
'name' => __( 'High-Performance order storage (COT)', 'woocommerce' ),
'is_experimental' => true,
'enabled_by_default' => false,
),
$hpos_authoritative => array(
'name' => __( 'High performance order storage', 'woocommerce' ),
'is_experimental' => true,
),
$hpos_enable_sync => array(
'name' => '',
'is_experimental' => true,
),
'cart_checkout_blocks' => array(
'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
'is_experimental' => false,
'disable_ui' => true,
),
);
$this->legacy_feature_ids = array(
'analytics',
'new_navigation',
'product_block_editor',
// Compatibility for COT is determined by `custom_order_tables'.
CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
);
$this->init_features( $features );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'added_option', array( $this, 'process_added_option' ), 999, 3 );
self::add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_features_section' ), 10, 1 );
@@ -162,22 +98,139 @@ class FeaturesController {
}
/**
* Initialize the class according to the existing features.
* Register a feature.
*
* @param array $features Information about the existing features.
* This should be called during the `woocommerce_register_feature_definitions` action hook.
*
* @param string $slug The ID slug of the feature.
* @param string $name The name of the feature that will appear on the Features screen and elsewhere.
* @param array $args {
* Optional. Properties that make up the feature definition. Each of these properties can also be set as a
* callback function, as long as that function returns the specified type.
*
* @type array[] $additional_settings An array of definitions for additional settings controls related to
* the feature that will display on the Features screen. See the Settings API
* for the schema of these props.
* @type string $description A brief description of the feature, used as an input label if the feature
* setting is a checkbox.
* @type bool $disabled True to disable the setting field for this feature on the Features screen,
* so it can't be changed.
* @type bool $disable_ui Set to true to hide the setting field for this feature on the
* Features screen. Defaults to false.
* @type bool $enabled_by_default Set to true to have this feature by opt-out instead of opt-in.
* Defaults to false.
* @type bool $is_experimental Set to true to display this feature under the "Experimental" heading on
* the Features screen. Features set to experimental are also omitted from
* the features list in some cases. Defaults to true.
* @type bool $is_legacy Set to true if this feature existed before the FeaturesController class
* was introduced. Features set to legacy also do not produce warnings about
* incompatible plugins. Defaults to false.
* @type string $option_key The key name for the option that enables/disables the feature.
* @type int $order The order that the feature will appear in the list on the Features screen.
* Higher number = higher in the list. Defaults to 10.
* @type array $setting The properties used by the Settings API to render the setting control on
* the Features screen. See the Settings API for the schema of these props.
* }
*
* @return void
*/
private function init_features( array $features ) {
$this->compatibility_info_by_plugin = array();
$this->compatibility_info_by_feature = array();
public function add_feature_definition( $slug, $name, array $args = array() ) {
$defaults = array(
'disable_ui' => false,
'enabled_by_default' => false,
'is_experimental' => true,
'is_legacy' => false,
'name' => $name,
'order' => 10,
);
$args = wp_parse_args( $args, $defaults );
$this->features = $features;
$this->features[ $slug ] = $args;
}
foreach ( array_keys( $this->features ) as $feature_id ) {
$this->compatibility_info_by_feature[ $feature_id ] = array(
'compatible' => array(),
'incompatible' => array(),
/**
* Generate and cache the feature definitions.
*
* @return array[]
*/
private function get_feature_definitions() {
if ( empty( $this->features ) ) {
$legacy_features = array(
'analytics' => array(
'name' => __( 'Analytics', 'woocommerce' ),
'description' => __( 'Enable WooCommerce Analytics', 'woocommerce' ),
'option_key' => Analytics::TOGGLE_OPTION_NAME,
'is_experimental' => false,
'enabled_by_default' => true,
'disable_ui' => false,
'is_legacy' => true,
),
'new_navigation' => array(
'name' => __( 'Navigation', 'woocommerce' ),
'description' => __( 'Add the new WooCommerce navigation experience to the dashboard', 'woocommerce' ),
'option_key' => Init::TOGGLE_OPTION_NAME,
'is_experimental' => false,
'disable_ui' => false,
'is_legacy' => true,
),
'product_block_editor' => array(
'name' => __( 'New product editor', 'woocommerce' ),
'description' => __( 'Try the new product editor (Beta)', 'woocommerce' ),
'is_experimental' => true,
'disable_ui' => false,
'is_legacy' => true,
'disabled' => function() {
return version_compare( get_bloginfo( 'version' ), '6.2', '<' );
},
'desc_tip' => function() {
$string = '';
if ( version_compare( get_bloginfo( 'version' ), '6.2', '<' ) ) {
$string = __( '⚠ This feature is compatible with WordPress version 6.2 or higher.', 'woocommerce' );
}
return $string;
},
),
'cart_checkout_blocks' => array(
'name' => __( 'Cart & Checkout Blocks', 'woocommerce' ),
'description' => __( 'Optimize for faster checkout', 'woocommerce' ),
'is_experimental' => false,
'disable_ui' => true,
),
'marketplace' => array(
'name' => __( 'Marketplace', 'woocommerce' ),
'description' => __(
'New, faster way to find extensions and themes for your WooCommerce store',
'woocommerce'
),
'is_experimental' => false,
'enabled_by_default' => true,
'disable_ui' => false,
'is_legacy' => true,
),
);
foreach ( $legacy_features as $slug => $definition ) {
$this->add_feature_definition( $slug, $definition['name'], $definition );
}
/**
* The action for registering features.
*
* @since 8.3.0
*
* @param FeaturesController $features_controller The instance of FeaturesController.
*/
do_action( 'woocommerce_register_feature_definitions', $this );
foreach ( array_keys( $this->features ) as $feature_id ) {
$this->compatibility_info_by_feature[ $feature_id ] = array(
'compatible' => array(),
'incompatible' => array(),
);
}
}
return $this->features;
}
/**
@@ -209,7 +262,7 @@ class FeaturesController {
* @returns array An array of information about existing features.
*/
public function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
$features = $this->features;
$features = $this->get_feature_definitions();
if ( ! $include_experimental ) {
$features = array_filter(
@@ -253,7 +306,9 @@ class FeaturesController {
* @return boolean TRUE if the feature is enabled by default, FALSE otherwise.
*/
private function feature_is_enabled_by_default( string $feature_id ): bool {
return ! empty( $this->features[ $feature_id ]['enabled_by_default'] );
$features = $this->get_feature_definitions();
return ! empty( $features[ $feature_id ]['enabled_by_default'] );
}
/**
@@ -335,7 +390,9 @@ class FeaturesController {
* @return bool True if the feature exists.
*/
private function feature_exists( string $feature_id ): bool {
return isset( $this->features[ $feature_id ] );
$features = $this->get_feature_definitions();
return isset( $features[ $feature_id ] );
}
/**
@@ -350,7 +407,7 @@ class FeaturesController {
public function get_compatible_features_for_plugin( string $plugin_name, bool $enabled_features_only = false ) : array {
$this->verify_did_woocommerce_init( __FUNCTION__ );
$features = $this->features;
$features = $this->get_feature_definitions();
if ( $enabled_features_only ) {
$features = array_filter(
$features,
@@ -422,25 +479,22 @@ class FeaturesController {
/**
* Get the name of the option that enables/disables a given feature.
* Note that it doesn't check if the feature actually exists.
*
* @param string $feature_id The id of the feature.
* Note that it doesn't check if the feature actually exists. Instead it
* defaults to "woocommerce_feature_{$feature_id}_enabled" if a different
* name isn't specified in the feature registration.
*
* @param string $feature_id The id of the feature.
* @return string The option that enables or disables the feature.
*/
public function feature_enable_option_name( string $feature_id ): string {
switch ( $feature_id ) {
case 'analytics':
return Analytics::TOGGLE_OPTION_NAME;
case 'new_navigation':
return Init::TOGGLE_OPTION_NAME;
case 'custom_order_tables':
case CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION:
return CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION;
case DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION:
return DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION;
default:
return "woocommerce_feature_{$feature_id}_enabled";
$features = $this->get_feature_definitions();
if ( ! empty( $features[ $feature_id ]['option_key'] ) ) {
return $features[ $feature_id ]['option_key'];
}
return "woocommerce_feature_{$feature_id}_enabled";
}
/**
@@ -451,7 +505,9 @@ class FeaturesController {
* @return bool True if the id corresponds to a legacy feature.
*/
public function is_legacy_feature( string $feature_id ): bool {
return in_array( $feature_id, $this->legacy_feature_ids, true );
$features = $this->get_feature_definitions();
return ! empty( $features[ $feature_id ]['is_legacy'] );
}
/**
@@ -487,23 +543,24 @@ class FeaturesController {
*
* It fires FEATURE_ENABLED_CHANGED_ACTION when a feature is enabled or disabled.
*
* @param string $option The option that has been modified.
* @param string $option The option that has been modified.
* @param mixed $old_value The old value of the option.
* @param mixed $value The new value of the option.
* @param mixed $value The new value of the option.
*
* @return void
*/
private function process_updated_option( string $option, $old_value, $value ) {
$matches = array();
$success = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
$known_features = array(
Analytics::TOGGLE_OPTION_NAME,
Init::TOGGLE_OPTION_NAME,
NewProductManagementExperience::TOGGLE_OPTION_NAME,
DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,
CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
$matches = array();
$is_default_key = preg_match( '/^woocommerce_feature_([a-zA-Z0-9_]+)_enabled$/', $option, $matches );
$features_with_custom_keys = array_filter(
$this->get_feature_definitions(),
function( $feature ) {
return ! empty( $feature['option_key'] );
}
);
$custom_keys = wp_list_pluck( $features_with_custom_keys, 'option_key' );
if ( ! $success && ! in_array( $option, $known_features, true ) ) {
if ( ! $is_default_key && ! in_array( $option, $custom_keys, true ) ) {
return;
}
@@ -511,14 +568,15 @@ class FeaturesController {
return;
}
if ( Analytics::TOGGLE_OPTION_NAME === $option ) {
$feature_id = 'analytics';
} elseif ( Init::TOGGLE_OPTION_NAME === $option ) {
$feature_id = 'new_navigation';
} elseif ( in_array( $option, $known_features, true ) ) {
$feature_id = $option;
} else {
$feature_id = '';
if ( $is_default_key ) {
$feature_id = $matches[1];
} elseif ( in_array( $option, $custom_keys, true ) ) {
$feature_id = array_search( $option, $custom_keys, true );
}
if ( ! $feature_id ) {
return;
}
/**
@@ -562,28 +620,21 @@ class FeaturesController {
return $settings;
}
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing WooCommerce Admin to be disabled.
*
* @param bool $disabled False.
*/
$admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
$feature_settings =
$feature_settings = array(
array(
array(
'title' => __( 'Features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
'id' => 'features_options',
),
);
'title' => __( 'Features', 'woocommerce' ),
'type' => 'title',
'desc' => __( 'Start using new features that are being progressively rolled out to improve the store management experience.', 'woocommerce' ),
'id' => 'features_options',
),
);
$features = $this->get_features( true );
$feature_ids = array_keys( $features );
usort( $feature_ids, function( $feature_id_a, $feature_id_b ) use ( $features ) {
return ( $features[ $feature_id_b ]['order'] ?? 0 ) <=> ( $features[ $feature_id_a ]['order'] ?? 0 );
} );
$experimental_feature_ids = array_filter(
$feature_ids,
function( $feature_id ) use ( $features ) {
@@ -624,7 +675,12 @@ class FeaturesController {
continue;
}
$feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ], $admin_features_disabled );
$feature_settings[] = $this->get_setting_for_feature( $id, $features[ $id ] );
$additional_settings = $features[ $id ]['additional_settings'] ?? array();
if ( count( $additional_settings ) > 0 ) {
$feature_settings = array_merge( $feature_settings, $additional_settings );
}
}
$feature_settings[] = array(
@@ -632,6 +688,20 @@ class FeaturesController {
'id' => empty( $experimental_feature_ids ) ? 'features_options' : 'experimental_features_options',
);
// Allow feature setting properties to be determined dynamically just before being rendered.
$feature_settings = array_map(
function( $feature_setting ) {
foreach ( $feature_setting as $prop => $value ) {
if ( is_callable( $value ) ) {
$feature_setting[ $prop ] = call_user_func( $value );
}
}
return $feature_setting;
},
$feature_settings
);
return $feature_settings;
}
@@ -640,15 +710,24 @@ class FeaturesController {
*
* @param string $feature_id The feature id.
* @param array $feature The feature parameters, as returned by get_features.
* @param bool $admin_features_disabled True if admin features have been disabled via 'woocommerce_admin_disabled' filter.
* @return array The parameters to add to the settings array.
*/
private function get_setting_for_feature( string $feature_id, array $feature, bool $admin_features_disabled ): array {
$description = $feature['description'] ?? '';
$disabled = false;
$desc_tip = '';
$tooltip = $feature['tooltip'] ?? '';
$type = $feature['type'] ?? 'checkbox';
private function get_setting_for_feature( string $feature_id, array $feature ): array {
$description = $feature['description'] ?? '';
$disabled = false;
$desc_tip = '';
$tooltip = $feature['tooltip'] ?? '';
$type = $feature['type'] ?? 'checkbox';
$setting_definition = $feature['setting'] ?? array();
// phpcs:disable WooCommerce.Commenting.CommentHooks.MissingSinceComment
/**
* Filter allowing WooCommerce Admin to be disabled.
*
* @param bool $disabled False.
*/
$admin_features_disabled = apply_filters( 'woocommerce_admin_disabled', false );
// phpcs:enable WooCommerce.Commenting.CommentHooks.MissingSinceComment
if ( ( 'analytics' === $feature_id || 'new_navigation' === $feature_id ) && $admin_features_disabled ) {
$disabled = true;
@@ -658,13 +737,13 @@ class FeaturesController {
if ( $disabled ) {
$update_text = sprintf(
// translators: 1: line break tag.
// translators: 1: line break tag.
__( '%1$s The development of this feature is currently on hold.', 'woocommerce' ),
'<br/>'
);
} else {
$update_text = sprintf(
// translators: 1: line break tag.
// translators: 1: line break tag.
__(
'%1$s This navigation will soon become unavailable while we make necessary improvements.
If you turn it off now, you will not be able to turn it back on.',
@@ -677,7 +756,7 @@ class FeaturesController {
$needs_update = version_compare( get_bloginfo( 'version' ), '5.6', '<' );
if ( $needs_update && current_user_can( 'update_core' ) && current_user_can( 'update_php' ) ) {
$update_text = sprintf(
// translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
// translators: 1: line break tag, 2: open link to WordPress update link, 3: close link tag.
__( '%1$s %2$sUpdate WordPress to enable the new navigation%3$s', 'woocommerce' ),
'<br/>',
'<a href="' . self_admin_url( 'update-core.php' ) . '" target="_blank">',
@@ -691,13 +770,6 @@ class FeaturesController {
}
}
if ( 'product_block_editor' === $feature_id ) {
$disabled = version_compare( get_bloginfo( 'version' ), '6.2', '<' );
if ( $disabled ) {
$desc_tip = __( '⚠ This feature is compatible with WordPress version 6.2 or higher.', 'woocommerce' );
}
}
if ( ! $this->is_legacy_feature( $feature_id ) && ! $disabled && $this->verify_did_woocommerce_init() ) {
$disabled = ! $this->feature_is_enabled( $feature_id );
$plugin_info_for_feature = $this->get_compatible_plugins_for_feature( $feature_id, true );
@@ -716,7 +788,7 @@ class FeaturesController {
*/
$desc_tip = apply_filters( 'woocommerce_feature_description_tip', $desc_tip, $feature_id, $disabled );
$feature_setting = array(
$feature_setting_defaults = array(
'title' => $feature['name'],
'desc' => $description,
'type' => $type,
@@ -727,6 +799,8 @@ class FeaturesController {
'default' => $this->feature_is_enabled_by_default( $feature_id ) ? 'yes' : 'no',
);
$feature_setting = wp_parse_args( $setting_definition, $feature_setting_defaults );
/**
* Allows to modify feature setting that will be used to render in the feature page.
*
@@ -759,7 +833,6 @@ class FeaturesController {
$incompatibles = $this->compatibility_info_by_feature[ $feature ]['incompatible'];
$this->compatibility_info_by_feature[ $feature ]['incompatible'] = array_diff( $incompatibles, array( $plugin_name ) );
}
}
@@ -808,9 +881,11 @@ class FeaturesController {
}
$compatibility = $this->get_compatible_features_for_plugin( $plugin_name );
$incompatible_with = array_diff(
$incompatible_with = array_filter(
array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ),
$this->legacy_feature_ids
function( $feature_id ) {
return ! $this->is_legacy_feature( $feature_id );
}
);
if ( ( 'all' === $feature_id && ! empty( $incompatible_with ) ) || in_array( $feature_id, $incompatible_with, true ) ) {
@@ -849,9 +924,11 @@ class FeaturesController {
foreach ( $this->plugin_util->get_woocommerce_aware_plugins( true ) as $plugin ) {
$compatibility = $this->get_compatible_features_for_plugin( $plugin, true );
$incompatible_with = array_diff(
$incompatible_with = array_filter(
array_merge( $compatibility['incompatible'], $compatibility['uncertain'] ),
$this->legacy_feature_ids
function( $feature_id ) {
return ! $this->is_legacy_feature( $feature_id );
}
);
if ( $incompatible_with ) {
@@ -904,7 +981,7 @@ class FeaturesController {
return false;
}
// phpcs:enable WordPress.Security.NonceVerification
$features = $this->get_feature_definitions();
$plugins_page_url = admin_url( 'plugins.php' );
$features_page_url = $this->get_features_page_url();
@@ -914,7 +991,7 @@ class FeaturesController {
: sprintf(
/* translators: %s is a feature name. */
__( "You are viewing the active plugins that are incompatible with the '%s' feature.", 'woocommerce' ),
$this->features[ $feature_id ]['name']
$features[ $feature_id ]['name']
);
$message .= '<br />';
@@ -975,6 +1052,7 @@ class FeaturesController {
return;
}
$features = $this->get_feature_definitions();
$feature_compatibility_info = $this->get_compatible_features_for_plugin( $plugin_file, true );
$incompatible_features = array_merge( $feature_compatibility_info['incompatible'], $feature_compatibility_info['uncertain'] );
$incompatible_features = array_values(
@@ -997,21 +1075,21 @@ class FeaturesController {
$message = sprintf(
/* translators: %s = printable plugin name */
__( "⚠ This plugin is incompatible with the enabled WooCommerce feature '%s', it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name']
$features[ $incompatible_features[0] ]['name']
);
} elseif ( 2 === $incompatible_features_count ) {
/* translators: %1\$s, %2\$s = printable plugin names */
$message = sprintf(
__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s' and '%2\$s', it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name'],
$this->features[ $incompatible_features[1] ]['name']
$features[ $incompatible_features[0] ]['name'],
$features[ $incompatible_features[1] ]['name']
);
} else {
/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
$message = sprintf(
__( "⚠ This plugin is incompatible with the enabled WooCommerce features '%1\$s', '%2\$s' and %3\$d more, it shouldn't be activated.", 'woocommerce' ),
$this->features[ $incompatible_features[0] ]['name'],
$this->features[ $incompatible_features[1] ]['name'],
$features[ $incompatible_features[0] ]['name'],
$features[ $incompatible_features[1] ]['name'],
$incompatible_features_count - 2
);
}
@@ -1040,7 +1118,7 @@ class FeaturesController {
*
* @return string
*/
private function get_features_page_url(): string {
public function get_features_page_url(): string {
return admin_url( 'admin.php?page=wc-settings&tab=advanced&section=features' );
}
@@ -1112,13 +1190,14 @@ class FeaturesController {
// phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
$all_items = get_plugins();
$features = $this->get_feature_definitions();
$incompatible_plugins_count = count( $this->filter_plugins_list( $all_items ) );
$incompatible_text =
'all' === $feature_id
? __( 'Incompatible with WooCommerce features', 'woocommerce' )
/* translators: %s = name of a WooCommerce feature */
: sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $this->features[ $feature_id ]['name'] );
: sprintf( __( "Incompatible with '%s'", 'woocommerce' ), $features[ $feature_id ]['name'] );
$incompatible_link = "<a href='plugins.php?plugin_status=incompatible_with_feature&feature_id={$feature_id}' class='current' aria-current='page'>{$incompatible_text} <span class='count'>({$incompatible_plugins_count})</span></a>";
$all_plugins_count = count( $all_items );
@@ -1158,7 +1237,7 @@ class FeaturesController {
$query_params_to_remove = array( '_feature_nonce' );
foreach ( array_keys( $this->features ) as $feature_id ) {
foreach ( array_keys( $this->get_feature_definitions() ) as $feature_id ) {
if ( isset( $_GET[ $feature_id ] ) && is_numeric( $_GET[ $feature_id ] ) ) {
$value = absint( $_GET[ $feature_id ] );

View File

@@ -52,7 +52,7 @@ class DatabaseUtil {
foreach ( $dbdelta_output as $table_name => $result ) {
if ( "Created table $table_name" === $result ) {
$created_tables[] = $table_name;
$created_tables[] = str_replace( '(', '', $table_name );
}
}
@@ -233,23 +233,62 @@ class DatabaseUtil {
*/
public function insert_on_duplicate_key_update( $table_name, $data, $format ) : int {
global $wpdb;
if ( empty( $data ) ) {
return 0;
}
$columns = array_keys( $data );
$columns = array_keys( $data );
$value_format = array();
$values = array();
$index = 0;
// Directly use NULL for placeholder if the value is NULL, since otherwise $wpdb->prepare will convert it to empty string.
foreach ( $data as $key => $value ) {
if ( is_null( $value ) ) {
$value_format[] = 'NULL';
} else {
$values[] = $value;
$value_format[] = $format[ $index ];
}
$index++;
}
$column_clause = '`' . implode( '`, `', $columns ) . '`';
$value_placeholders = implode( ', ', array_values( $format ) );
$value_format_clause = implode( ', ', $value_format );
$on_duplicate_clause = $this->generate_on_duplicate_statement_clause( $columns );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Values are escaped in $wpdb->prepare.
$sql = $wpdb->prepare(
"
INSERT INTO $table_name ( $column_clause )
VALUES ( $value_placeholders )
VALUES ( $value_format_clause )
$on_duplicate_clause
",
array_values( $data )
$values
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared.
return $wpdb->query( $sql );
}
/**
* Get max index length.
*
* @return int Max index length.
*/
public function get_max_index_length() : int {
/**
* Filters the maximum index length in the database.
*
* Indexes have a maximum size of 767 bytes. Historically, we haven't need to be concerned about that.
* As of WP 4.2, however, they moved to utf8mb4, which uses 4 bytes per character. This means that an index which
* used to have room for floor(767/3) = 255 characters, now only has room for floor(767/4) = 191 characters.
*
* Additionally, MyISAM engine also limits the index size to 1000 bytes. We add this filter so that interested folks on InnoDB engine can increase the size till allowed 3071 bytes.
*
* @param int $max_index_length Maximum index length. Default 191.
*
* @since 8.0.0
*/
$max_index_length = apply_filters( 'woocommerce_database_max_index_length', 191 );
// Index length cannot be more than 768, which is 3078 bytes in utf8mb4 and max allowed by InnoDB engine.
return min( absint( $max_index_length ), 767 );
}
}

View File

@@ -6,6 +6,7 @@
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use WC_Cache_Helper;
/**
* Class with utility methods for dealing with webhooks.
@@ -133,4 +134,23 @@ class WebhookUtil {
)
);
}
/**
* Gets the count of webhooks that are configured to use the Legacy REST API to compose their payloads.
*
* @return int
*/
public function get_legacy_webhooks_count(): int {
global $wpdb;
$cache_key = WC_Cache_Helper::get_cache_prefix( 'webhooks' ) . 'legacy_count';
$count = wp_cache_get( $cache_key, 'webhooks' );
if ( false === $count ) {
$count = absint( $wpdb->get_var( "SELECT count( webhook_id ) FROM {$wpdb->prefix}wc_webhooks WHERE `api_version` < 1;" ) );
wp_cache_add( $cache_key, $count, 'webhooks' );
}
return $count;
}
}