initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

74
src/Autoloader.php Normal file
View File

@ -0,0 +1,74 @@
<?php
/**
* Includes the composer Autoloader used for packages and classes in the src/ directory.
*/
namespace Automattic\WooCommerce;
defined( 'ABSPATH' ) || exit;
/**
* Autoloader class.
*
* @since 3.7.0
*/
class Autoloader {
/**
* Static-only class.
*/
private function __construct() {}
/**
* Require the autoloader and return the result.
*
* If the autoloader is not present, let's log the failure and display a nice admin notice.
*
* @return boolean
*/
public static function init() {
$autoloader = dirname( __DIR__ ) . '/vendor/autoload_packages.php';
if ( ! is_readable( $autoloader ) ) {
self::missing_autoloader();
return false;
}
$autoloader_result = require $autoloader;
if ( ! $autoloader_result ) {
return false;
}
return $autoloader_result;
}
/**
* If the autoloader is missing, add an admin notice.
*/
protected static function missing_autoloader() {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore
esc_html__( 'Your installation of WooCommerce is incomplete. If you installed WooCommerce from GitHub, please refer to this document to set up your development environment: https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment', 'woocommerce' )
);
}
add_action(
'admin_notices',
function() {
?>
<div class="notice notice-error">
<p>
<?php
printf(
/* translators: 1: is a link to a support document. 2: closing link */
esc_html__( 'Your installation of WooCommerce is incomplete. If you installed WooCommerce from GitHub, %1$splease refer to this document%2$s to set up your development environment.', 'woocommerce' ),
'<a href="' . esc_url( 'https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment' ) . '" target="_blank" rel="noopener noreferrer">',
'</a>'
);
?>
</p>
</div>
<?php
}
);
}
}

View File

@ -0,0 +1,229 @@
<?php
/**
* Handle product stock reservation during checkout.
*/
namespace Automattic\WooCommerce\Checkout\Helpers;
defined( 'ABSPATH' ) || exit;
/**
* Stock Reservation class.
*/
final class ReserveStock {
/**
* Is stock reservation enabled?
*
* @var boolean
*/
private $enabled = true;
/**
* Constructor
*/
public function __construct() {
// Table needed for this feature are added in 4.3.
$this->enabled = get_option( 'woocommerce_schema_version', 0 ) >= 430;
}
/**
* Is stock reservation enabled?
*
* @return boolean
*/
protected function is_enabled() {
return $this->enabled;
}
/**
* Query for any existing holds on stock for this item.
*
* @param \WC_Product $product Product to get reserved stock for.
* @param integer $exclude_order_id Optional order to exclude from the results.
*
* @return integer Amount of stock already reserved.
*/
public function get_reserved_stock( $product, $exclude_order_id = 0 ) {
global $wpdb;
if ( ! $this->is_enabled() ) {
return 0;
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
return (int) $wpdb->get_var( $this->get_query_for_reserved_stock( $product->get_stock_managed_by_id(), $exclude_order_id ) );
}
/**
* Put a temporary hold on stock for an order if enough is available.
*
* @throws ReserveStockException If stock cannot be reserved.
*
* @param \WC_Order $order Order object.
* @param int $minutes How long to reserve stock in minutes. Defaults to woocommerce_hold_stock_minutes.
*/
public function reserve_stock_for_order( $order, $minutes = 0 ) {
$minutes = $minutes ? $minutes : (int) get_option( 'woocommerce_hold_stock_minutes', 60 );
if ( ! $minutes || ! $this->is_enabled() ) {
return;
}
try {
$items = array_filter(
$order->get_items(),
function( $item ) {
return $item->is_type( 'line_item' ) && $item->get_product() instanceof \WC_Product && $item->get_quantity() > 0;
}
);
$rows = array();
foreach ( $items as $item ) {
$product = $item->get_product();
if ( ! $product->is_in_stock() ) {
throw new ReserveStockException(
'woocommerce_product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( '&quot;%s&quot; is out of stock and cannot be purchased.', 'woocommerce' ),
$product->get_name()
),
403
);
}
// If stock management is off, no need to reserve any stock here.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
}
$managed_by_id = $product->get_stock_managed_by_id();
/**
* Filter order item quantity.
*
* @param int|float $quantity Quantity.
* @param WC_Order $order Order data.
* @param WC_Order_Item_Product $item Order item data.
*/
$item_quantity = apply_filters( 'woocommerce_order_item_quantity', $item->get_quantity(), $order, $item );
$rows[ $managed_by_id ] = isset( $rows[ $managed_by_id ] ) ? $rows[ $managed_by_id ] + $item_quantity : $item_quantity;
}
if ( ! empty( $rows ) ) {
foreach ( $rows as $product_id => $quantity ) {
$this->reserve_stock_for_product( $product_id, $quantity, $order, $minutes );
}
}
} catch ( ReserveStockException $e ) {
$this->release_stock_for_order( $order );
throw $e;
}
}
/**
* Release a temporary hold on stock for an order.
*
* @param \WC_Order $order Order object.
*/
public function release_stock_for_order( $order ) {
global $wpdb;
if ( ! $this->is_enabled() ) {
return;
}
$wpdb->delete(
$wpdb->wc_reserved_stock,
array(
'order_id' => $order->get_id(),
)
);
}
/**
* Reserve stock for a product by inserting rows into the DB.
*
* @throws ReserveStockException If a row cannot be inserted.
*
* @param int $product_id Product ID which is having stock reserved.
* @param int $stock_quantity Stock amount to reserve.
* @param \WC_Order $order Order object which contains the product.
* @param int $minutes How long to reserve stock in minutes.
*/
private function reserve_stock_for_product( $product_id, $stock_quantity, $order, $minutes ) {
global $wpdb;
$product_data_store = \WC_Data_Store::load( 'product' );
$query_for_stock = $product_data_store->get_query_for_stock( $product_id );
$query_for_reserved_stock = $this->get_query_for_reserved_stock( $product_id, $order->get_id() );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
$result = $wpdb->query(
$wpdb->prepare(
"
INSERT INTO {$wpdb->wc_reserved_stock} ( `order_id`, `product_id`, `stock_quantity`, `timestamp`, `expires` )
SELECT %d, %d, %d, NOW(), ( NOW() + INTERVAL %d MINUTE ) FROM DUAL
WHERE ( $query_for_stock FOR UPDATE ) - ( $query_for_reserved_stock FOR UPDATE ) >= %d
ON DUPLICATE KEY UPDATE `expires` = VALUES( `expires` ), `stock_quantity` = VALUES( `stock_quantity` )
",
$order->get_id(),
$product_id,
$stock_quantity,
$minutes,
$stock_quantity
)
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
if ( ! $result ) {
$product = wc_get_product( $product_id );
throw new ReserveStockException(
'woocommerce_product_not_enough_stock',
sprintf(
/* translators: %s: product name */
__( 'Not enough units of %s are available in stock to fulfil this order.', 'woocommerce' ),
$product ? $product->get_name() : '#' . $product_id
),
403
);
}
}
/**
* Returns query statement for getting reserved stock of a product.
*
* @param int $product_id Product ID.
* @param integer $exclude_order_id Optional order to exclude from the results.
* @return string|void Query statement.
*/
private function get_query_for_reserved_stock( $product_id, $exclude_order_id = 0 ) {
global $wpdb;
$query = $wpdb->prepare(
"
SELECT COALESCE( SUM( stock_table.`stock_quantity` ), 0 ) FROM $wpdb->wc_reserved_stock stock_table
LEFT JOIN $wpdb->posts posts ON stock_table.`order_id` = posts.ID
WHERE posts.post_status IN ( 'wc-checkout-draft', 'wc-pending' )
AND stock_table.`expires` > NOW()
AND stock_table.`product_id` = %d
AND stock_table.`order_id` != %d
",
$product_id,
$exclude_order_id
);
/**
* Filter: woocommerce_query_for_reserved_stock
* Allows to filter the query for getting reserved stock of a product.
*
* @since 4.5.0
* @param string $query The query for getting reserved stock of a product.
* @param int $product_id Product ID.
* @param int $exclude_order_id Order to exclude from the results.
*/
return apply_filters( 'woocommerce_query_for_reserved_stock', $query, $product_id, $exclude_order_id );
}
}

View File

@ -0,0 +1,60 @@
<?php
/**
* Exceptions for stock reservation.
*/
namespace Automattic\WooCommerce\Checkout\Helpers;
defined( 'ABSPATH' ) || exit;
/**
* ReserveStockException class.
*/
class ReserveStockException extends \Exception {
/**
* Sanitized error code.
*
* @var string
*/
protected $error_code;
/**
* Error extra data.
*
* @var array
*/
protected $error_data;
/**
* Setup exception.
*
* @param string $code Machine-readable error code, e.g `woocommerce_invalid_product_id`.
* @param string $message User-friendly translated error message, e.g. 'Product ID is invalid'.
* @param int $http_status_code Proper HTTP status code to respond with, e.g. 400.
* @param array $data Extra error data.
*/
public function __construct( $code, $message, $http_status_code = 400, $data = array() ) {
$this->error_code = $code;
$this->error_data = $data;
parent::__construct( $message, $http_status_code );
}
/**
* Returns the error code.
*
* @return string
*/
public function getErrorCode() {
return $this->error_code;
}
/**
* Returns error data.
*
* @return array
*/
public function getErrorData() {
return $this->error_data;
}
}

97
src/Container.php Normal file
View File

@ -0,0 +1,97 @@
<?php
/**
* Container class file.
*/
namespace Automattic\WooCommerce;
use Automattic\WooCommerce\Internal\DependencyManagement\ExtendedContainer;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\DownloadPermissionsAdjusterServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ProductAttributesLookupServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ProxiesServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\RestockRefundedItemsAdjusterServiceProvider;
/**
* PSR11 compliant dependency injection container for WooCommerce.
*
* Classes in the `src` directory should specify dependencies from that directory via an 'init' method having arguments
* with type hints. If an instance of the container itself is needed, the type hint to use is \Psr\Container\ContainerInterface.
*
* Classes in the `src` directory should interact with anything outside (especially code in the `includes` directory
* and WordPress functions) by using the classes in the `Proxies` directory. The exception is idempotent
* functions (e.g. `wp_parse_url`), those can be used directly.
*
* Classes in the `includes` directory should use the `wc_get_container` function to get the instance of the container when
* they need to get an instance of a class from the `src` directory.
*
* Class registration should be done via service providers that inherit from Automattic\WooCommerce\Internal\DependencyManagement
* and those should go in the `src\Internal\DependencyManagement\ServiceProviders` folder unless there's a good reason
* to put them elsewhere. All the service provider class names must be in the `SERVICE_PROVIDERS` constant.
*/
final class Container implements \Psr\Container\ContainerInterface {
/**
* The list of service provider classes to register.
*
* @var string[]
*/
private $service_providers = array(
AssignDefaultCategoryServiceProvider::class,
DownloadPermissionsAdjusterServiceProvider::class,
ProductAttributesLookupServiceProvider::class,
ProxiesServiceProvider::class,
RestockRefundedItemsAdjusterServiceProvider::class,
);
/**
* The underlying container.
*
* @var \League\Container\Container
*/
private $container;
/**
* Class constructor.
*/
public function __construct() {
$this->container = new ExtendedContainer();
// Add ourselves as the shared instance of ContainerInterface,
// register everything else using service providers.
$this->container->share( \Psr\Container\ContainerInterface::class, $this );
foreach ( $this->service_providers as $service_provider_class ) {
$this->container->addServiceProvider( $service_provider_class );
}
}
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for.
*
* @throws NotFoundExceptionInterface No entry was found for **this** identifier.
* @throws Psr\Container\ContainerExceptionInterface Error while retrieving the entry.
*
* @return mixed Entry.
*/
public function get( $id ) {
return $this->container->get( $id );
}
/**
* Returns true if the container can return an entry for the given identifier.
* Returns false otherwise.
*
* `has($id)` returning true does not mean that `get($id)` will not throw an exception.
* It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`.
*
* @param string $id Identifier of the entry to look for.
*
* @return bool
*/
public function has( $id ) {
return $this->container->has( $id );
}
}

View File

@ -0,0 +1,73 @@
<?php
/**
* AssignDefaultCategory class file.
*/
namespace Automattic\WooCommerce\Internal;
defined( 'ABSPATH' ) || exit;
/**
* Class to assign default category to products.
*/
class AssignDefaultCategory {
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
add_action( 'wc_schedule_update_product_default_cat', array( $this, 'maybe_assign_default_product_cat' ) );
}
/**
* When a product category is deleted, we need to check
* if the product has no categories assigned. Then assign
* it a default category. We delay this with a scheduled
* action job to not block the response.
*
* @return void
*/
public function schedule_action() {
WC()->queue()->schedule_single(
time(),
'wc_schedule_update_product_default_cat',
array(),
'wc_update_product_default_cat'
);
}
/**
* Assigns default product category for products
* that have no categories.
*
* @return void
*/
public function maybe_assign_default_product_cat() {
global $wpdb;
$default_category = get_option( 'default_product_cat', 0 );
if ( $default_category ) {
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->term_relationships} (object_id, term_taxonomy_id)
SELECT DISTINCT posts.ID, %s FROM {$wpdb->posts} posts
LEFT JOIN
(
SELECT object_id FROM {$wpdb->term_relationships} term_relationships
LEFT JOIN {$wpdb->term_taxonomy} term_taxonomy ON term_relationships.term_taxonomy_id = term_taxonomy.term_taxonomy_id
WHERE term_taxonomy.taxonomy = 'product_cat'
) AS tax_query
ON posts.ID = tax_query.object_id
WHERE posts.post_type = 'product'
AND tax_query.object_id IS NULL",
$default_category
)
);
wp_cache_flush();
delete_transient( 'wc_term_counts' );
wp_update_term_count_now( array( $default_category ), 'product_cat' );
}
}
}

View File

@ -0,0 +1,172 @@
<?php
/**
* AbstractServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Vendor\League\Container\Argument\RawArgument;
use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterface;
use Automattic\WooCommerce\Vendor\League\Container\ServiceProvider\AbstractServiceProvider as BaseServiceProvider;
/**
* Base class for the service providers used to register classes in the container.
*
* See the documentation of the original class this one is based on (https://container.thephpleague.com/3.x/service-providers)
* for basic usage details. What this class adds is:
*
* - The `add_with_auto_arguments` method that allows to register classes without having to specify the injection method arguments.
* - The `share_with_auto_arguments` method, sibling of the above.
* - Convenience `add` and `share` methods that are just proxies for the same methods in `$this->getContainer()`.
*/
abstract class AbstractServiceProvider extends BaseServiceProvider {
/**
* Register a class in the container and use reflection to guess the injection method arguments.
*
* WARNING: this method uses reflection, so please have performance in mind when using it.
*
* @param string $class_name Class name to register.
* @param mixed $concrete The concrete to register. Can be a shared instance, a factory callback, or a class name.
* @param bool $shared Whether to register the class as shared (`get` always returns the same instance) or not.
*
* @return DefinitionInterface The generated container definition.
*
* @throws ContainerException Error when reflecting the class, or class injection method is not public, or an argument has no valid type hint.
*/
protected function add_with_auto_arguments( string $class_name, $concrete = null, bool $shared = false ) : DefinitionInterface {
$definition = new Definition( $class_name, $concrete );
$function = $this->reflect_class_or_callable( $class_name, $concrete );
if ( ! is_null( $function ) ) {
$arguments = $function->getParameters();
foreach ( $arguments as $argument ) {
if ( $argument->isDefaultValueAvailable() ) {
$default_value = $argument->getDefaultValue();
$definition->addArgument( new RawArgument( $default_value ) );
} else {
$argument_class = $this->get_class( $argument );
if ( is_null( $argument_class ) ) {
throw new ContainerException( "Argument '{$argument->getName()}' of class '$class_name' doesn't have a type hint or has one that doesn't specify a class." );
}
$definition->addArgument( $argument_class->name );
}
}
}
// Register the definition only after being sure that no exception will be thrown.
$this->getContainer()->add( $definition->getAlias(), $definition, $shared );
return $definition;
}
/**
* Gets the class of a parameter.
*
* This method is a replacement for ReflectionParameter::getClass,
* which is deprecated as of PHP 8.
*
* @param \ReflectionParameter $parameter The parameter to get the class for.
*
* @return \ReflectionClass|null The class of the parameter, or null if it hasn't any.
*/
private function get_class( \ReflectionParameter $parameter ) {
// TODO: Remove this 'if' block once minimum PHP version for WooCommerce is bumped to at least 7.1.
if ( version_compare( PHP_VERSION, '7.1', '<' ) ) {
return $parameter->getClass();
}
return $parameter->getType() && ! $parameter->getType()->isBuiltin()
? new \ReflectionClass( $parameter->getType()->getName() )
: null;
}
/**
* Check if a combination of class name and concrete is valid for registration.
* Also return the class injection method if the concrete is either a class name or null (then use the supplied class name).
*
* @param string $class_name The class name to check.
* @param mixed $concrete The concrete to check.
*
* @return \ReflectionFunctionAbstract|null A reflection instance for the $class_name injection method or $concrete injection method or callable; null otherwise.
* @throws ContainerException Class has a private injection method, can't reflect class, or the concrete is invalid.
*/
private function reflect_class_or_callable( string $class_name, $concrete ) {
if ( ! isset( $concrete ) || is_string( $concrete ) && class_exists( $concrete ) ) {
try {
$class = $concrete ?? $class_name;
$method = new \ReflectionMethod( $class, Definition::INJECTION_METHOD );
if ( ! isset( $method ) ) {
return null;
}
$missing_modifiers = array();
if ( ! $method->isFinal() ) {
$missing_modifiers[] = 'final';
}
if ( ! $method->isPublic() ) {
$missing_modifiers[] = 'public';
}
if ( ! empty( $missing_modifiers ) ) {
throw new ContainerException( "Method '" . Definition::INJECTION_METHOD . "' of class '$class' isn't '" . implode( ' ', $missing_modifiers ) . "', instances can't be created." );
}
return $method;
} catch ( \ReflectionException $ex ) {
return null;
}
} elseif ( is_callable( $concrete ) ) {
try {
return new \ReflectionFunction( $concrete );
} catch ( \ReflectionException $ex ) {
throw new ContainerException( "Error when reflecting callable: {$ex->getMessage()}" );
}
}
return null;
}
/**
* Register a class in the container and use reflection to guess the injection method arguments.
* The class is registered as shared, so `get` on the container always returns the same instance.
*
* WARNING: this method uses reflection, so please have performance in mind when using it.
*
* @param string $class_name Class name to register.
* @param mixed $concrete The concrete to register. Can be a shared instance, a factory callback, or a class name.
*
* @return DefinitionInterface The generated container definition.
*
* @throws ContainerException Error when reflecting the class, or class injection method is not public, or an argument has no valid type hint.
*/
protected function share_with_auto_arguments( string $class_name, $concrete = null ) : DefinitionInterface {
return $this->add_with_auto_arguments( $class_name, $concrete, true );
}
/**
* Register an entry in the container.
*
* @param string $id Entry id (typically a class or interface name).
* @param mixed|null $concrete Concrete entity to register under that id, null for automatic creation.
* @param bool|null $shared Whether to register the class as shared (`get` always returns the same instance) or not.
*
* @return DefinitionInterface The generated container definition.
*/
protected function add( string $id, $concrete = null, bool $shared = null ) : DefinitionInterface {
return $this->getContainer()->add( $id, $concrete, $shared );
}
/**
* Register a shared entry in the container (`get` always returns the same instance).
*
* @param string $id Entry id (typically a class or interface name).
* @param mixed|null $concrete Concrete entity to register under that id, null for automatic creation.
*
* @return DefinitionInterface The generated container definition.
*/
protected function share( string $id, $concrete = null ) : DefinitionInterface {
return $this->add( $id, $concrete, true );
}
}

View File

@ -0,0 +1,23 @@
<?php
/**
* ExtendedContainer class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
/**
* Class ContainerException.
* Used to signal error conditions related to the dependency injection container.
*/
class ContainerException extends \Exception {
/**
* Create a new instance of the class.
*
* @param null $message The exception message to throw.
* @param int $code The error code.
* @param Exception|null $previous The previous throwable used for exception chaining.
*/
public function __construct( $message = null, $code = 0, Exception $previous = null ) {
parent::__construct( $message, $code, $previous );
}
}

View File

@ -0,0 +1,39 @@
<?php
/**
* An extension to the Definition class to prevent constructor injection from being possible.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Vendor\League\Container\Definition\Definition as BaseDefinition;
/**
* An extension of the definition class that replaces constructor injection with method injection.
*/
class Definition extends BaseDefinition {
/**
* The standard method that we use for dependency injection.
*/
const INJECTION_METHOD = 'init';
/**
* Resolve a class using method injection instead of constructor injection.
*
* @param string $concrete The concrete to instantiate.
*
* @return object
*/
protected function resolveClass( string $concrete ) {
$resolved = $this->resolveArguments( $this->arguments );
$concrete = new $concrete();
// Constructor injection causes backwards compatibility problems
// so we will rely on method injection via an internal method.
if ( method_exists( $concrete, static::INJECTION_METHOD ) ) {
call_user_func_array( array( $concrete, static::INJECTION_METHOD ), $resolved );
}
return $concrete;
}
}

View File

@ -0,0 +1,162 @@
<?php
/**
* ExtendedContainer class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement;
use Automattic\WooCommerce\Utilities\StringUtil;
use Automattic\WooCommerce\Vendor\League\Container\Container as BaseContainer;
use Automattic\WooCommerce\Vendor\League\Container\Definition\DefinitionInterface;
/**
* This class extends the original League's Container object by adding some functionality
* that we need for WooCommerce.
*/
class ExtendedContainer extends BaseContainer {
/**
* The root namespace of all WooCommerce classes in the `src` directory.
*
* @var string
*/
private $woocommerce_namespace = 'Automattic\\WooCommerce\\';
/**
* Whitelist of classes that we can register using the container
* despite not belonging to the WooCommerce root namespace.
*
* In general we allow only the registration of classes in the
* WooCommerce root namespace to prevent registering 3rd party code
* (which doesn't really belong to this container) or old classes
* (which may be eventually deprecated, also the LegacyProxy
* should be used for those).
*
* @var string[]
*/
private $registration_whitelist = array(
\Psr\Container\ContainerInterface::class,
);
/**
* Register a class in the container.
*
* @param string $class_name Class name.
* @param mixed $concrete How to resolve the class with `get`: a factory callback, a concrete instance, another class name, or null to just create an instance of the class.
* @param bool|null $shared Whether the resolution should be performed only once and cached.
*
* @return DefinitionInterface The generated definition for the container.
* @throws ContainerException Invalid parameters.
*/
public function add( string $class_name, $concrete = null, bool $shared = null ) : DefinitionInterface {
if ( ! $this->is_class_allowed( $class_name ) ) {
throw new ContainerException( "You cannot add '$class_name', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
$concrete_class = $this->get_class_from_concrete( $concrete );
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) ) {
throw new ContainerException( "You cannot add concrete '$concrete_class', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
// We want to use a definition class that does not support constructor injection to avoid accidental usage.
if ( ! $concrete instanceof DefinitionInterface ) {
$concrete = new Definition( $class_name, $concrete );
}
return parent::add( $class_name, $concrete, $shared );
}
/**
* Replace an existing registration with a different concrete.
*
* @param string $class_name The class name whose definition will be replaced.
* @param mixed $concrete The new concrete (same as "add").
*
* @return DefinitionInterface The modified definition.
* @throws ContainerException Invalid parameters.
*/
public function replace( string $class_name, $concrete ) : DefinitionInterface {
if ( ! $this->has( $class_name ) ) {
throw new ContainerException( "The container doesn't have '$class_name' registered, please use 'add' instead of 'replace'." );
}
$concrete_class = $this->get_class_from_concrete( $concrete );
if ( isset( $concrete_class ) && ! $this->is_class_allowed( $concrete_class ) && ! $this->is_anonymous_class( $concrete_class ) ) {
throw new ContainerException( "You cannot use concrete '$concrete_class', only classes in the {$this->woocommerce_namespace} namespace are allowed." );
}
return $this->extend( $class_name )->setConcrete( $concrete );
}
/**
* Reset all the cached resolutions, so any further "get" for shared definitions will generate the instance again.
*/
public function reset_all_resolved() {
foreach ( $this->definitions->getIterator() as $definition ) {
// setConcrete causes the cached resolved value to be forgotten.
$concrete = $definition->getConcrete();
$definition->setConcrete( $concrete );
}
}
/**
* Get an instance of a registered class.
*
* @param string $id The class name.
* @param bool $new True to generate a new instance even if the class was registered as shared.
*
* @return object An instance of the requested class.
* @throws ContainerException Attempt to get an instance of a non-namespaced class.
*/
public function get( $id, bool $new = false ) {
if ( false === strpos( $id, '\\' ) ) {
throw new ContainerException( "Attempt to get an instance of the non-namespaced class '$id' from the container, did you forget to add a namespace import?" );
}
return parent::get( $id, $new );
}
/**
* Gets the class from the concrete regardless of type.
*
* @param mixed $concrete The concrete that we want the class from..
*
* @return string|null The class from the concrete if one is available, null otherwise.
*/
protected function get_class_from_concrete( $concrete ) {
if ( is_object( $concrete ) && ! is_callable( $concrete ) ) {
if ( $concrete instanceof DefinitionInterface ) {
return $this->get_class_from_concrete( $concrete->getConcrete() );
}
return get_class( $concrete );
}
if ( is_string( $concrete ) && class_exists( $concrete ) ) {
return $concrete;
}
return null;
}
/**
* Checks to see whether or not a class is allowed to be registered.
*
* @param string $class_name The class to check.
*
* @return bool True if the class is allowed to be registered, false otherwise.
*/
protected function is_class_allowed( string $class_name ): bool {
return StringUtil::starts_with( $class_name, $this->woocommerce_namespace, false ) || in_array( $class_name, $this->registration_whitelist, true );
}
/**
* Check if a class name corresponds to an anonymous class.
*
* @param string $class_name The class name to check.
* @return bool True if the name corresponds to an anonymous class.
*/
protected function is_anonymous_class( string $class_name ): bool {
return StringUtil::starts_with( $class_name, 'class@anonymous' );
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,163 @@
<?php
/**
* DownloadPermissionsAdjuster class file.
*/
namespace Automattic\WooCommerce\Internal;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* Class to adjust download permissions on product save.
*/
class DownloadPermissionsAdjuster {
/**
* The downloads data store to use.
*
* @var WC_Data_Store
*/
private $downloads_data_store;
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
$this->downloads_data_store = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Data_Store::class, 'customer-download' );
add_action( 'adjust_download_permissions', array( $this, 'adjust_download_permissions' ), 10, 1 );
}
/**
* Schedule a download permissions adjustment for a product if necessary.
* This should be executed whenever a product is saved.
*
* @param \WC_Product $product The product to schedule a download permission adjustments for.
*/
public function maybe_schedule_adjust_download_permissions( \WC_Product $product ) {
$children_ids = $product->get_children();
if ( ! $children_ids ) {
return;
}
$scheduled_action_args = array( $product->get_id() );
$already_scheduled_actions =
WC()->call_function(
'as_get_scheduled_actions',
array(
'hook' => 'adjust_download_permissions',
'args' => $scheduled_action_args,
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
);
if ( empty( $already_scheduled_actions ) ) {
WC()->call_function(
'as_schedule_single_action',
WC()->call_function( 'time' ) + 1,
'adjust_download_permissions',
$scheduled_action_args
);
}
}
/**
* Create additional download permissions for variations if necessary.
*
* When a simple downloadable product is converted to a variable product,
* existing download permissions are still present in the database but they don't apply anymore.
* This method creates additional download permissions for the variations based on
* the old existing ones for the main product.
*
* The procedure is as follows. For each existing download permission for the parent product,
* check if there's any variation offering the same file for download (the file URL, not name, is checked).
* If that is found, check if an equivalent permission exists (equivalent means for the same file and with
* the same order id and customer id). If no equivalent permission exists, create it.
*
* @param int $product_id The id of the product to check permissions for.
*/
public function adjust_download_permissions( int $product_id ) {
$product = wc_get_product( $product_id );
if ( ! $product ) {
return;
}
$children_ids = $product->get_children();
if ( ! $children_ids ) {
return;
}
$parent_downloads = $this->get_download_files_and_permissions( $product );
if ( ! $parent_downloads ) {
return;
}
$children_with_downloads = array();
foreach ( $children_ids as $child_id ) {
$child = wc_get_product( $child_id );
$children_with_downloads[ $child_id ] = $this->get_download_files_and_permissions( $child );
}
foreach ( $parent_downloads['permission_data_by_file_order_user'] as $parent_file_order_and_user => $parent_download_data ) {
foreach ( $children_with_downloads as $child_id => $child_download_data ) {
$file_url = $parent_download_data['file'];
$must_create_permission =
// The variation offers the same file as the parent for download...
in_array( $file_url, array_keys( $child_download_data['download_ids_by_file_url'] ), true ) &&
// ...but no equivalent download permission (same file URL, order id and user id) exists.
! array_key_exists( $parent_file_order_and_user, $child_download_data['permission_data_by_file_order_user'] );
if ( $must_create_permission ) {
// The new child download permission is a copy of the parent's,
// but with the product and download ids changed to match those of the variation.
$new_download_data = $parent_download_data['data'];
$new_download_data['product_id'] = $child_id;
$new_download_data['download_id'] = $child_download_data['download_ids_by_file_url'][ $file_url ];
$this->downloads_data_store->create_from_data( $new_download_data );
}
}
}
}
/**
* Get the existing downloadable files and download permissions for a given product.
* The returned value is an array with two keys:
*
* - download_ids_by_file_url: an associative array of file url => download_id.
* - permission_data_by_file_order_user: an associative array where key is "file_url:customer_id:order_id" and value is the full permission data set.
*
* @param \WC_Product $product The product to get the downloadable files and permissions for.
* @return array[] Information about the downloadable files and permissions for the product.
*/
private function get_download_files_and_permissions( \WC_Product $product ) {
$result = array(
'permission_data_by_file_order_user' => array(),
'download_ids_by_file_url' => array(),
);
$downloads = $product->get_downloads();
foreach ( $downloads as $download ) {
$result['download_ids_by_file_url'][ $download->get_file() ] = $download->get_id();
}
$permissions = $this->downloads_data_store->get_downloads( array( 'product_id' => $product->get_id() ) );
foreach ( $permissions as $permission ) {
$permission_data = (array) $permission->data;
if ( array_key_exists( $permission_data['download_id'], $downloads ) ) {
$file = $downloads[ $permission_data['download_id'] ]->get_file();
$data = array(
'file' => $file,
'data' => (array) $permission->data,
);
$result['permission_data_by_file_order_user'][ "${file}:${permission_data['user_id']}:${permission_data['order_id']}" ] = $data;
}
}
return $result;
}
}

View File

@ -0,0 +1,391 @@
<?php
/**
* DataRegenerator class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\LookupDataStore;
use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit;
/**
* This class handles the (re)generation of the product attributes lookup table.
* It schedules the regeneration in small product batches by itself, so it can be used outside the
* regular WooCommerce data regenerations mechanism.
*
* After the regeneration is completed a wp_wc_product_attributes_lookup table will exist with entries for
* all the products that existed when initiate_regeneration was invoked; entries for products created after that
* are supposed to be created/updated by the appropriate data store classes (or by the code that uses
* the data store classes) whenever a product is created/updated.
*
* Additionally, after the regeneration is completed a 'woocommerce_attribute_lookup_enabled' option
* with a value of 'no' will have been created.
*
* This class also adds two entries to the Status - Tools menu: one for manually regenerating the table contents,
* and another one for enabling or disabling the actual lookup table usage.
*/
class DataRegenerator {
const PRODUCTS_PER_GENERATION_STEP = 10;
/**
* The data store to use.
*
* @var LookupDataStore
*/
private $data_store;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* DataRegenerator constructor.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
add_filter(
'woocommerce_debug_tools',
function( $tools ) {
return $this->add_initiate_regeneration_entry_to_tools_array( $tools );
},
1,
999
);
add_action(
'woocommerce_run_product_attribute_lookup_regeneration_callback',
function () {
$this->run_regeneration_step_callback();
}
);
}
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param LookupDataStore $data_store The data store to use.
*/
final public function init( LookupDataStore $data_store ) {
$this->data_store = $data_store;
}
/**
* Initialize the regeneration procedure:
* deletes the lookup table and related options if they exist,
* then it creates the table and runs the first step of the regeneration process.
*
* This is the method that should be used as a callback for a data regeneration in wc-update-functions, e.g.:
*
* function wc_update_XX_regenerate_product_attributes_lookup_table() {
* wc_get_container()->get(DataRegenerator::class)->initiate_regeneration();
* return false;
* }
*
* (Note how we are returning "false" since the class handles the step scheduling by itself).
*/
public function initiate_regeneration() {
$this->enable_or_disable_lookup_table_usage( false );
$this->delete_all_attributes_lookup_data();
$products_exist = $this->initialize_table_and_data();
if ( $products_exist ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration();
}
}
/**
* Delete all the existing data related to the lookup table, including the table itself.
*
* Shortcut to run this method in case the debug tools UI isn't available or for quick debugging:
*
* wp eval "wc_get_container()->get(Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator::class)->delete_all_attributes_lookup_data();"
*/
public function delete_all_attributes_lookup_data() {
global $wpdb;
delete_option( 'woocommerce_attribute_lookup_enabled' );
delete_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup_last_products_page_processed' );
$this->data_store->unset_regeneration_in_progress_flag();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( 'DROP TABLE IF EXISTS ' . $this->lookup_table_name );
}
/**
* Create the lookup table and initialize the options that will be temporarily used
* while the regeneration is in progress.
*
* @return bool True if there's any product at all in the database, false otherwise.
*/
private function initialize_table_and_data() {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
'
CREATE TABLE ' . $this->lookup_table_name . '(
product_id bigint(20) NOT NULL,
product_or_parent_id bigint(20) NOT NULL,
taxonomy varchar(32) NOT NULL,
term_id bigint(20) NOT NULL,
is_variation_attribute tinyint(1) NOT NULL,
in_stock tinyint(1) NOT NULL
);
'
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
$last_existing_product_id =
WC()->call_function(
'wc_get_products',
array(
'return' => 'ids',
'limit' => 1,
'orderby' => array(
'ID' => 'DESC',
),
)
);
if ( ! $last_existing_product_id ) {
// No products exist, nothing to (re)generate.
return false;
}
$this->data_store->set_regeneration_in_progress_flag();
update_option( 'woocommerce_attribute_lookup_last_product_id_to_process', current( $last_existing_product_id ) );
update_option( 'woocommerce_attribute_lookup_last_products_page_processed', 0 );
return true;
}
/**
* Action scheduler callback, performs one regeneration step and then
* schedules the next step if necessary.
*/
private function run_regeneration_step_callback() {
if ( ! $this->data_store->regeneration_is_in_progress() ) {
return;
}
$result = $this->do_regeneration_step();
if ( $result ) {
$this->enqueue_regeneration_step_run();
} else {
$this->finalize_regeneration();
}
}
/**
* Enqueue one regeneration step in action scheduler.
*/
private function enqueue_regeneration_step_run() {
$queue = WC()->get_instance_of( \WC_Queue::class );
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_regeneration_callback',
array(),
'woocommerce-db-updates'
);
}
/**
* Perform one regeneration step: grabs a chunk of products and creates
* the appropriate entries for them in the lookup table.
*
* @return bool True if more steps need to be run, false otherwise.
*/
private function do_regeneration_step() {
$last_products_page_processed = get_option( 'woocommerce_attribute_lookup_last_products_page_processed' );
$current_products_page = (int) $last_products_page_processed + 1;
$product_ids = WC()->call_function(
'wc_get_products',
array(
'limit' => self::PRODUCTS_PER_GENERATION_STEP,
'page' => $current_products_page,
'orderby' => array(
'ID' => 'ASC',
),
'return' => 'ids',
)
);
if ( ! $product_ids ) {
return false;
}
foreach ( $product_ids as $id ) {
$this->data_store->create_data_for_product( $id );
}
update_option( 'woocommerce_attribute_lookup_last_products_page_processed', $current_products_page );
$last_product_id_to_process = get_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
return end( $product_ids ) < $last_product_id_to_process;
}
/**
* Cleanup/final option setup after the regeneration has been completed.
*/
private function finalize_regeneration() {
delete_option( 'woocommerce_attribute_lookup_last_product_id_to_process' );
delete_option( 'woocommerce_attribute_lookup_last_products_page_processed' );
update_option( 'woocommerce_attribute_lookup_enabled', 'no' );
$this->data_store->unset_regeneration_in_progress_flag();
}
/**
* Add a 'Regenerate product attributes lookup table' entry to the Status - Tools page.
*
* @param array $tools_array The tool definitions array that is passed ro the woocommerce_debug_tools filter.
* @return array The tools array with the entry added.
*/
private function add_initiate_regeneration_entry_to_tools_array( array $tools_array ) {
if ( ! $this->data_store->is_feature_visible() ) {
return $tools_array;
}
$lookup_table_exists = $this->data_store->check_lookup_table_exists();
$generation_is_in_progress = $this->data_store->regeneration_is_in_progress();
// Regenerate table.
if ( $lookup_table_exists ) {
$generate_item_name = __( 'Regenerate the product attributes lookup table', 'woocommerce' );
$generate_item_desc = __( 'This tool will regenerate the product attributes lookup table data from existing product(s) data. This process may take a while.', 'woocommerce' );
$generate_item_return = __( 'Product attributes lookup table data is regenerating', 'woocommerce' );
$generate_item_button = __( 'Regenerate', 'woocommerce' );
} else {
$generate_item_name = __( 'Create and fill product attributes lookup table', 'woocommerce' );
$generate_item_desc = __( 'This tool will create the product attributes lookup table data and fill it with existing products data. This process may take a while.', 'woocommerce' );
$generate_item_return = __( 'Product attributes lookup table is being filled', 'woocommerce' );
$generate_item_button = __( 'Create', 'woocommerce' );
}
$entry = array(
'name' => $generate_item_name,
'desc' => $generate_item_desc,
'requires_refresh' => true,
'callback' => function() use ( $generate_item_return ) {
$this->initiate_regeneration_from_tools_page();
return $generate_item_return;
},
);
if ( $lookup_table_exists ) {
$entry['selector'] = array(
'description' => __( 'Select a product to regenerate the data for, or leave empty for a full table regeneration:', 'woocommerce' ),
'class' => 'wc-product-search',
'search_action' => 'woocommerce_json_search_products',
'name' => 'regenerate_product_attribute_lookup_data_product_id',
'placeholder' => esc_attr__( 'Search for a product&hellip;', 'woocommerce' ),
);
}
if ( $generation_is_in_progress ) {
$entry['button'] = sprintf(
/* translators: %d: How many products have been processed so far. */
__( 'Filling in progress (%d)', 'woocommerce' ),
get_option( 'woocommerce_attribute_lookup_last_products_page_processed', 0 ) * self::PRODUCTS_PER_GENERATION_STEP
);
$entry['disabled'] = true;
} else {
$entry['button'] = $generate_item_button;
}
$tools_array['regenerate_product_attributes_lookup_table'] = $entry;
if ( $lookup_table_exists ) {
// Delete the table.
$tools_array['delete_product_attributes_lookup_table'] = array(
'name' => __( 'Delete the product attributes lookup table', 'woocommerce' ),
'desc' => sprintf(
'<strong class="red">%1$s</strong> %2$s',
__( 'Note:', 'woocommerce' ),
__( 'This will delete the product attributes lookup table. You can create it again with the "Create and fill product attributes lookup table" tool.', 'woocommerce' )
),
'button' => __( 'Delete', 'woocommerce' ),
'requires_refresh' => true,
'callback' => function () {
$this->delete_all_attributes_lookup_data();
return __( 'Product attributes lookup table has been deleted.', 'woocommerce' );
},
);
}
return $tools_array;
}
/**
* Callback to initiate the regeneration process from the Status - Tools page.
*
* @throws \Exception The regeneration is already in progress.
*/
private function initiate_regeneration_from_tools_page() {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! isset( $_REQUEST['_wpnonce'] ) || false === wp_verify_nonce( $_REQUEST['_wpnonce'], 'debug_action' ) ) {
throw new \Exception( 'Invalid nonce' );
}
if ( isset( $_REQUEST['regenerate_product_attribute_lookup_data_product_id'] ) ) {
$product_id = (int) $_REQUEST['regenerate_product_attribute_lookup_data_product_id'];
$this->check_can_do_lookup_table_regeneration( $product_id );
$this->data_store->create_data_for_product( $product_id );
} else {
$this->check_can_do_lookup_table_regeneration();
$this->initiate_regeneration();
}
}
/**
* Enable or disable the actual lookup table usage.
*
* @param bool $enable True to enable, false to disable.
* @throws \Exception A lookup table regeneration is currently in progress.
*/
private function enable_or_disable_lookup_table_usage( $enable ) {
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't enable or disable the attributes lookup table usage while it's regenerating." );
}
update_option( 'woocommerce_attribute_lookup_enabled', $enable ? 'yes' : 'no' );
}
/**
* Check if everything is good to go to perform a complete or per product lookup table data regeneration
* and throw an exception if not.
*
* @param mixed $product_id The product id to check the regeneration viability for, or null to check if a complete regeneration is possible.
* @throws \Exception Something prevents the regeneration from starting.
*/
private function check_can_do_lookup_table_regeneration( $product_id = null ) {
if ( ! $this->data_store->is_feature_visible() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: feature is not visible" );
}
if ( $product_id && ! $this->data_store->check_lookup_table_exists() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: lookup table doesn't exist" );
}
if ( $this->data_store->regeneration_is_in_progress() ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: regeneration is already in progress" );
}
if ( $product_id && ! wc_get_product( $product_id ) ) {
throw new \Exception( "Can't do product attribute lookup data regeneration: product doesn't exist" );
}
}
}

View File

@ -0,0 +1,329 @@
<?php
/**
* Filterer class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
defined( 'ABSPATH' ) || exit;
/**
* Helper class for filtering products using the product attributes lookup table.
*/
class Filterer {
/**
* The product attributes lookup data store to use.
*
* @var LookupDataStore
*/
private $data_store;
/**
* The name of the product attributes lookup table.
*
* @var string
*/
private $lookup_table_name;
/**
* Class initialization, invoked by the DI container.
*
* @internal
* @param LookupDataStore $data_store The data store to use.
*/
final public function init( LookupDataStore $data_store ) {
$this->data_store = $data_store;
$this->lookup_table_name = $data_store->get_lookup_table_name();
}
/**
* Checks if the product attribute filtering via lookup table feature is enabled.
*
* @return bool
*/
public function filtering_via_lookup_table_is_active() {
return 'yes' === get_option( 'woocommerce_attribute_lookup_enabled' );
}
/**
* Adds post clauses for filtering via lookup table.
* This method should be invoked within a 'posts_clauses' filter.
*
* @param array $args Product query clauses as supplied to the 'posts_clauses' filter.
* @param \WP_Query $wp_query Current product query as supplied to the 'posts_clauses' filter.
* @param array $attributes_to_filter_by Attribute filtering data as generated by WC_Query::get_layered_nav_chosen_attributes.
* @return array The updated product query clauses.
*/
public function filter_by_attribute_post_clauses( array $args, \WP_Query $wp_query, array $attributes_to_filter_by ) {
global $wpdb;
if ( ! $wp_query->is_main_query() || ! $this->filtering_via_lookup_table_is_active() ) {
return $args;
}
$clause_root = " {$wpdb->prefix}posts.ID IN (";
if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$in_stock_clause = ' AND in_stock = 1';
} else {
$in_stock_clause = '';
}
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
$term_ids_to_filter_by = array_map( 'absint', $term_ids_to_filter_by );
$term_ids_to_filter_by_list = '(' . join( ',', $term_ids_to_filter_by ) . ')';
$is_and_query = 'and' === $data['query_type'];
$count = count( $term_ids_to_filter_by );
if ( 0 !== $count ) {
if ( $is_and_query ) {
$clauses[] = "
{$clause_root}
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=0
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
GROUP BY product_id
HAVING COUNT(product_id)={$count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_to_filter_by_list}
)";
} else {
$clauses[] = "
{$clause_root}
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE term_id in {$term_ids_to_filter_by_list}
{$in_stock_clause}
)";
}
}
}
if ( ! empty( $clauses ) ) {
$args['where'] .= ' AND (' . join( ' AND ', $clauses ) . ')';
} elseif ( ! empty( $attributes_to_filter_by ) ) {
$args['where'] .= ' AND 1=0';
}
return $args;
}
/**
* Count products within certain terms, taking the main WP query into consideration,
* for the WC_Widget_Layered_Nav widget.
*
* This query allows counts to be generated based on the viewed products, not all products.
*
* @param array $term_ids Term IDs.
* @param string $taxonomy Taxonomy.
* @param string $query_type Query Type.
* @return array
*/
public function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type ) {
global $wpdb;
$use_lookup_table = $this->filtering_via_lookup_table_is_active();
$tax_query = \WC_Query::get_main_tax_query();
$meta_query = \WC_Query::get_main_meta_query();
if ( 'or' === $query_type ) {
foreach ( $tax_query as $key => $query ) {
if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) {
unset( $tax_query[ $key ] );
}
}
}
$meta_query = new \WP_Meta_Query( $meta_query );
$tax_query = new \WP_Tax_Query( $tax_query );
if ( $use_lookup_table ) {
$query = $this->get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids );
} else {
$query = $this->get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids );
}
$query = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query );
$query_sql = implode( ' ', $query );
// We have a query - let's see if cached results of this query already exist.
$query_hash = md5( $query_sql );
// Maybe store a transient of the count values.
$cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true );
if ( true === $cache ) {
$cached_counts = (array) get_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) );
} else {
$cached_counts = array();
}
if ( ! isset( $cached_counts[ $query_hash ] ) ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
$results = $wpdb->get_results( $query_sql, ARRAY_A );
$counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) );
$cached_counts[ $query_hash ] = $counts;
if ( true === $cache ) {
set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, DAY_IN_SECONDS );
}
}
return array_map( 'absint', (array) $cached_counts[ $query_hash ] );
}
/**
* Get the query for counting products by terms using the product attributes lookup table.
*
* @param \WP_Tax_Query $tax_query The current main tax query.
* @param \WP_Meta_Query $meta_query The current main meta query.
* @param string $taxonomy The attribute name to get the term counts for.
* @param string $term_ids The term ids to include in the search.
* @return array An array of SQL query parts.
*/
private function get_product_counts_query_using_lookup_table( $tax_query, $meta_query, $taxonomy, $term_ids ) {
global $wpdb;
$meta_query_sql = $meta_query->get_sql( 'post', $this->lookup_table_name, 'product_or_parent_id' );
$tax_query_sql = $tax_query->get_sql( $this->lookup_table_name, 'product_or_parent_id' );
$hide_out_of_stock = 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' );
$in_stock_clause = $hide_out_of_stock ? ' AND in_stock = 1' : '';
$query['select'] = 'SELECT COUNT(DISTINCT product_or_parent_id) as term_count, term_id as term_count_id';
$query['from'] = "FROM {$this->lookup_table_name}";
$query['join'] = "
{$tax_query_sql['join']} {$meta_query_sql['join']}
INNER JOIN {$wpdb->posts} ON {$wpdb->posts}.ID = {$this->lookup_table_name}.product_or_parent_id";
$term_ids_sql = $this->get_term_ids_sql( $term_ids );
$query['where'] = "
WHERE {$wpdb->posts}.post_type IN ( 'product' )
AND {$wpdb->posts}.post_status = 'publish'
{$tax_query_sql['where']} {$meta_query_sql['where']}
AND {$this->lookup_table_name}.taxonomy='{$taxonomy}'
AND {$this->lookup_table_name}.term_id IN $term_ids_sql
{$in_stock_clause}";
if ( ! empty( $term_ids ) ) {
$attributes_to_filter_by = \WC_Query::get_layered_nav_chosen_attributes();
if ( ! empty( $attributes_to_filter_by ) ) {
$and_term_ids = array();
$or_term_ids = array();
foreach ( $attributes_to_filter_by as $taxonomy => $data ) {
$all_terms = get_terms( $taxonomy, array( 'hide_empty' => false ) );
$term_ids_by_slug = wp_list_pluck( $all_terms, 'term_id', 'slug' );
$term_ids_to_filter_by = array_values( array_intersect_key( $term_ids_by_slug, array_flip( $data['terms'] ) ) );
if ( 'and' === $data['query_type'] ) {
$and_term_ids = array_merge( $and_term_ids, $term_ids_to_filter_by );
} else {
$or_term_ids = array_merge( $or_term_ids, $term_ids_to_filter_by );
}
}
if ( ! empty( $and_term_ids ) ) {
$terms_count = count( $and_term_ids );
$term_ids_list = '(' . join( ',', $and_term_ids ) . ')';
$query['where'] .= "
AND product_or_parent_id IN (
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=0
{$in_stock_clause}
AND term_id in {$term_ids_list}
GROUP BY product_id
HAVING COUNT(product_id)={$terms_count}
UNION
SELECT product_or_parent_id
FROM {$this->lookup_table_name} lt
WHERE is_variation_attribute=1
{$in_stock_clause}
AND term_id in {$term_ids_list}
)";
}
if ( ! empty( $or_term_ids ) ) {
$term_ids_list = '(' . join( ',', $or_term_ids ) . ')';
$query['where'] .= "
AND product_or_parent_id IN (
SELECT product_or_parent_id FROM {$this->lookup_table_name}
WHERE term_id in {$term_ids_list}
{$in_stock_clause}
)";
}
} else {
$query['where'] .= $in_stock_clause;
}
} elseif ( $hide_out_of_stock ) {
$query['where'] .= " AND {$this->lookup_table_name}.in_stock=1";
}
$search_query_sql = \WC_Query::get_main_search_query_sql();
if ( $search_query_sql ) {
$query['where'] .= ' AND ' . $search_query_sql;
}
$query['group_by'] = 'GROUP BY terms.term_id';
$query['group_by'] = "GROUP BY {$this->lookup_table_name}.term_id";
return $query;
}
/**
* Get the query for counting products by terms NOT using the product attributes lookup table.
*
* @param \WP_Tax_Query $tax_query The current main tax query.
* @param \WP_Meta_Query $meta_query The current main meta query.
* @param string $term_ids The term ids to include in the search.
* @return array An array of SQL query parts.
*/
private function get_product_counts_query_not_using_lookup_table( $tax_query, $meta_query, $term_ids ) {
global $wpdb;
$meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' );
$tax_query_sql = $tax_query->get_sql( $wpdb->posts, 'ID' );
// Generate query.
$query = array();
$query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) AS term_count, terms.term_id AS term_count_id";
$query['from'] = "FROM {$wpdb->posts}";
$query['join'] = "
INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id
INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id )
INNER JOIN {$wpdb->terms} AS terms USING( term_id )
" . $tax_query_sql['join'] . $meta_query_sql['join'];
$term_ids_sql = $this->get_term_ids_sql( $term_ids );
$query['where'] = "
WHERE {$wpdb->posts}.post_type IN ( 'product' )
AND {$wpdb->posts}.post_status = 'publish'
{$tax_query_sql['where']} {$meta_query_sql['where']}
AND terms.term_id IN $term_ids_sql";
$search_query_sql = \WC_Query::get_main_search_query_sql();
if ( $search_query_sql ) {
$query['where'] .= ' AND ' . $search_query_sql;
}
$query['group_by'] = 'GROUP BY terms.term_id';
return $query;
}
/**
* Formats a list of term ids as "(id,id,id)".
*
* @param array $term_ids The list of terms to format.
* @return string The formatted list.
*/
private function get_term_ids_sql( $term_ids ) {
return '(' . implode( ',', array_map( 'absint', $term_ids ) ) . ')';
}
}

View File

@ -0,0 +1,682 @@
<?php
/**
* LookupDataStore class file.
*/
namespace Automattic\WooCommerce\Internal\ProductAttributesLookup;
use Automattic\WooCommerce\Utilities\ArrayUtil;
defined( 'ABSPATH' ) || exit;
/**
* Data store class for the product attributes lookup table.
*/
class LookupDataStore {
/**
* Types of updates to perform depending on the current changest
*/
const ACTION_NONE = 0;
const ACTION_INSERT = 1;
const ACTION_UPDATE_STOCK = 2;
const ACTION_DELETE = 3;
/**
* The lookup table name.
*
* @var string
*/
private $lookup_table_name;
/**
* Is the feature visible?
*
* @var bool
*/
private $is_feature_visible;
/**
* LookupDataStore constructor. Makes the feature hidden by default.
*/
public function __construct() {
global $wpdb;
$this->lookup_table_name = $wpdb->prefix . 'wc_product_attributes_lookup';
$this->is_feature_visible = false;
$this->init_hooks();
}
/**
* Initialize the hooks used by the class.
*/
private function init_hooks() {
add_action(
'woocommerce_run_product_attribute_lookup_update_callback',
function ( $product_id, $action ) {
$this->run_update_callback( $product_id, $action );
},
10,
2
);
add_filter(
'woocommerce_get_sections_products',
function ( $products ) {
if ( $this->is_feature_visible() && $this->check_lookup_table_exists() ) {
$products['advanced'] = __( 'Advanced', 'woocommerce' );
}
return $products;
},
100,
1
);
add_filter(
'woocommerce_get_settings_products',
function ( $settings, $section_id ) {
if ( 'advanced' === $section_id && $this->is_feature_visible() && $this->check_lookup_table_exists() ) {
$title_item = array(
'title' => __( 'Product attributes lookup table', 'woocommerce' ),
'type' => 'title',
);
$regeneration_is_in_progress = $this->regeneration_is_in_progress();
if ( $regeneration_is_in_progress ) {
$title_item['desc'] = __( 'These settings are not available while the lookup table regeneration is in progress.', 'woocommerce' );
}
$settings[] = $title_item;
if ( ! $regeneration_is_in_progress ) {
$settings[] = array(
'title' => __( 'Enable table usage', 'woocommerce' ),
'desc' => __( 'Use the product attributes lookup table for catalog filtering.', 'woocommerce' ),
'id' => 'woocommerce_attribute_lookup_enabled',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
$settings[] = array(
'title' => __( 'Direct updates', 'woocommerce' ),
'desc' => __( 'Update the table directly upon product changes, instead of scheduling a deferred update.', 'woocommerce' ),
'id' => 'woocommerce_attribute_lookup_direct_updates',
'default' => 'no',
'type' => 'checkbox',
'checkboxgroup' => 'start',
);
}
$settings[] = array( 'type' => 'sectionend' );
}
return $settings;
},
100,
2
);
}
/**
* Check if the lookup table exists in the database.
*
* TODO: Remove this method and references to it once the lookup table is created via data migration.
*
* @return bool
*/
public function check_lookup_table_exists() {
global $wpdb;
$query = $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $this->lookup_table_name ) );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return $this->lookup_table_name === $wpdb->get_var( $query );
}
/**
* 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() {
return $this->is_feature_visible;
}
/**
* Makes the feature visible, so that dedicated entries will be added to the debug tools page.
*/
public function show_feature() {
$this->is_feature_visible = true;
}
/**
* Hides the feature, so that no entries will be added to the debug tools page.
*/
public function hide_feature() {
$this->is_feature_visible = false;
}
/**
* Get the name of the lookup table.
*
* @return string
*/
public function get_lookup_table_name() {
return $this->lookup_table_name;
}
/**
* Insert/update the appropriate lookup table entries for a new or modified product or variation.
* This must be invoked after a product or a variation is created (including untrashing and duplication)
* or modified.
*
* @param int|\WC_Product $product Product object or product id.
* @param null|array $changeset Changes as provided by 'get_changes' method in the product object, null if it's being created.
*/
public function on_product_changed( $product, $changeset = null ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
$action = $this->get_update_action( $changeset );
if ( self::ACTION_NONE !== $action ) {
$this->maybe_schedule_update( $product->get_id(), $action );
}
}
/**
* Schedule an update of the product attributes lookup table for a given product.
* If an update for the same action is already scheduled, nothing is done.
*
* If the 'woocommerce_attribute_lookup_direct_update' option is set to 'yes',
* the update is done directly, without scheduling.
*
* @param int $product_id The product id to schedule the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function maybe_schedule_update( int $product_id, int $action ) {
if ( 'yes' === get_option( 'woocommerce_attribute_lookup_direct_updates' ) ) {
$this->run_update_callback( $product_id, $action );
return;
}
$args = array( $product_id, $action );
$queue = WC()->get_instance_of( \WC_Queue::class );
$already_scheduled = $queue->search(
array(
'hook' => 'woocommerce_run_product_attribute_lookup_update_callback',
'args' => $args,
'status' => \ActionScheduler_Store::STATUS_PENDING,
),
'ids'
);
if ( empty( $already_scheduled ) ) {
$queue->schedule_single(
WC()->call_function( 'time' ) + 1,
'woocommerce_run_product_attribute_lookup_update_callback',
$args,
'woocommerce-db-updates'
);
}
}
/**
* Perform an update of the lookup table for a specific product.
*
* @param int $product_id The product id to perform the update for.
* @param int $action The action to perform, one of the ACTION_ constants.
*/
private function run_update_callback( int $product_id, int $action ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
$product = WC()->call_function( 'wc_get_product', $product_id );
if ( ! $product ) {
$action = self::ACTION_DELETE;
}
switch ( $action ) {
case self::ACTION_INSERT:
$this->delete_data_for( $product_id );
$this->create_data_for( $product );
break;
case self::ACTION_UPDATE_STOCK:
$this->update_stock_status_for( $product );
break;
case self::ACTION_DELETE:
$this->delete_data_for( $product_id );
break;
}
}
/**
* Determine the type of action to perform depending on the received changeset.
*
* @param array|null $changeset The changeset received by on_product_changed.
* @return int One of the ACTION_ constants.
*/
private function get_update_action( $changeset ) {
if ( is_null( $changeset ) ) {
// No changeset at all means that the product is new.
return self::ACTION_INSERT;
}
$keys = array_keys( $changeset );
// Order matters:
// - The change with the most precedence is a change in catalog visibility
// (which will result in all data being regenerated or deleted).
// - Then a change in attributes (all data will be regenerated).
// - And finally a change in stock status (existing data will be updated).
// Thus these conditions must be checked in that same order.
if ( in_array( 'catalog_visibility', $keys, true ) ) {
$new_visibility = $changeset['catalog_visibility'];
if ( 'visible' === $new_visibility || 'catalog' === $new_visibility ) {
return self::ACTION_INSERT;
} else {
return self::ACTION_DELETE;
}
}
if ( in_array( 'attributes', $keys, true ) ) {
return self::ACTION_INSERT;
}
if ( array_intersect( $keys, array( 'stock_quantity', 'stock_status', 'manage_stock' ) ) ) {
return self::ACTION_UPDATE_STOCK;
}
return self::ACTION_NONE;
}
/**
* Update the stock status of the lookup table entries for a given product.
*
* @param \WC_Product $product The product to update the entries for.
*/
private function update_stock_status_for( \WC_Product $product ) {
global $wpdb;
$in_stock = $product->is_in_stock();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'UPDATE ' . $this->lookup_table_name . ' SET in_stock = %d WHERE product_id = %d',
$in_stock ? 1 : 0,
$product->get_id()
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Delete the lookup table contents related to a given product or variation,
* if it's a variable product it deletes the information for variations too.
* This must be invoked after a product or a variation is trashed or deleted.
*
* @param int|\WC_Product $product Product object or product id.
*/
public function on_product_deleted( $product ) {
if ( ! $this->check_lookup_table_exists() ) {
return;
}
if ( is_a( $product, \WC_Product::class ) ) {
$product_id = $product->get_id();
} else {
$product_id = $product;
}
$this->maybe_schedule_update( $product_id, self::ACTION_DELETE );
}
/**
* Create the lookup data for a given product, if a variable product is passed
* the information is created for all of its variations.
* This method is intended to be called from the data regenerator.
*
* @param int|WC_Product $product Product object or id.
* @throws \Exception A variation object is passed.
*/
public function create_data_for_product( $product ) {
if ( ! is_a( $product, \WC_Product::class ) ) {
$product = WC()->call_function( 'wc_get_product', $product );
}
if ( $this->is_variation( $product ) ) {
throw new \Exception( "LookupDataStore::create_data_for_product can't be called for variations." );
}
$this->delete_data_for( $product->get_id() );
$this->create_data_for( $product );
}
/**
* Create lookup table data for a given product.
*
* @param \WC_Product $product The product to create the data for.
*/
private function create_data_for( \WC_Product $product ) {
if ( $this->is_variation( $product ) ) {
$this->create_data_for_variation( $product );
} elseif ( $this->is_variable_product( $product ) ) {
$this->create_data_for_variable_product( $product );
} else {
$this->create_data_for_simple_product( $product );
}
}
/**
* Delete all the lookup table entries for a given product,
* if it's a variable product information for variations is deleted too.
*
* @param int $product_id Simple product id, or main/parent product id for variable products.
*/
private function delete_data_for( int $product_id ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'DELETE FROM ' . $this->lookup_table_name . ' WHERE product_id = %d OR product_or_parent_id = %d',
$product_id,
$product_id
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Create lookup table entries for a simple (non variable) product.
* Assumes that no entries exist yet.
*
* @param \WC_Product $product The product to create the entries for.
*/
private function create_data_for_simple_product( \WC_Product $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$has_stock = $product->is_in_stock();
$product_id = $product->get_id();
foreach ( $product_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $product_id, $product_id, $taxonomy, $term_id, false, $has_stock );
}
}
}
/**
* Create lookup table entries for a variable product.
* Assumes that no entries exist yet.
*
* @param \WC_Product_Variable $product The product to create the entries for.
*/
private function create_data_for_variable_product( \WC_Product_Variable $product ) {
$product_attributes_data = $this->get_attribute_taxonomies( $product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$non_variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return ! $item['used_for_variations'];
}
);
$main_product_has_stock = $product->is_in_stock();
$main_product_id = $product->get_id();
foreach ( $non_variation_attributes_data as $taxonomy => $data ) {
$term_ids = $data['term_ids'];
foreach ( $term_ids as $term_id ) {
$this->insert_lookup_table_data( $main_product_id, $main_product_id, $taxonomy, $term_id, false, $main_product_has_stock );
}
}
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
$variations = $this->get_variations_of( $product );
foreach ( $variation_attributes_data as $taxonomy => $data ) {
foreach ( $variations as $variation ) {
$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product_id, $data['term_ids'], $term_ids_by_slug_cache );
}
}
}
/**
* Create all the necessary lookup data for a given variation.
*
* @param \WC_Product_Variation $variation The variation to create entries for.
*/
private function create_data_for_variation( \WC_Product_Variation $variation ) {
$main_product = WC()->call_function( 'wc_get_product', $variation->get_parent_id() );
$product_attributes_data = $this->get_attribute_taxonomies( $main_product );
$variation_attributes_data = array_filter(
$product_attributes_data,
function( $item ) {
return $item['used_for_variations'];
}
);
$term_ids_by_slug_cache = $this->get_term_ids_by_slug_cache( array_keys( $variation_attributes_data ) );
foreach ( $variation_attributes_data as $taxonomy => $data ) {
$this->insert_lookup_table_data_for_variation( $variation, $taxonomy, $main_product->get_id(), $data['term_ids'], $term_ids_by_slug_cache );
}
}
/**
* Create lookup table entries for a given variation, corresponding to a given taxonomy and a set of term ids.
*
* @param \WC_Product_Variation $variation The variation to create entries for.
* @param string $taxonomy The taxonomy to create the entries for.
* @param int $main_product_id The parent product id.
* @param array $term_ids The term ids to create entries for.
* @param array $term_ids_by_slug_cache A dictionary of term ids by term slug, as returned by 'get_term_ids_by_slug_cache'.
*/
private function insert_lookup_table_data_for_variation( \WC_Product_Variation $variation, string $taxonomy, int $main_product_id, array $term_ids, array $term_ids_by_slug_cache ) {
$variation_id = $variation->get_id();
$variation_has_stock = $variation->is_in_stock();
$variation_definition_term_id = $this->get_variation_definition_term_id( $variation, $taxonomy, $term_ids_by_slug_cache );
if ( $variation_definition_term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $variation_definition_term_id, true, $variation_has_stock );
} else {
$term_ids_for_taxonomy = $term_ids;
foreach ( $term_ids_for_taxonomy as $term_id ) {
$this->insert_lookup_table_data( $variation_id, $main_product_id, $taxonomy, $term_id, true, $variation_has_stock );
}
}
}
/**
* Get a cache of term ids by slug for a set of taxonomies, with this format:
*
* [
* 'taxonomy' => [
* 'slug_1' => id_1,
* 'slug_2' => id_2,
* ...
* ], ...
* ]
*
* @param array $taxonomies List of taxonomies to build the cache for.
* @return array A dictionary of taxonomies => dictionary of term slug => term id.
*/
private function get_term_ids_by_slug_cache( $taxonomies ) {
$result = array();
foreach ( $taxonomies as $taxonomy ) {
$terms = WC()->call_function(
'get_terms',
array(
'taxonomy' => $taxonomy,
'hide_empty' => false,
'fields' => 'id=>slug',
)
);
$result[ $taxonomy ] = array_flip( $terms );
}
return $result;
}
/**
* Get the id of the term that defines a variation for a given taxonomy,
* or null if there's no such defining id (for variations having "Any <taxonomy>" as the definition)
*
* @param \WC_Product_Variation $variation The variation to get the defining term id for.
* @param string $taxonomy The taxonomy to get the defining term id for.
* @param array $term_ids_by_slug_cache A term ids by slug as generated by get_term_ids_by_slug_cache.
* @return int|null The term id, or null if there's no defining id for that taxonomy in that variation.
*/
private function get_variation_definition_term_id( \WC_Product_Variation $variation, string $taxonomy, array $term_ids_by_slug_cache ) {
$variation_attributes = $variation->get_attributes();
$term_slug = ArrayUtil::get_value_or_default( $variation_attributes, $taxonomy );
if ( $term_slug ) {
return $term_ids_by_slug_cache[ $taxonomy ][ $term_slug ];
} else {
return null;
}
}
/**
* Get the variations of a given variable product.
*
* @param \WC_Product_Variable $product The product to get the variations for.
* @return array An array of WC_Product_Variation objects.
*/
private function get_variations_of( \WC_Product_Variable $product ) {
$variation_ids = $product->get_children();
return array_map(
function( $id ) {
return WC()->call_function( 'wc_get_product', $id );
},
$variation_ids
);
}
/**
* Check if a given product is a variable product.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variable product, false otherwise.
*/
private function is_variable_product( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variable::class );
}
/**
* Check if a given product is a variation.
*
* @param \WC_Product $product The product to check.
* @return bool True if it's a variation, false otherwise.
*/
private function is_variation( \WC_Product $product ) {
return is_a( $product, \WC_Product_Variation::class );
}
/**
* Return the list of taxonomies used for variations on a product together with
* the associated term ids, with the following format:
*
* [
* 'taxonomy_name' =>
* [
* 'term_ids' => [id, id, ...],
* 'used_for_variations' => true|false
* ], ...
* ]
*
* @param \WC_Product $product The product to get the attribute taxonomies for.
* @return array Information about the attribute taxonomies of the product.
*/
private function get_attribute_taxonomies( \WC_Product $product ) {
$product_attributes = $product->get_attributes();
$result = array();
foreach ( $product_attributes as $taxonomy_name => $attribute_data ) {
if ( ! $attribute_data->get_id() ) {
// Custom product attribute, not suitable for attribute-based filtering.
continue;
}
$result[ $taxonomy_name ] = array(
'term_ids' => $attribute_data->get_options(),
'used_for_variations' => $attribute_data->get_variation(),
);
}
return $result;
}
/**
* Insert one entry in the lookup table.
*
* @param int $product_id The product id.
* @param int $product_or_parent_id The product id for non-variable products, the main/parent product id for variations.
* @param string $taxonomy Taxonomy name.
* @param int $term_id Term id.
* @param bool $is_variation_attribute True if the taxonomy corresponds to an attribute used to define variations.
* @param bool $has_stock True if the product is in stock.
*/
private function insert_lookup_table_data( int $product_id, int $product_or_parent_id, string $taxonomy, int $term_id, bool $is_variation_attribute, bool $has_stock ) {
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
$wpdb->prepare(
'INSERT INTO ' . $this->lookup_table_name . ' (
product_id,
product_or_parent_id,
taxonomy,
term_id,
is_variation_attribute,
in_stock)
VALUES
( %d, %d, %s, %d, %d, %d )',
$product_id,
$product_or_parent_id,
$taxonomy,
$term_id,
$is_variation_attribute ? 1 : 0,
$has_stock ? 1 : 0
)
);
// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared
}
/**
* Tells if a lookup table regeneration is currently in progress.
*
* @return bool True if a lookup table regeneration is already in progress.
*/
public function regeneration_is_in_progress() {
return 'yes' === get_option( 'woocommerce_attribute_lookup_regeneration_in_progress', null );
}
/**
* Set a permanent flag (via option) indicating that the lookup table regeneration is in process.
*/
public function set_regeneration_in_progress_flag() {
update_option( 'woocommerce_attribute_lookup_regeneration_in_progress', 'yes' );
}
/**
* Remove the flag indicating that the lookup table regeneration is in process.
*/
public function unset_regeneration_in_progress_flag() {
delete_option( 'woocommerce_attribute_lookup_regeneration_in_progress' );
}
}

View File

@ -0,0 +1,202 @@
<?php
/**
* ApiUtil class file.
*/
namespace Automattic\WooCommerce\Internal;
/**
* Helper methos for the REST API.
*
* Class ApiUtil
*
* @package Automattic\WooCommerce\Internal
*/
class RestApiUtil {
/**
* Converts a create refund request from the public API format:
*
* [
* "reason" => "",
* "api_refund" => "x",
* "api_restock" => "x",
* "line_items" => [
* "id" => "111",
* "quantity" => 222,
* "refund_total" => 333,
* "refund_tax" => [
* [
* "id": "444",
* "refund_total": 555
* ],...
* ],...
* ]
*
* ...to the internally used format:
*
* [
* "reason" => null, (if it's missing or any empty value, set as null)
* "api_refund" => true, (if it's missing or non-bool, set as "true")
* "api_restock" => true, (if it's missing or non-bool, set as "true")
* "line_items" => [ (convert sequential array to associative based on "id")
* "111" => [
* "qty" => 222, (rename "quantity" to "qty")
* "refund_total" => 333,
* "refund_tax" => [ (convert sequential array to associative based on "id" and "refund_total)
* "444" => 555,...
* ],...
* ]
* ]
*
* It also calculates the amount if missing and whenever possible, see maybe_calculate_refund_amount_from_line_items.
*
* The conversion is done in a way that if the request is already in the internal format,
* then nothing is changed for compatibility. For example, if the line items array
* is already an associative array or any of its elements
* is missing the "id" key, then the entire array is left unchanged.
* Same for the "refund_tax" array inside each line item.
*
* @param \WP_REST_Request $request The request to adjust.
*/
public static function adjust_create_refund_request_parameters( \WP_REST_Request &$request ) {
if ( empty( $request['reason'] ) ) {
$request['reason'] = null;
}
if ( ! is_bool( $request['api_refund'] ) ) {
$request['api_refund'] = true;
}
if ( ! is_bool( $request['api_restock'] ) ) {
$request['api_restock'] = true;
}
if ( empty( $request['line_items'] ) ) {
$request['line_items'] = array();
} else {
$request['line_items'] = self::adjust_line_items_for_create_refund_request( $request['line_items'] );
}
if ( ! isset( $request['amount'] ) ) {
$amount = self::calculate_refund_amount_from_line_items( $request );
if ( null !== $amount ) {
$request['amount'] = strval( $amount );
}
}
}
/**
* Calculate the "amount" parameter for the request based on the amounts found in line items.
* This will ONLY be possible if ALL of the following is true:
*
* - "line_items" in the request is a non-empty array.
* - All line items have a "refund_total" field with a numeric value.
* - All values inside "refund_tax" in all line items are a numeric value.
*
* The request is assumed to be in internal format already.
*
* @param \WP_REST_Request $request The request to maybe calculate the total amount for.
* @return number|null The calculated amount, or null if it can't be calculated.
*/
private static function calculate_refund_amount_from_line_items( $request ) {
$line_items = $request['line_items'];
if ( ! is_array( $line_items ) || empty( $line_items ) ) {
return null;
}
$amount = 0;
foreach ( $line_items as $item ) {
if ( ! isset( $item['refund_total'] ) || ! is_numeric( $item['refund_total'] ) ) {
return null;
}
$amount += $item['refund_total'];
if ( ! isset( $item['refund_tax'] ) ) {
continue;
}
foreach ( $item['refund_tax'] as $tax ) {
if ( ! is_numeric( $tax ) ) {
return null;
}
$amount += $tax;
}
}
return $amount;
}
/**
* Convert the line items of a refund request to internal format (see adjust_create_refund_request_parameters).
*
* @param array $line_items The line items to convert.
* @return array The converted line items.
*/
private static function adjust_line_items_for_create_refund_request( $line_items ) {
if ( ! is_array( $line_items ) || empty( $line_items ) || self::is_associative( $line_items ) ) {
return $line_items;
}
$new_array = array();
foreach ( $line_items as $item ) {
if ( ! isset( $item['id'] ) ) {
return $line_items;
}
if ( isset( $item['quantity'] ) && ! isset( $item['qty'] ) ) {
$item['qty'] = $item['quantity'];
}
unset( $item['quantity'] );
if ( isset( $item['refund_tax'] ) ) {
$item['refund_tax'] = self::adjust_taxes_for_create_refund_request_line_item( $item['refund_tax'] );
}
$id = $item['id'];
$new_array[ $id ] = $item;
unset( $new_array[ $id ]['id'] );
}
return $new_array;
}
/**
* Adjust the taxes array from a line item in a refund request, see adjust_create_refund_parameters.
*
* @param array $taxes_array The array to adjust.
* @return array The adjusted array.
*/
private static function adjust_taxes_for_create_refund_request_line_item( $taxes_array ) {
if ( ! is_array( $taxes_array ) || empty( $taxes_array ) || self::is_associative( $taxes_array ) ) {
return $taxes_array;
}
$new_array = array();
foreach ( $taxes_array as $item ) {
if ( ! isset( $item['id'] ) || ! isset( $item['refund_total'] ) ) {
return $taxes_array;
}
$id = $item['id'];
$refund_total = $item['refund_total'];
$new_array[ $id ] = $refund_total;
}
return $new_array;
}
/**
* Is an array sequential or associative?
*
* @param array $array The array to check.
* @return bool True if the array is associative, false if it's sequential.
*/
private static function is_associative( array $array ) {
return array_keys( $array ) !== range( 0, count( $array ) - 1 );
}
}

View File

@ -0,0 +1,77 @@
<?php
/**
* RestockRefundedItemsAdjuster class file.
*/
namespace Automattic\WooCommerce\Internal;
use Automattic\WooCommerce\Proxies\LegacyProxy;
defined( 'ABSPATH' ) || exit;
/**
* Class to adjust or initialize the restock refunded items.
*/
class RestockRefundedItemsAdjuster {
/**
* The order factory to use.
*
* @var WC_Order_Factory
*/
private $order_factory;
/**
* Class initialization, to be executed when the class is resolved by the container.
*
* @internal
*/
final public function init() {
$this->order_factory = wc_get_container()->get( LegacyProxy::class )->get_instance_of( \WC_Order_Factory::class );
add_action( 'woocommerce_before_save_order_items', array( $this, 'initialize_restock_refunded_items' ), 10, 2 );
}
/**
* Initializes the restock refunded items meta for order version less than 5.5.
*
* @see https://github.com/woocommerce/woocommerce/issues/29502
*
* @param int $order_id Order ID.
* @param array $items Order items to save.
*/
public function initialize_restock_refunded_items( $order_id, $items ) {
$order = wc_get_order( $order_id );
$order_version = $order->get_version();
if ( version_compare( $order_version, '5.5', '>=' ) ) {
return;
}
// If there are no refund lines, then this migration isn't necessary because restock related meta's wouldn't be set.
if ( 0 === count( $order->get_refunds() ) ) {
return;
}
if ( isset( $items['order_item_id'] ) ) {
foreach ( $items['order_item_id'] as $item_id ) {
$item = $this->order_factory::get_order_item( absint( $item_id ) );
if ( ! $item ) {
continue;
}
if ( 'line_item' !== $item->get_type() ) {
continue;
}
// There could be code paths in custom code which don't update version number but still update the items.
if ( '' !== $item->get_meta( '_restock_refunded_items', true ) ) {
continue;
}
$refunded_item_quantity = abs( $order->get_qty_refunded_for_item( $item->get_id() ) );
$item->add_meta_data( '_restock_refunded_items', $refunded_item_quantity, false );
$item->save();
}
}
}
}

View File

@ -0,0 +1,29 @@
<?php
/**
* Helpers for managing connection to WooCommerce.com.
*/
namespace Automattic\WooCommerce\Internal\WCCom;
defined( 'ABSPATH' ) || exit;
/**
* Class WCConnectionHelper.
*
* Helpers for managing connection to WooCommerce.com.
*/
final class ConnectionHelper {
/**
* Check if WooCommerce.com account is connected.
*
* @since 4.4.0
* @return bool Whether account is connected.
*/
public static function is_connected() {
$helper_options = get_option( 'woocommerce_helper_data', array() );
if ( array_key_exists( 'auth', $helper_options ) && ! empty( $helper_options['auth'] ) ) {
return true;
}
return false;
}
}

136
src/Packages.php Normal file
View File

@ -0,0 +1,136 @@
<?php
/**
* Loads WooCommece packages from the /packages directory. These are packages developed outside of core.
*/
namespace Automattic\WooCommerce;
defined( 'ABSPATH' ) || exit;
/**
* Packages class.
*
* @since 3.7.0
*/
class Packages {
/**
* Static-only class.
*/
private function __construct() {}
/**
* Array of package names and their main package classes.
*
* @var array Key is the package name/directory, value is the main package class which handles init.
*/
protected static $packages = array(
'woocommerce-blocks' => '\\Automattic\\WooCommerce\\Blocks\\Package',
'woocommerce-admin' => '\\Automattic\\WooCommerce\\Admin\\Composer\\Package',
);
/**
* Init the package loader.
*
* @since 3.7.0
*/
public static function init() {
add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ) );
}
/**
* Callback for WordPress init hook.
*/
public static function on_init() {
self::load_packages();
}
/**
* Checks a package exists by looking for it's directory.
*
* @param string $package Package name.
* @return boolean
*/
public static function package_exists( $package ) {
return file_exists( dirname( __DIR__ ) . '/packages/' . $package );
}
/**
* Loads packages after plugins_loaded hook.
*
* Each package should include an init file which loads the package so it can be used by core.
*/
protected static function load_packages() {
foreach ( self::$packages as $package_name => $package_class ) {
if ( ! self::package_exists( $package_name ) ) {
self::missing_package( $package_name );
continue;
}
call_user_func( array( $package_class, 'init' ) );
}
// Proxies "activated_plugin" hook for embedded packages listen on WC plugin activation
// https://github.com/woocommerce/woocommerce/issues/28697.
if ( is_admin() ) {
$activated_plugin = get_transient( 'woocommerce_activated_plugin' );
if ( $activated_plugin ) {
delete_transient( 'woocommerce_activated_plugin' );
/**
* WooCommerce is activated hook.
*
* @since 5.0.0
* @param bool $activated_plugin Activated plugin path,
* generally woocommerce/woocommerce.php.
*/
do_action( 'woocommerce_activated_plugin', $activated_plugin );
}
}
}
/**
* If a package is missing, add an admin notice.
*
* @param string $package Package name.
*/
protected static function missing_package( $package ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( // phpcs:ignore
sprintf(
/* Translators: %s package name. */
esc_html__( 'Missing the WooCommerce %s package', 'woocommerce' ),
'<code>' . esc_html( $package ) . '</code>'
) . ' - ' . esc_html__( 'Your installation of WooCommerce is incomplete. If you installed WooCommerce from GitHub, please refer to this document to set up your development environment: https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment', 'woocommerce' )
);
}
add_action(
'admin_notices',
function() use ( $package ) {
?>
<div class="notice notice-error">
<p>
<strong>
<?php
printf(
/* Translators: %s package name. */
esc_html__( 'Missing the WooCommerce %s package', 'woocommerce' ),
'<code>' . esc_html( $package ) . '</code>'
);
?>
</strong>
<br>
<?php
printf(
/* translators: 1: is a link to a support document. 2: closing link */
esc_html__( 'Your installation of WooCommerce is incomplete. If you installed WooCommerce from GitHub, %1$splease refer to this document%2$s to set up your development environment.', 'woocommerce' ),
'<a href="' . esc_url( 'https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment' ) . '" target="_blank" rel="noopener noreferrer">',
'</a>'
);
?>
</p>
</div>
<?php
}
);
}
}

View File

@ -0,0 +1,40 @@
<?php
/**
* ActionsProxy class file.
*/
namespace Automattic\WooCommerce\Proxies;
/**
* Proxy for interacting with WordPress actions and filters.
*
* This class should be used instead of directly accessing the WordPress functions, to ease unit testing.
*/
class ActionsProxy {
/**
* Retrieve the number of times an action is fired.
*
* @param string $tag The name of the action hook.
*
* @return int The number of times action hook $tag is fired.
*/
public function did_action( $tag ) {
return did_action( $tag );
}
/**
* Calls the callback functions that have been added to a filter hook.
*
* @param string $tag The name of the filter hook.
* @param mixed $value The value to filter.
* @param mixed ...$parameters Additional parameters to pass to the callback functions.
*
* @return mixed The filtered value after all hooked functions are applied to it.
*/
public function apply_filters( $tag, $value, ...$parameters ) {
return apply_filters( $tag, $value, ...$parameters );
}
// TODO: Add the rest of the actions and filters related methods.
}

View File

@ -0,0 +1,99 @@
<?php
/**
* LegacyProxy class file.
*/
namespace Automattic\WooCommerce\Proxies;
use Automattic\WooCommerce\Internal\DependencyManagement\Definition;
use \Psr\Container\ContainerInterface;
/**
* Proxy class to access legacy WooCommerce functionality.
*
* This class should be used to interact with code outside the `src` directory, especially functions and classes
* in the `includes` directory, unless a more specific proxy exists for the functionality at hand (e.g. `ActionsProxy`).
* Idempotent functions can be executed directly.
*/
class LegacyProxy {
/**
* Gets an instance of a given legacy class.
* This must not be used to get instances of classes in the `src` directory.
*
* If a given class needs a special procedure to get an instance of it,
* please add a private get_instance_of_(lowercased_class_name) and it will be
* automatically invoked. See also how objects of classes having a static `instance`
* method are retrieved, similar approaches can be used as needed to make use
* of existing factory methods such as e.g. 'load'.
*
* @param string $class_name The name of the class to get an instance for.
* @param mixed ...$args Parameters to be passed to the class constructor or to the appropriate internal 'get_instance_of_' method.
*
* @return object The instance of the class.
* @throws \Exception The requested class belongs to the `src` directory, or there was an error creating an instance of the class.
*/
public function get_instance_of( string $class_name, ...$args ) {
if ( false !== strpos( $class_name, '\\' ) ) {
throw new \Exception(
'The LegacyProxy class is not intended for getting instances of classes in the src directory, please use ' .
Definition::INJECTION_METHOD . ' method injection or the instance of ' . ContainerInterface::class . ' for that.'
);
}
// If a class has a dedicated method to obtain a instance, use it.
$method = 'get_instance_of_' . strtolower( $class_name );
if ( method_exists( __CLASS__, $method ) ) {
return $this->$method( ...$args );
}
// If the class is a singleton, use the "instance" method.
if ( method_exists( $class_name, 'instance' ) ) {
return $class_name::instance( ...$args );
}
// If the class has a "load" method, use it.
if ( method_exists( $class_name, 'load' ) ) {
return $class_name::load( ...$args );
}
// Fallback to simply creating a new instance of the class.
return new $class_name( ...$args );
}
/**
* Get an instance of a class implementing WC_Queue_Interface.
*
* @return \WC_Queue_Interface The instance.
*/
private function get_instance_of_wc_queue_interface() {
return \WC_Queue::instance();
}
/**
* Call a user function. This should be used to execute any non-idempotent function, especially
* those in the `includes` directory or provided by WordPress.
*
* @param string $function_name The function to execute.
* @param mixed ...$parameters The parameters to pass to the function.
*
* @return mixed The result from the function.
*/
public function call_function( $function_name, ...$parameters ) {
return call_user_func_array( $function_name, $parameters );
}
/**
* Call a static method in a class. This should be used to execute any non-idempotent method in classes
* from the `includes` directory.
*
* @param string $class_name The name of the class containing the method.
* @param string $method_name The name of the method.
* @param mixed ...$parameters The parameters to pass to the method.
*
* @return mixed The result from the method.
*/
public function call_static( $class_name, $method_name, ...$parameters ) {
return call_user_func_array( "$class_name::$method_name", $parameters );
}
}

View File

@ -0,0 +1,71 @@
<?php
/**
* A class of utilities for dealing with arrays.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with arrays.
*/
class ArrayUtil {
/**
* Get a value from an nested array by specifying the entire key hierarchy with '::' as separator.
*
* E.g. for [ 'foo' => [ 'bar' => [ 'fizz' => 'buzz' ] ] ] the value for key 'foo::bar::fizz' would be 'buzz'.
*
* @param array $array The array to get the value from.
* @param string $key The complete key hierarchy, using '::' as separator.
* @param mixed $default The value to return if the key doesn't exist in the array.
*
* @return mixed The retrieved value, or the supplied default value.
* @throws \Exception $array is not an array.
*/
public static function get_nested_value( array $array, string $key, $default = null ) {
$key_stack = explode( '::', $key );
$subkey = array_shift( $key_stack );
if ( isset( $array[ $subkey ] ) ) {
$value = $array[ $subkey ];
if ( count( $key_stack ) ) {
foreach ( $key_stack as $subkey ) {
if ( is_array( $value ) && isset( $value[ $subkey ] ) ) {
$value = $value[ $subkey ];
} else {
$value = $default;
break;
}
}
}
} else {
$value = $default;
}
return $value;
}
/**
* Checks if a given key exists in an array and its value can be evaluated as 'true'.
*
* @param array $array The array to check.
* @param string $key The key for the value to check.
* @return bool True if the key exists in the array and the value can be evaluated as 'true'.
*/
public static function is_truthy( array $array, string $key ) {
return isset( $array[ $key ] ) && $array[ $key ];
}
/**
* Gets the value for a given key from an array, or a default value if the key doesn't exist in the array.
*
* @param array $array The array to get the value from.
* @param string $key The key to use to retrieve the value.
* @param null $default The default value to return if the key doesn't exist in the array.
* @return mixed|null The value for the key, or the default value passed.
*/
public static function get_value_or_default( array $array, string $key, $default = null ) {
return isset( $array[ $key ] ) ? $array[ $key ] : $default;
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* A class of utilities for dealing with numbers.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with numbers.
*/
final class NumberUtil {
/**
* Round a number using the built-in `round` function, but unless the value to round is numeric
* (a number or a string that can be parsed as a number), apply 'floatval' first to it
* (so it will convert it to 0 in most cases).
*
* This is needed because in PHP 7 applying `round` to a non-numeric value returns 0,
* but in PHP 8 it throws an error. Specifically, in WooCommerce we have a few places where
* round('') is often executed.
*
* @param mixed $val The value to round.
* @param int $precision The optional number of decimal digits to round to.
* @param int $mode A constant to specify the mode in which rounding occurs.
*
* @return float The value rounded to the given precision as a float, or the supplied default value.
*/
public static function round( $val, int $precision = 0, int $mode = PHP_ROUND_HALF_UP ) : float {
if ( ! is_numeric( $val ) ) {
$val = floatval( $val );
}
return round( $val, $precision, $mode );
}
}

View File

@ -0,0 +1,60 @@
<?php
/**
* A class of utilities for dealing with strings.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with strings.
*/
final class StringUtil {
/**
* Checks to see whether or not a string starts with another.
*
* @param string $string The string we want to check.
* @param string $starts_with The string we're looking for at the start of $string.
* @param bool $case_sensitive Indicates whether the comparison should be case-sensitive.
*
* @return bool True if the $string starts with $starts_with, false otherwise.
*/
public static function starts_with( string $string, string $starts_with, bool $case_sensitive = true ): bool {
$len = strlen( $starts_with );
if ( $len > strlen( $string ) ) {
return false;
}
$string = substr( $string, 0, $len );
if ( $case_sensitive ) {
return strcmp( $string, $starts_with ) === 0;
}
return strcasecmp( $string, $starts_with ) === 0;
}
/**
* Checks to see whether or not a string ends with another.
*
* @param string $string The string we want to check.
* @param string $ends_with The string we're looking for at the end of $string.
* @param bool $case_sensitive Indicates whether the comparison should be case-sensitive.
*
* @return bool True if the $string ends with $ends_with, false otherwise.
*/
public static function ends_with( string $string, string $ends_with, bool $case_sensitive = true ): bool {
$len = strlen( $ends_with );
if ( $len > strlen( $string ) ) {
return false;
}
$string = substr( $string, -$len );
if ( $case_sensitive ) {
return strcmp( $string, $ends_with ) === 0;
}
return strcasecmp( $string, $ends_with ) === 0;
}
}