initial commit
This commit is contained in:
74
src/Autoloader.php
Normal file
74
src/Autoloader.php
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
229
src/Checkout/Helpers/ReserveStock.php
Normal file
229
src/Checkout/Helpers/ReserveStock.php
Normal 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 */
|
||||
__( '"%s" 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 );
|
||||
}
|
||||
}
|
60
src/Checkout/Helpers/ReserveStockException.php
Normal file
60
src/Checkout/Helpers/ReserveStockException.php
Normal 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
97
src/Container.php
Normal 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 );
|
||||
}
|
||||
}
|
73
src/Internal/AssignDefaultCategory.php
Normal file
73
src/Internal/AssignDefaultCategory.php
Normal 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' );
|
||||
}
|
||||
}
|
||||
}
|
172
src/Internal/DependencyManagement/AbstractServiceProvider.php
Normal file
172
src/Internal/DependencyManagement/AbstractServiceProvider.php
Normal 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 );
|
||||
}
|
||||
}
|
23
src/Internal/DependencyManagement/ContainerException.php
Normal file
23
src/Internal/DependencyManagement/ContainerException.php
Normal 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 );
|
||||
}
|
||||
}
|
39
src/Internal/DependencyManagement/Definition.php
Normal file
39
src/Internal/DependencyManagement/Definition.php
Normal 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;
|
||||
}
|
||||
}
|
162
src/Internal/DependencyManagement/ExtendedContainer.php
Normal file
162
src/Internal/DependencyManagement/ExtendedContainer.php
Normal 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' );
|
||||
}
|
||||
}
|
@ -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 );
|
||||
}
|
||||
}
|
@ -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 );
|
||||
}
|
||||
}
|
@ -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 );
|
||||
}
|
||||
}
|
@ -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 );
|
||||
}
|
||||
}
|
@ -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 );
|
||||
}
|
||||
}
|
163
src/Internal/DownloadPermissionsAdjuster.php
Normal file
163
src/Internal/DownloadPermissionsAdjuster.php
Normal 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;
|
||||
}
|
||||
}
|
391
src/Internal/ProductAttributesLookup/DataRegenerator.php
Normal file
391
src/Internal/ProductAttributesLookup/DataRegenerator.php
Normal 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…', '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" );
|
||||
}
|
||||
}
|
||||
}
|
329
src/Internal/ProductAttributesLookup/Filterer.php
Normal file
329
src/Internal/ProductAttributesLookup/Filterer.php
Normal 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 ) ) . ')';
|
||||
}
|
||||
}
|
682
src/Internal/ProductAttributesLookup/LookupDataStore.php
Normal file
682
src/Internal/ProductAttributesLookup/LookupDataStore.php
Normal 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' );
|
||||
}
|
||||
}
|
202
src/Internal/RestApiUtil.php
Normal file
202
src/Internal/RestApiUtil.php
Normal 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 );
|
||||
}
|
||||
}
|
77
src/Internal/RestockRefundedItemsAdjuster.php
Normal file
77
src/Internal/RestockRefundedItemsAdjuster.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
29
src/Internal/WCCom/ConnectionHelper.php
Normal file
29
src/Internal/WCCom/ConnectionHelper.php
Normal 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
136
src/Packages.php
Normal 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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
40
src/Proxies/ActionsProxy.php
Normal file
40
src/Proxies/ActionsProxy.php
Normal 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.
|
||||
}
|
99
src/Proxies/LegacyProxy.php
Normal file
99
src/Proxies/LegacyProxy.php
Normal 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 );
|
||||
}
|
||||
}
|
71
src/Utilities/ArrayUtil.php
Normal file
71
src/Utilities/ArrayUtil.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
34
src/Utilities/NumberUtil.php
Normal file
34
src/Utilities/NumberUtil.php
Normal 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 );
|
||||
}
|
||||
}
|
60
src/Utilities/StringUtil.php
Normal file
60
src/Utilities/StringUtil.php
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user