File: /var/www/vhosts/uyarreklam.com.tr/httpdocs/Utilities.tar
ArrayUtil.php 0000644 00000026424 15153675437 0007221 0 ustar 00 <?php
/**
* A class of utilities for dealing with arrays.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with arrays.
*/
class ArrayUtil {
/**
* Automatic selector type for the 'select' method.
*/
public const SELECT_BY_AUTO = 0;
/**
* Object method selector type for the 'select' method.
*/
public const SELECT_BY_OBJECT_METHOD = 1;
/**
* Object property selector type for the 'select' method.
*/
public const SELECT_BY_OBJECT_PROPERTY = 2;
/**
* Array key selector type for the 'select' method.
*/
public const SELECT_BY_ARRAY_KEY = 3;
/**
* 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.
*
* This is equivalent to "$array[$key] ?? $default" except in one case:
* when they key exists, has a null value, and a non-null default is supplied:
*
* $array = ['key' => null]
* $array['key'] ?? 'default' => 'default'
* ArrayUtil::get_value_or_default($array, 'key', 'default') => null
*
* @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 array_key_exists( $key, $array ) ? $array[ $key ] : $default;
}
/**
* Converts an array of numbers to a human-readable range, such as "1,2,3,5" to "1-3, 5". It also supports
* floating point numbers, however with some perhaps unexpected / undefined behaviour if used within a range.
* Source: https://stackoverflow.com/a/34254663/4574
*
* @param array $items An array (in any order, see $sort) of individual numbers.
* @param string $item_separator The string that separates sequential range groups. Defaults to ', '.
* @param string $range_separator The string that separates ranges. Defaults to '-'. A plausible example otherwise would be ' to '.
* @param bool|true $sort Sort the array prior to iterating? You'll likely always want to sort, but if not, you can set this to false.
*
* @return string
*/
public static function to_ranges_string( array $items, string $item_separator = ', ', string $range_separator = '-', bool $sort = true ): string {
if ( $sort ) {
sort( $items );
}
$point = null;
$range = false;
$str = '';
foreach ( $items as $i ) {
if ( null === $point ) {
$str .= $i;
} elseif ( ( $point + 1 ) === $i ) {
$range = true;
} else {
if ( $range ) {
$str .= $range_separator . $point;
$range = false;
}
$str .= $item_separator . $i;
}
$point = $i;
}
if ( $range ) {
$str .= $range_separator . $point;
}
return $str;
}
/**
* Helper function to generate a callback which can be executed on an array to select a value from each item.
*
* @param string $selector_name Field/property/method name to select.
* @param int $selector_type Selector type.
*
* @return \Closure Callback to select the value.
*/
private static function get_selector_callback( string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): \Closure {
if ( self::SELECT_BY_OBJECT_METHOD === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
return $item->$selector_name();
};
} elseif ( self::SELECT_BY_OBJECT_PROPERTY === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
return $item->$selector_name;
};
} elseif ( self::SELECT_BY_ARRAY_KEY === $selector_type ) {
$callback = function( $item ) use ( $selector_name ) {
return $item[ $selector_name ];
};
} else {
$callback = function( $item ) use ( $selector_name ) {
if ( is_array( $item ) ) {
return $item[ $selector_name ];
} elseif ( method_exists( $item, $selector_name ) ) {
return $item->$selector_name();
} else {
return $item->$selector_name;
}
};
}
return $callback;
}
/**
* Select one single value from all the items in an array of either arrays or objects based on a selector.
* For arrays, the selector is a key name; for objects, the selector can be either a method name or a property name.
*
* @param array $items Items to apply the selection to.
* @param string $selector_name Key, method or property name to use as a selector.
* @param int $selector_type Selector type, one of the SELECT_BY_* constants.
* @return array The selected values.
*/
public static function select( array $items, string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): array {
$callback = self::get_selector_callback( $selector_name, $selector_type );
return array_map( $callback, $items );
}
/**
* Returns a new assoc array with format [ $key1 => $item1, $key2 => $item2, ... ] where $key is the value of the selector and items are original items passed.
*
* @param array $items Items to use for conversion.
* @param string $selector_name Key, method or property name to use as a selector.
* @param int $selector_type Selector type, one of the SELECT_BY_* constants.
*
* @return array The converted assoc array.
*/
public static function select_as_assoc( array $items, string $selector_name, int $selector_type = self::SELECT_BY_AUTO ): array {
$selector_callback = self::get_selector_callback( $selector_name, $selector_type );
$result = array();
foreach ( $items as $item ) {
$key = $selector_callback( $item );
self::ensure_key_is_array( $result, $key );
$result[ $key ][] = $item;
}
return $result;
}
/**
* Returns whether two assoc array are same. The comparison is done recursively by keys, and the functions returns on first difference found.
*
* @param array $array1 First array to compare.
* @param array $array2 Second array to compare.
* @param bool $strict Whether to use strict comparison.
*
* @return bool Whether the arrays are different.
*/
public static function deep_compare_array_diff( array $array1, array $array2, bool $strict = true ) {
return self::deep_compute_or_compare_array_diff( $array1, $array2, true, $strict );
}
/**
* Computes difference between two assoc arrays recursively. Similar to PHP's native assoc_array_diff, but also supports nested arrays.
*
* @param array $array1 First array.
* @param array $array2 Second array.
* @param bool $strict Whether to also match type of values.
*
* @return array The difference between the two arrays.
*/
public static function deep_assoc_array_diff( array $array1, array $array2, bool $strict = true ): array {
return self::deep_compute_or_compare_array_diff( $array1, $array2, false, $strict );
}
/**
* Helper method to compare to compute difference between two arrays. Comparison is done recursively.
*
* @param array $array1 First array.
* @param array $array2 Second array.
* @param bool $compare Whether to compare the arrays. If true, then function will return false on first difference, in order to be slightly more efficient.
* @param bool $strict Whether to do string comparison.
*
* @return array|bool The difference between the two arrays, or if array are same, depending upon $compare param.
*/
private static function deep_compute_or_compare_array_diff( array $array1, array $array2, bool $compare, bool $strict = true ) {
$diff = array();
foreach ( $array1 as $key => $value ) {
if ( is_array( $value ) ) {
if ( ! array_key_exists( $key, $array2 ) || ! is_array( $array2[ $key ] ) ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
continue;
}
$new_diff = self::deep_assoc_array_diff( $value, $array2[ $key ], $strict );
if ( ! empty( $new_diff ) ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $new_diff;
}
} elseif ( $strict ) {
if ( ! array_key_exists( $key, $array2 ) || $value !== $array2[ $key ] ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
}
} else {
// phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- Intentional when $strict is false.
if ( ! array_key_exists( $key, $array2 ) || $value != $array2[ $key ] ) {
if ( $compare ) {
return true;
}
$diff[ $key ] = $value;
}
}
}
return $compare ? false : $diff;
}
/**
* Push a value to an array, but only if the value isn't in the array already.
*
* @param array $array The array.
* @param mixed $value The value to maybe push.
* @return bool True if the value has been added to the array, false if the value was already in the array.
*/
public static function push_once( array &$array, $value ) : bool {
if ( in_array( $value, $array, true ) ) {
return false;
}
$array[] = $value;
return true;
}
/**
* Ensure that an associative array has a given key, and if not, set the key to an empty array.
*
* @param array $array The array to check.
* @param string $key The key to check.
* @param bool $throw_if_existing_is_not_array If true, an exception will be thrown if the key already exists in the array but the value is not an array.
* @return bool True if the key has been added to the array, false if not (the key already existed).
* @throws \Exception The key already exists in the array but the value is not an array.
*/
public static function ensure_key_is_array( array &$array, string $key, bool $throw_if_existing_is_not_array = false ): bool {
if ( ! isset( $array[ $key ] ) ) {
$array[ $key ] = array();
return true;
}
if ( $throw_if_existing_is_not_array && ! is_array( $array[ $key ] ) ) {
$type = is_object( $array[ $key ] ) ? get_class( $array[ $key ] ) : gettype( $array[ $key ] );
throw new \Exception( "Array key exists but it's not an array, it's a {$type}" );
}
return false;
}
}
FeaturesUtil.php 0000644 00000010766 15153675437 0007723 0 ustar 00 <?php
/**
* FeaturesUtil class file.
*/
namespace Automattic\WooCommerce\Utilities;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
/**
* Class with methods that allow to retrieve information about the existing WooCommerce features,
* also has methods for WooCommerce plugins to declare (in)compatibility with the features.
*/
class FeaturesUtil {
/**
* Get all the existing WooCommerce features.
*
* Returns an associative array where keys are unique feature ids
* and values are arrays with these keys:
*
* - name
* - description
* - is_experimental
* - is_enabled (if $include_enabled_info is passed as true)
*
* @param bool $include_experimental Include also experimental/work in progress features in the list.
* @param bool $include_enabled_info True to include the 'is_enabled' field in the returned features info.
* @returns array An array of information about existing features.
*/
public static function get_features( bool $include_experimental = false, bool $include_enabled_info = false ): array {
return wc_get_container()->get( FeaturesController::class )->get_features( $include_experimental, $include_enabled_info );
}
/**
* Check if a given feature is currently enabled.
*
* @param string $feature_id Unique feature id.
* @return bool True if the feature is enabled, false if not or if the feature doesn't exist.
*/
public static function feature_is_enabled( string $feature_id ): bool {
return wc_get_container()->get( FeaturesController::class )->feature_is_enabled( $feature_id );
}
/**
* Declare (in)compatibility with a given feature for a given plugin.
*
* This method MUST be executed from inside a handler for the 'before_woocommerce_init' hook and
* SHOULD be executed from the main plugin file passing __FILE__ or 'my-plugin/my-plugin.php' for the
* $plugin_file argument.
*
* @param string $feature_id Unique feature id.
* @param string $plugin_file The full plugin file path.
* @param bool $positive_compatibility True if the plugin declares being compatible with the feature, false if it declares being incompatible.
* @return bool True on success, false on error (feature doesn't exist or not inside the required hook).
*/
public static function declare_compatibility( string $feature_id, string $plugin_file, bool $positive_compatibility = true ): bool {
$plugin_id = wc_get_container()->get( PluginUtil::class )->get_wp_plugin_id( $plugin_file );
if ( ! $plugin_id ) {
$logger = wc_get_logger();
$logger->error( "FeaturesUtil::declare_compatibility: {$plugin_file} is not a known WordPress plugin." );
return false;
}
return wc_get_container()->get( FeaturesController::class )->declare_compatibility( $feature_id, $plugin_id, $positive_compatibility );
}
/**
* Get the ids of the features that a certain plugin has declared compatibility for.
*
* This method can't be called before the 'woocommerce_init' hook is fired.
*
* @param string $plugin_name Plugin name, in the form 'directory/file.php'.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin ids.
*/
public static function get_compatible_features_for_plugin( string $plugin_name ): array {
return wc_get_container()->get( FeaturesController::class )->get_compatible_features_for_plugin( $plugin_name );
}
/**
* Get the names of the plugins that have been declared compatible or incompatible with a given feature.
*
* @param string $feature_id Feature id.
* @return array An array having a 'compatible' and an 'incompatible' key, each holding an array of plugin names.
*/
public static function get_compatible_plugins_for_feature( string $feature_id ): array {
return wc_get_container()->get( FeaturesController::class )->get_compatible_plugins_for_feature( $feature_id );
}
/**
* Sets a flag indicating that it's allowed to enable features for which incompatible plugins are active
* from the WooCommerce feature settings page.
*/
public static function allow_enabling_features_with_incompatible_plugins(): void {
wc_get_container()->get( FeaturesController::class )->allow_enabling_features_with_incompatible_plugins();
}
/**
* Sets a flag indicating that it's allowed to activate plugins for which incompatible features are enabled
* from the WordPress plugins page.
*/
public static function allow_activating_plugins_with_incompatible_features(): void {
wc_get_container()->get( FeaturesController::class )->allow_activating_plugins_with_incompatible_features();
}
}
I18nUtil.php 0000644 00000003277 15153675437 0006663 0 ustar 00 <?php
/**
* A class of utilities for dealing with internationalization.
*/
namespace Automattic\WooCommerce\Utilities;
/**
* A class of utilities for dealing with internationalization.
*/
final class I18nUtil {
/**
* A cache for the i18n units data.
*
* @var array $units
*/
private static $units;
/**
* Get the translated label for a weight unit of measure.
*
* This will return the original input string if it isn't found in the units array. This way a custom unit of
* measure can be used even if it's not getting translated.
*
* @param string $weight_unit The abbreviated weight unit in English, e.g. kg.
*
* @return string
*/
public static function get_weight_unit_label( $weight_unit ) {
if ( empty( self::$units ) ) {
self::$units = include WC()->plugin_path() . '/i18n/units.php';
}
$label = $weight_unit;
if ( ! empty( self::$units['weight'][ $weight_unit ] ) ) {
$label = self::$units['weight'][ $weight_unit ];
}
return $label;
}
/**
* Get the translated label for a dimensions unit of measure.
*
* This will return the original input string if it isn't found in the units array. This way a custom unit of
* measure can be used even if it's not getting translated.
*
* @param string $dimensions_unit The abbreviated dimension unit in English, e.g. cm.
*
* @return string
*/
public static function get_dimensions_unit_label( $dimensions_unit ) {
if ( empty( self::$units ) ) {
self::$units = include WC()->plugin_path() . '/i18n/units.php';
}
$label = $dimensions_unit;
if ( ! empty( self::$units['dimensions'][ $dimensions_unit ] ) ) {
$label = self::$units['dimensions'][ $dimensions_unit ];
}
return $label;
}
}
NumberUtil.php 0000644 00000002230 15153675437 0007360 0 ustar 00 <?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 );
}
}
OrderUtil.php 0000644 00000014376 15153675437 0007221 0 ustar 00 <?php
/**
* A class of utilities for dealing with orders.
*/
namespace Automattic\WooCommerce\Utilities;
use Automattic\WooCommerce\Caches\OrderCacheController;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Utilities\COTMigrationUtil;
use WC_Order;
use WP_Post;
/**
* A class of utilities for dealing with orders.
*/
final class OrderUtil {
/**
* Helper function to get screen name of orders page in wp-admin.
*
* @return string
*/
public static function get_order_admin_screen() : string {
return wc_get_container()->get( COTMigrationUtil::class )->get_order_admin_screen();
}
/**
* Helper function to get whether custom order tables are enabled or not.
*
* @return bool
*/
public static function custom_orders_table_usage_is_enabled() : bool {
return wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled();
}
/**
* Helper function to get whether the orders cache should be used or not.
*
* @return bool True if the orders cache should be used, false otherwise.
*/
public static function orders_cache_usage_is_enabled() : bool {
return wc_get_container()->get( OrderCacheController::class )->orders_cache_usage_is_enabled();
}
/**
* Checks if posts and order custom table sync is enabled and there are no pending orders.
*
* @return bool
*/
public static function is_custom_order_tables_in_sync() : bool {
return wc_get_container()->get( COTMigrationUtil::class )->is_custom_order_tables_in_sync();
}
/**
* Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
* This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
*
* @param WP_Post|null $post Post object, meta will be fetched from this only when `$data` is not passed.
* @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
* @param string $key Key to fetch metadata for.
* @param bool $single Whether metadata is single.
*
* @return array|mixed|string Value of the meta key.
*/
public static function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
return wc_get_container()->get( COTMigrationUtil::class )->get_post_or_object_meta( $post, $data, $key, $single );
}
/**
* Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
*
* @param WC_Order|WP_Post $post_or_order_object Post or order object.
*
* @return bool|WC_Order|WC_Order_Refund WC_Order object.
*/
public static function init_theorder_object( $post_or_order_object ) {
return wc_get_container()->get( COTMigrationUtil::class )->init_theorder_object( $post_or_order_object );
}
/**
* Helper function to id from an post or order object.
*
* @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
*
* @return int Order or post ID.
*/
public static function get_post_or_order_id( $post_or_order_object ) : int {
return wc_get_container()->get( COTMigrationUtil::class )->get_post_or_order_id( $post_or_order_object );
}
/**
* Checks if passed id, post or order object is a WC_Order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
* @param string[] $types Types to match against.
*
* @return bool Whether the passed param is an order.
*/
public static function is_order( $order_id, $types = array( 'shop_order' ) ) {
return wc_get_container()->get( COTMigrationUtil::class )->is_order( $order_id, $types );
}
/**
* Returns type pf passed id, post or order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
*
* @return string|null Type of the order.
*/
public static function get_order_type( $order_id ) {
return wc_get_container()->get( COTMigrationUtil::class )->get_order_type( $order_id );
}
/**
* Helper method to generate admin url for an order.
*
* @param int $order_id Order ID.
*
* @return string Admin url for an order.
*/
public static function get_order_admin_edit_url( int $order_id ) : string {
return wc_get_container()->get( PageController::class )->get_edit_url( $order_id );
}
/**
* Helper method to generate admin URL for new order.
*
* @return string Link for new order.
*/
public static function get_order_admin_new_url() : string {
return wc_get_container()->get( PageController::class )->get_new_page_url();
}
/**
* Check if the current admin screen is an order list table.
*
* @param string $order_type Optional. The order type to check for. Default shop_order.
*
* @return bool
*/
public static function is_order_list_table_screen( $order_type = 'shop_order' ) : bool {
return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'list' );
}
/**
* Check if the current admin screen is for editing an order.
*
* @param string $order_type Optional. The order type to check for. Default shop_order.
*
* @return bool
*/
public static function is_order_edit_screen( $order_type = 'shop_order' ) : bool {
return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'edit' );
}
/**
* Check if the current admin screen is adding a new order.
*
* @param string $order_type Optional. The order type to check for. Default shop_order.
*
* @return bool
*/
public static function is_new_order_screen( $order_type = 'shop_order' ) : bool {
return wc_get_container()->get( PageController::class )->is_order_screen( $order_type, 'new' );
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public static function get_table_for_orders() {
return wc_get_container()->get( COTMigrationUtil::class )->get_table_for_orders();
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public static function get_table_for_order_meta() {
return wc_get_container()->get( COTMigrationUtil::class )->get_table_for_order_meta();
}
}
PluginUtil.php 0000644 00000017227 15153675437 0007402 0 ustar 00 <?php
/**
* A class of utilities for dealing with plugins.
*/
namespace Automattic\WooCommerce\Utilities;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
* A class of utilities for dealing with plugins.
*/
class PluginUtil {
use AccessiblePrivateMethods;
/**
* The LegacyProxy instance to use.
*
* @var LegacyProxy
*/
private $proxy;
/**
* The cached list of WooCommerce aware plugin ids.
*
* @var null|array
*/
private $woocommerce_aware_plugins = null;
/**
* The cached list of enabled WooCommerce aware plugin ids.
*
* @var null|array
*/
private $woocommerce_aware_active_plugins = null;
/**
* Creates a new instance of the class.
*/
public function __construct() {
self::add_action( 'activated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 );
self::add_action( 'deactivated_plugin', array( $this, 'handle_plugin_de_activation' ), 10, 0 );
}
/**
* Initialize the class instance.
*
* @internal
*
* @param LegacyProxy $proxy The instance of LegacyProxy to use.
*/
final public function init( LegacyProxy $proxy ) {
$this->proxy = $proxy;
require_once ABSPATH . WPINC . '/plugin.php';
}
/**
* Get a list with the names of the WordPress plugins that are WooCommerce aware
* (they have a "WC tested up to" header).
*
* @param bool $active_only True to return only active plugins, false to return all the active plugins.
* @return string[] A list of plugin ids (path/file.php).
*/
public function get_woocommerce_aware_plugins( bool $active_only = false ): array {
if ( is_null( $this->woocommerce_aware_plugins ) ) {
// In case `get_plugins` was called much earlier in the request (before our headers could be injected), we
// invalidate the plugin cache list.
wp_cache_delete( 'plugins', 'plugins' );
$all_plugins = $this->proxy->call_function( 'get_plugins' );
$this->woocommerce_aware_plugins =
array_keys(
array_filter(
$all_plugins,
array( $this, 'is_woocommerce_aware_plugin' )
)
);
$this->woocommerce_aware_active_plugins =
array_values(
array_filter(
$this->woocommerce_aware_plugins,
function ( $plugin_name ) {
return $this->proxy->call_function( 'is_plugin_active', $plugin_name );
}
)
);
}
return $active_only ? $this->woocommerce_aware_active_plugins : $this->woocommerce_aware_plugins;
}
/**
* Get the printable name of a plugin.
*
* @param string $plugin_id Plugin id (path/file.php).
* @return string Printable plugin name, or the plugin id itself if printable name is not available.
*/
public function get_plugin_name( string $plugin_id ): string {
$plugin_data = $this->proxy->call_function( 'get_plugin_data', WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $plugin_id );
return $plugin_data['Name'] ?? $plugin_id;
}
/**
* Check if a plugin is WooCommerce aware.
*
* @param string|array $plugin_file_or_data Plugin id (path/file.php) or plugin data (as returned by get_plugins).
* @return bool True if the plugin exists and is WooCommerce aware.
* @throws \Exception The input is neither a string nor an array.
*/
public function is_woocommerce_aware_plugin( $plugin_file_or_data ): bool {
if ( is_string( $plugin_file_or_data ) ) {
return in_array( $plugin_file_or_data, $this->get_woocommerce_aware_plugins(), true );
} elseif ( is_array( $plugin_file_or_data ) ) {
return '' !== ( $plugin_file_or_data['WC tested up to'] ?? '' );
} else {
throw new \Exception( 'is_woocommerce_aware_plugin requires a plugin name or an array of plugin data as input' );
}
}
/**
* Match plugin identifier passed as a parameter with the output from `get_plugins()`.
*
* @param string $plugin_file Plugin identifier, either 'my-plugin/my-plugin.php', or output from __FILE__.
*
* @return string|false Key from the array returned by `get_plugins` if matched. False if no match.
*/
public function get_wp_plugin_id( $plugin_file ) {
$wp_plugins = array_keys( $this->proxy->call_function( 'get_plugins' ) );
// Try to match plugin_basename().
$plugin_basename = $this->proxy->call_function( 'plugin_basename', $plugin_file );
if ( in_array( $plugin_basename, $wp_plugins, true ) ) {
return $plugin_basename;
}
// Try to match by the my-file/my-file.php (dir + file name), then by my-file.php (file name only).
$plugin_file = str_replace( array( '\\', '/' ), DIRECTORY_SEPARATOR, $plugin_file );
$file_name_parts = explode( DIRECTORY_SEPARATOR, $plugin_file );
$file_name = array_pop( $file_name_parts );
$directory_name = array_pop( $file_name_parts );
$full_matches = array();
$partial_matches = array();
foreach ( $wp_plugins as $wp_plugin ) {
if ( false !== strpos( $wp_plugin, $directory_name . DIRECTORY_SEPARATOR . $file_name ) ) {
$full_matches[] = $wp_plugin;
}
if ( false !== strpos( $wp_plugin, $file_name ) ) {
$partial_matches[] = $wp_plugin;
}
}
if ( 1 === count( $full_matches ) ) {
return $full_matches[0];
}
if ( 1 === count( $partial_matches ) ) {
return $partial_matches[0];
}
return false;
}
/**
* Handle plugin activation and deactivation by clearing the WooCommerce aware plugin ids cache.
*/
private function handle_plugin_de_activation(): void {
$this->woocommerce_aware_plugins = null;
$this->woocommerce_aware_active_plugins = null;
}
/**
* Util function to generate warning string for incompatible features based on active plugins.
*
* @param string $feature_id Feature id.
* @param array $plugin_feature_info Array of plugin feature info. See FeaturesControllers->get_compatible_plugins_for_feature() for details.
*
* @return string Warning string.
*/
public function generate_incompatible_plugin_feature_warning( string $feature_id, array $plugin_feature_info ) : string {
$feature_warning = '';
$incompatibles = array_merge( $plugin_feature_info['incompatible'], $plugin_feature_info['uncertain'] );
$incompatibles = array_filter( $incompatibles, 'is_plugin_active' );
$incompatible_count = count( $incompatibles );
if ( $incompatible_count > 0 ) {
if ( 1 === $incompatible_count ) {
/* translators: %s = printable plugin name */
$feature_warning = sprintf( __( '⚠ 1 Incompatible plugin detected (%s).', 'woocommerce' ), $this->get_plugin_name( $incompatibles[0] ) );
} elseif ( 2 === $incompatible_count ) {
$feature_warning = sprintf(
/* translators: %1\$s, %2\$s = printable plugin names */
__( '⚠ 2 Incompatible plugins detected (%1$s and %2$s).', 'woocommerce' ),
$this->get_plugin_name( $incompatibles[0] ),
$this->get_plugin_name( $incompatibles[1] )
);
} else {
$feature_warning = sprintf(
/* translators: %1\$s, %2\$s = printable plugin names, %3\$d = plugins count */
_n(
'⚠ Incompatible plugins detected (%1$s, %2$s and %3$d other).',
'⚠ Incompatible plugins detected (%1$s and %2$s plugins and %3$d others).',
$incompatible_count - 2,
'woocommerce'
),
$this->get_plugin_name( $incompatibles[0] ),
$this->get_plugin_name( $incompatibles[1] ),
$incompatible_count - 2
);
}
$incompatible_plugins_url = add_query_arg(
array(
'plugin_status' => 'incompatible_with_feature',
'feature_id' => $feature_id,
),
admin_url( 'plugins.php' )
);
$extra_desc_tip = '<br>' . sprintf(
/* translators: %1$s opening link tag %2$s closing link tag. */
__( '%1$sView and manage%2$s', 'woocommerce' ),
'<a href="' . esc_url( $incompatible_plugins_url ) . '">',
'</a>'
);
$feature_warning .= $extra_desc_tip;
}
return $feature_warning;
}
}
StringUtil.php 0000644 00000011156 15153675437 0007405 0 ustar 00 <?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;
}
/**
* Checks if one string is contained into another at any position.
*
* @param string $string The string we want to check.
* @param string $contained The string we're looking for inside $string.
* @param bool $case_sensitive Indicates whether the comparison should be case-sensitive.
* @return bool True if $contained is contained inside $string, false otherwise.
*/
public static function contains( string $string, string $contained, bool $case_sensitive = true ): bool {
if ( $case_sensitive ) {
return false !== strpos( $string, $contained );
} else {
return false !== stripos( $string, $contained );
}
}
/**
* Get the name of a plugin in the form 'directory/file.php', as in the keys of the array returned by 'get_plugins'.
*
* @param string $plugin_file_path The path of the main plugin file (can be passed as __FILE__ from the plugin itself).
* @return string The name of the plugin in the form 'directory/file.php'.
*/
public static function plugin_name_from_plugin_file( string $plugin_file_path ): string {
return basename( dirname( $plugin_file_path ) ) . DIRECTORY_SEPARATOR . basename( $plugin_file_path );
}
/**
* Check if a string is null or is empty.
*
* @param string|null $value The string to check.
* @return bool True if the string is null or is empty.
*/
public static function is_null_or_empty( ?string $value ) {
return is_null( $value ) || '' === $value;
}
/**
* Check if a string is null, is empty, or has only whitespace characters
* (space, tab, vertical tab, form feed, carriage return, new line)
*
* @param string|null $value The string to check.
* @return bool True if the string is null, is empty, or contains only whitespace characters.
*/
public static function is_null_or_whitespace( ?string $value ) {
return is_null( $value ) || '' === $value || ctype_space( $value );
}
/**
* Convert an array of values to a list suitable for a SQL "IN" statement
* (so comma separated and delimited by parenthesis).
* e.g.: [1,2,3] --> (1,2,3)
*
* @param array $values The values to convert.
* @return string A parenthesized and comma-separated string generated from the values.
* @throws \InvalidArgumentException Empty values array passed.
*/
public static function to_sql_list( array $values ) {
if ( empty( $values ) ) {
throw new \InvalidArgumentException( self::class_name_without_namespace( __CLASS__ ) . '::' . __FUNCTION__ . ': the values array is empty' );
}
return '(' . implode( ',', $values ) . ')';
}
/**
* Get the name of a class without the namespace.
*
* @param string $class_name The full class name.
* @return string The class name without the namespace.
*/
public static function class_name_without_namespace( string $class_name ) {
// A '?:' would convert this to a one-liner, but WP coding standards disallow these :shrug:.
$result = substr( strrchr( $class_name, '\\' ), 1 );
return $result ? $result : $class_name;
}
}
BlocksUtil.php 0000644 00000004275 15154263032 0007341 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with blocks.
*/
class BlocksUtil {
/**
* Return blocks with their inner blocks flattened.
*
* @param array $blocks Array of blocks as returned by parse_blocks().
* @return array All blocks.
*/
public static function flatten_blocks( $blocks ) {
return array_reduce(
$blocks,
function( $carry, $block ) {
array_push( $carry, array_diff_key( $block, array_flip( array( 'innerBlocks' ) ) ) );
if ( isset( $block['innerBlocks'] ) ) {
$inner_blocks = self::flatten_blocks( $block['innerBlocks'] );
return array_merge( $carry, $inner_blocks );
}
return $carry;
},
array()
);
}
/**
* Get all instances of the specified block from the widget area.
*
* @param array $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_blocks_from_widget_area( $block_name ) {
return array_reduce(
get_option( 'widget_block' ),
function ( $acc, $block ) use ( $block_name ) {
$parsed_blocks = ! empty( $block ) && is_array( $block ) ? parse_blocks( $block['content'] ) : array();
if ( ! empty( $parsed_blocks ) && $block_name === $parsed_blocks[0]['blockName'] ) {
array_push( $acc, $parsed_blocks[0] );
return $acc;
}
return $acc;
},
array()
);
}
/**
* Get all instances of the specified block on a specific template part.
*
* @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
* @param string $template_part_slug The woo page to search, e.g. `header`.
* @return array Array of blocks as returned by parse_blocks().
*/
public static function get_block_from_template_part( $block_name, $template_part_slug ) {
$template = get_block_template( get_stylesheet() . '//' . $template_part_slug, 'wp_template_part' );
$blocks = parse_blocks( $template->content );
$flatten_blocks = self::flatten_blocks( $blocks );
return array_values(
array_filter(
$flatten_blocks,
function ( $block ) use ( $block_name ) {
return ( $block_name === $block['blockName'] );
}
)
);
}
}
COTMigrationUtil.php 0000644 00000014072 15154263032 0010417 0 ustar 00 <?php
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\DataStores\Orders\{ DataSynchronizer, OrdersTableDataStore };
use WC_Order;
use WP_Post;
/**
* Utility functions meant for helping in migration from posts tables to custom order tables.
*/
class COTMigrationUtil {
/**
* Custom order table controller.
*
* @var CustomOrdersTableController
*/
private $table_controller;
/**
* Data synchronizer.
*
* @var DataSynchronizer
*/
private $data_synchronizer;
/**
* Initialize method, invoked by the DI container.
*
* @internal Automatically called by the container.
* @param CustomOrdersTableController $table_controller Custom order table controller.
* @param DataSynchronizer $data_synchronizer Data synchronizer.
*
* @return void
*/
final public function init( CustomOrdersTableController $table_controller, DataSynchronizer $data_synchronizer ) {
$this->table_controller = $table_controller;
$this->data_synchronizer = $data_synchronizer;
}
/**
* Helper function to get screen name of orders page in wp-admin.
*
* @throws \Exception If called from outside of wp-admin.
*
* @return string
*/
public function get_order_admin_screen() : string {
if ( ! is_admin() ) {
throw new \Exception( 'This function should only be called in admin.' );
}
return $this->custom_orders_table_usage_is_enabled() && function_exists( 'wc_get_page_screen_id' )
? wc_get_page_screen_id( 'shop-order' )
: 'shop_order';
}
/**
* Helper function to get whether custom order tables are enabled or not.
*
* @return bool
*/
private function custom_orders_table_usage_is_enabled() : bool {
return $this->table_controller->custom_orders_table_usage_is_enabled();
}
/**
* Checks if posts and order custom table sync is enabled and there are no pending orders.
*
* @return bool
*/
public function is_custom_order_tables_in_sync() : bool {
$sync_status = $this->data_synchronizer->get_sync_status();
return 0 === $sync_status['current_pending_count'] && $this->data_synchronizer->data_sync_is_enabled();
}
/**
* Gets value of a meta key from WC_Data object if passed, otherwise from the post object.
* This helper function support backward compatibility for meta box functions, when moving from posts based store to custom tables.
*
* @param WP_Post|null $post Post object, meta will be fetched from this only when `$data` is not passed.
* @param \WC_Data|null $data WC_Data object, will be preferred over post object when passed.
* @param string $key Key to fetch metadata for.
* @param bool $single Whether metadata is single.
*
* @return array|mixed|string Value of the meta key.
*/
public function get_post_or_object_meta( ?WP_Post $post, ?\WC_Data $data, string $key, bool $single ) {
if ( isset( $data ) ) {
if ( method_exists( $data, "get$key" ) ) {
return $data->{"get$key"}();
}
return $data->get_meta( $key, $single );
} else {
return isset( $post->ID ) ? get_post_meta( $post->ID, $key, $single ) : false;
}
}
/**
* Helper function to initialize the global $theorder object, mostly used during order meta boxes rendering.
*
* @param WC_Order|WP_Post $post_or_order_object Post or order object.
*
* @return bool|WC_Order|WC_Order_Refund WC_Order object.
*/
public function init_theorder_object( $post_or_order_object ) {
global $theorder;
if ( $theorder instanceof WC_Order ) {
return $theorder;
}
if ( $post_or_order_object instanceof WC_Order ) {
$theorder = $post_or_order_object;
} else {
$theorder = wc_get_order( $post_or_order_object->ID );
}
return $theorder;
}
/**
* Helper function to get ID from a post or order object.
*
* @param WP_Post/WC_Order $post_or_order_object WP_Post/WC_Order object to get ID for.
*
* @return int Order or post ID.
*/
public function get_post_or_order_id( $post_or_order_object ) : int {
if ( is_numeric( $post_or_order_object ) ) {
return (int) $post_or_order_object;
} elseif ( $post_or_order_object instanceof WC_Order ) {
return $post_or_order_object->get_id();
} elseif ( $post_or_order_object instanceof WP_Post ) {
return $post_or_order_object->ID;
}
return 0;
}
/**
* Checks if passed id, post or order object is a WC_Order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
* @param string[] $types Types to match against.
*
* @return bool Whether the passed param is an order.
*/
public function is_order( $order_id, array $types = array( 'shop_order' ) ) : bool {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return in_array( $order_data_store->get_order_type( $order_id ), $types, true );
}
/**
* Returns type pf passed id, post or order object.
*
* @param int|WP_Post|WC_Order $order_id Order ID, post object or order object.
*
* @return string|null Type of the order.
*/
public function get_order_type( $order_id ) {
$order_id = $this->get_post_or_order_id( $order_id );
$order_data_store = \WC_Data_Store::load( 'order' );
return $order_data_store->get_order_type( $order_id );
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public function get_table_for_orders() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
$table_name = OrdersTableDataStore::get_orders_table_name();
} else {
global $wpdb;
$table_name = $wpdb->posts;
}
return $table_name;
}
/**
* Get the name of the database table that's currently in use for orders.
*
* @return string
*/
public function get_table_for_order_meta() {
if ( $this->custom_orders_table_usage_is_enabled() ) {
$table_name = OrdersTableDataStore::get_meta_table_name();
} else {
global $wpdb;
$table_name = $wpdb->postmeta;
}
return $table_name;
}
}
DatabaseUtil.php 0000644 00000023674 15154263032 0007634 0 ustar 00 <?php
/**
* DatabaseUtil class file.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use DateTime;
use DateTimeZone;
/**
* A class of utilities for dealing with the database.
*/
class DatabaseUtil {
/**
* Wrapper for the WordPress dbDelta function, allows to execute a series of SQL queries.
*
* @param string $queries The SQL queries to execute.
* @param bool $execute Ture to actually execute the queries, false to only simulate the execution.
* @return array The result of the execution (or simulation) from dbDelta.
*/
public function dbdelta( string $queries = '', bool $execute = true ): array {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
return dbDelta( $queries, $execute );
}
/**
* Given a set of table creation SQL statements, check which of the tables are currently missing in the database.
*
* @param string $creation_queries The SQL queries to execute ("CREATE TABLE" statements, same format as for dbDelta).
* @return array An array containing the names of the tables that currently don't exist in the database.
*/
public function get_missing_tables( string $creation_queries ): array {
global $wpdb;
$suppress_errors = $wpdb->suppress_errors( true );
$dbdelta_output = $this->dbdelta( $creation_queries, false );
$wpdb->suppress_errors( $suppress_errors );
$parsed_output = $this->parse_dbdelta_output( $dbdelta_output );
return $parsed_output['created_tables'];
}
/**
* Parses the output given by dbdelta and returns information about it.
*
* @param array $dbdelta_output The output from the execution of dbdelta.
* @return array[] An array containing a 'created_tables' key whose value is an array with the names of the tables that have been (or would have been) created.
*/
public function parse_dbdelta_output( array $dbdelta_output ): array {
$created_tables = array();
foreach ( $dbdelta_output as $table_name => $result ) {
if ( "Created table $table_name" === $result ) {
$created_tables[] = str_replace( '(', '', $table_name );
}
}
return array( 'created_tables' => $created_tables );
}
/**
* Drops a database table.
*
* @param string $table_name The name of the table to drop.
* @param bool $add_prefix True if the table name passed needs to be prefixed with $wpdb->prefix before processing.
* @return bool True on success, false on error.
*/
public function drop_database_table( string $table_name, bool $add_prefix = false ) {
global $wpdb;
if ( $add_prefix ) {
$table_name = $wpdb->prefix . $table_name;
}
//phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return $wpdb->query( "DROP TABLE IF EXISTS `{$table_name}`" );
}
/**
* Drops a table index, if both the table and the index exist.
*
* @param string $table_name The name of the table that contains the index.
* @param string $index_name The name of the index to be dropped.
* @return bool True if the index has been dropped, false if either the table or the index don't exist.
*/
public function drop_table_index( string $table_name, string $index_name ): bool {
global $wpdb;
if ( empty( $this->get_index_columns( $table_name, $index_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name DROP INDEX $index_name" );
return true;
}
/**
* Create a primary key for a table, only if the table doesn't have a primary key already.
*
* @param string $table_name Table name.
* @param array $columns An array with the index column names.
* @return bool True if the key has been created, false if the table already had a primary key.
*/
public function create_primary_key( string $table_name, array $columns ) {
global $wpdb;
if ( ! empty( $this->get_index_columns( $table_name ) ) ) {
return false;
}
// phpcs:ignore WordPress.DB.PreparedSQL
$wpdb->query( "ALTER TABLE $table_name ADD PRIMARY KEY(`" . join( '`,`', $columns ) . '`)' );
return true;
}
/**
* Get the columns of a given table index, or of the primary key.
*
* @param string $table_name Table name.
* @param string $index_name Index name, empty string for the primary key.
* @return array The index columns. Empty array if the table or the index don't exist.
*/
public function get_index_columns( string $table_name, string $index_name = '' ): array {
global $wpdb;
if ( empty( $index_name ) ) {
$index_name = 'PRIMARY';
}
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM $table_name WHERE Key_name = %s", $index_name ) );
if ( empty( $results ) ) {
return array();
}
return array_column( $results, 'Column_name' );
}
/**
* Formats an object value of type `$type` for inclusion in the database.
*
* @param mixed $value Raw value.
* @param string $type Data type.
* @return mixed
* @throws \Exception When an invalid type is passed.
*/
public function format_object_value_for_db( $value, string $type ) {
switch ( $type ) {
case 'decimal':
$value = wc_format_decimal( $value, false, true );
break;
case 'int':
$value = (int) $value;
break;
case 'bool':
$value = wc_string_to_bool( $value );
break;
case 'string':
$value = strval( $value );
break;
case 'date':
// Date properties are converted to the WP timezone (see WC_Data::set_date_prop() method), however
// for our own tables we persist dates in GMT.
$value = $value ? ( new DateTime( $value ) )->setTimezone( new DateTimeZone( '+00:00' ) )->format( 'Y-m-d H:i:s' ) : null;
break;
case 'date_epoch':
$value = $value ? ( new DateTime( "@{$value}" ) )->format( 'Y-m-d H:i:s' ) : null;
break;
default:
throw new \Exception( 'Invalid type received: ' . $type );
}
return $value;
}
/**
* Returns the `$wpdb` placeholder to use for data type `$type`.
*
* @param string $type Data type.
* @return string
* @throws \Exception When an invalid type is passed.
*/
public function get_wpdb_format_for_type( string $type ) {
static $wpdb_placeholder_for_type = array(
'int' => '%d',
'decimal' => '%f',
'string' => '%s',
'date' => '%s',
'date_epoch' => '%s',
'bool' => '%d',
);
if ( ! isset( $wpdb_placeholder_for_type[ $type ] ) ) {
throw new \Exception( 'Invalid column type: ' . $type );
}
return $wpdb_placeholder_for_type[ $type ];
}
/**
* Generates ON DUPLICATE KEY UPDATE clause to be used in migration.
*
* @param array $columns List of column names.
*
* @return string SQL clause for INSERT...ON DUPLICATE KEY UPDATE
*/
public function generate_on_duplicate_statement_clause( array $columns ): string {
$update_value_statements = array();
foreach ( $columns as $column ) {
$update_value_statements[] = "`$column` = VALUES( `$column` )";
}
$update_value_clause = implode( ', ', $update_value_statements );
return "ON DUPLICATE KEY UPDATE $update_value_clause";
}
/**
* Hybrid of $wpdb->update and $wpdb->insert. It will try to update a row, and if it doesn't exist, it will insert it. This needs unique constraints to be set on the table on all ID columns.
*
* You can use this function only when:
* 1. There is only one unique constraint on the table. The constraint can contain multiple columns, but it must be the only one unique constraint.
* 2. The complete unique constraint must be part of the $data array.
* 3. You do not need the LAST_INSERT_ID() value.
*
* @param string $table_name Table name.
* @param array $data Unescaped data to update (in column => value pairs).
* @param array $format An array of formats to be mapped to each of the values in $data.
*
* @return int Returns the value of DB's ON DUPLICATE KEY UPDATE clause.
*/
public function insert_on_duplicate_key_update( $table_name, $data, $format ) : int {
global $wpdb;
if ( empty( $data ) ) {
return 0;
}
$columns = array_keys( $data );
$value_format = array();
$values = array();
$index = 0;
// Directly use NULL for placeholder if the value is NULL, since otherwise $wpdb->prepare will convert it to empty string.
foreach ( $data as $key => $value ) {
if ( is_null( $value ) ) {
$value_format[] = 'NULL';
} else {
$values[] = $value;
$value_format[] = $format[ $index ];
}
$index++;
}
$column_clause = '`' . implode( '`, `', $columns ) . '`';
$value_format_clause = implode( ', ', $value_format );
$on_duplicate_clause = $this->generate_on_duplicate_statement_clause( $columns );
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Values are escaped in $wpdb->prepare.
$sql = $wpdb->prepare(
"
INSERT INTO $table_name ( $column_clause )
VALUES ( $value_format_clause )
$on_duplicate_clause
",
$values
);
// phpcs:enable
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is prepared.
return $wpdb->query( $sql );
}
/**
* Get max index length.
*
* @return int Max index length.
*/
public function get_max_index_length() : int {
/**
* Filters the maximum index length in the database.
*
* Indexes have a maximum size of 767 bytes. Historically, we haven't need to be concerned about that.
* As of WP 4.2, however, they moved to utf8mb4, which uses 4 bytes per character. This means that an index which
* used to have room for floor(767/3) = 255 characters, now only has room for floor(767/4) = 191 characters.
*
* Additionally, MyISAM engine also limits the index size to 1000 bytes. We add this filter so that interested folks on InnoDB engine can increase the size till allowed 3071 bytes.
*
* @param int $max_index_length Maximum index length. Default 191.
*
* @since 8.0.0
*/
$max_index_length = apply_filters( 'woocommerce_database_max_index_length', 191 );
// Index length cannot be more than 768, which is 3078 bytes in utf8mb4 and max allowed by InnoDB engine.
return min( absint( $max_index_length ), 767 );
}
}
HtmlSanitizer.php 0000644 00000005321 15154263032 0010054 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Utility for re-using WP Kses-based sanitization rules.
*/
class HtmlSanitizer {
/**
* Rules for allowing minimal HTML (breaks, images, paragraphs and spans) without any links.
*/
public const LOW_HTML_BALANCED_TAGS_NO_LINKS = array(
'pre_processors' => array(
'stripslashes',
'force_balance_tags',
),
'wp_kses_rules' => array(
'br' => true,
'img' => array(
'alt' => true,
'class' => true,
'src' => true,
'title' => true,
),
'p' => array(
'class' => true,
),
'span' => array(
'class' => true,
'title' => true,
),
),
);
/**
* Sanitizes the HTML according to the provided rules.
*
* @see wp_kses()
*
* @param string $html HTML string to be sanitized.
* @param array $sanitizer_rules {
* Optional and defaults to self::TRIMMED_BALANCED_LOW_HTML_NO_LINKS. Otherwise, one or more of the following
* keys should be set.
*
* @type array $pre_processors Callbacks to run before invoking `wp_kses()`.
* @type array $wp_kses_rules Element names and attributes to allow, per `wp_kses()`.
* }
*
* @return string
*/
public function sanitize( string $html, array $sanitizer_rules = self::LOW_HTML_BALANCED_TAGS_NO_LINKS ): string {
if ( isset( $sanitizer_rules['pre_processors'] ) && is_array( $sanitizer_rules['pre_processors'] ) ) {
$html = $this->apply_string_callbacks( $sanitizer_rules['pre_processors'], $html );
}
// If no KSES rules are specified, assume all HTML should be stripped.
$kses_rules = isset( $sanitizer_rules['wp_kses_rules'] ) && is_array( $sanitizer_rules['wp_kses_rules'] )
? $sanitizer_rules['wp_kses_rules']
: array();
return wp_kses( $html, $kses_rules );
}
/**
* Applies callbacks used to process the string before and after wp_kses().
*
* If a callback is invalid we will short-circuit and return an empty string, on the grounds that it is better to
* output nothing than risky HTML. We also call the problem out via _doing_it_wrong() to highlight the problem (and
* increase the chances of this being caught during development).
*
* @param callable[] $callbacks The callbacks used to mutate the string.
* @param string $string The string being processed.
*
* @return string
*/
private function apply_string_callbacks( array $callbacks, string $string ): string {
foreach ( $callbacks as $callback ) {
if ( ! is_callable( $callback ) ) {
_doing_it_wrong( __CLASS__ . '::apply', esc_html__( 'String processors must be an array of valid callbacks.', 'woocommerce' ), esc_html( WC()->version ) );
return '';
}
$string = (string) $callback( $string );
}
return $string;
}
}
URL.php 0000644 00000032143 15154263032 0005723 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Provides an easy method of assessing URLs, including filepaths (which will be silently
* converted to a file:// URL if provided).
*/
class URL {
/**
* Components of the URL being assessed.
*
* The keys match those potentially returned by the parse_url() function, except
* that they are always defined and 'drive' (Windows drive letter) has been added.
*
* @var string|null[]
*/
private $components = array(
'drive' => null,
'fragment' => null,
'host' => null,
'pass' => null,
'path' => null,
'port' => null,
'query' => null,
'scheme' => null,
'user' => null,
);
/**
* If the URL (or filepath) is absolute.
*
* @var bool
*/
private $is_absolute;
/**
* If the URL (or filepath) represents a directory other than the root directory.
*
* This is useful at different points in the process, when deciding whether to re-apply
* a trailing slash at the end of processing or when we need to calculate how many
* directory traversals are needed to form a (grand-)parent URL.
*
* @var bool
*/
private $is_non_root_directory;
/**
* The components of the URL's path.
*
* For instance, in the case of "file:///srv/www/wp.site" (noting that a file URL has
* no host component) this would contain:
*
* [ "srv", "www", "wp.site" ]
*
* In the case of a non-file URL such as "https://example.com/foo/bar/baz" (noting the
* host is not part of the path) it would contain:
*
* [ "foo", "bar", "baz" ]
*
* @var array
*/
private $path_parts = array();
/**
* The URL.
*
* @var string
*/
private $url;
/**
* Creates and processes the provided URL (or filepath).
*
* @throws URLException If the URL (or filepath) is seriously malformed.
*
* @param string $url The URL (or filepath).
*/
public function __construct( string $url ) {
$this->url = $url;
$this->preprocess();
$this->process_path();
}
/**
* Makes all slashes forward slashes, converts filepaths to file:// URLs, and
* other processing to help with comprehension of filepaths.
*
* @throws URLException If the URL is seriously malformed.
*/
private function preprocess() {
// For consistency, all slashes should be forward slashes.
$this->url = str_replace( '\\', '/', $this->url );
// Windows: capture the drive letter if provided.
if ( preg_match( '#^(file://)?([a-z]):/(?!/).*#i', $this->url, $matches ) ) {
$this->components['drive'] = $matches[2];
}
/*
* If there is no scheme, assume and prepend "file://". An exception is made for cases where the URL simply
* starts with exactly two forward slashes, which indicates 'any scheme' (most commonly, that is used when
* there is freedom to switch between 'http' and 'https').
*/
if ( ! preg_match( '#^[a-z]+://#i', $this->url ) && ! preg_match( '#^//(?!/)#', $this->url ) ) {
$this->url = 'file://' . $this->url;
}
$parsed_components = wp_parse_url( $this->url );
// If we received a really badly formed URL, let's go no further.
if ( false === $parsed_components ) {
throw new URLException(
sprintf(
/* translators: %s is the URL. */
__( '%s is not a valid URL.', 'woocommerce' ),
$this->url
)
);
}
$this->components = array_merge( $this->components, $parsed_components );
// File URLs cannot have a host. However, the initial path segment *or* the Windows drive letter
// (if present) may be incorrectly be interpreted as the host name.
if ( 'file' === $this->components['scheme'] && ! empty( $this->components['host'] ) ) {
// If we do not have a drive letter, then simply merge the host and the path together.
if ( null === $this->components['drive'] ) {
$this->components['path'] = $this->components['host'] . ( $this->components['path'] ?? '' );
}
// Restore the host to null in this situation.
$this->components['host'] = null;
}
}
/**
* Simplifies the path if possible, by resolving directory traversals to the extent possible
* without touching the filesystem.
*/
private function process_path() {
$segments = explode( '/', $this->components['path'] );
$this->is_absolute = substr( $this->components['path'], 0, 1 ) === '/' || ! empty( $this->components['host'] );
$this->is_non_root_directory = substr( $this->components['path'], -1, 1 ) === '/' && strlen( $this->components['path'] ) > 1;
$resolve_traversals = 'file' !== $this->components['scheme'] || $this->is_absolute;
$retain_traversals = false;
// Clean the path.
foreach ( $segments as $part ) {
// Drop empty segments.
if ( strlen( $part ) === 0 || '.' === $part ) {
continue;
}
// Directory traversals created with percent-encoding syntax should also be detected.
$is_traversal = str_ireplace( '%2e', '.', $part ) === '..';
// Resolve directory traversals (if allowed: see further comment relating to this).
if ( $resolve_traversals && $is_traversal ) {
if ( count( $this->path_parts ) > 0 && ! $retain_traversals ) {
$this->path_parts = array_slice( $this->path_parts, 0, count( $this->path_parts ) - 1 );
continue;
} elseif ( $this->is_absolute ) {
continue;
}
}
/*
* Consider allowing directory traversals to be resolved (ie, the process that converts 'foo/bar/../baz' to
* 'foo/baz').
*
* 1. For this decision point, we are only concerned with relative filepaths (in all other cases,
* $resolve_traversals will already be true).
* 2. This is a 'one time' and unidirectional operation. We only wish to flip from false to true, and we
* never wish to do this more than once.
* 3. We only flip the switch after we have examined all leading '..' traversal segments.
*/
if ( false === $resolve_traversals && '..' !== $part && 'file' === $this->components['scheme'] && ! $this->is_absolute ) {
$resolve_traversals = true;
}
/*
* Set a flag indicating that traversals should be retained. This is done to ensure we don't prematurely
* discard traversals at the start of the path.
*/
$retain_traversals = $resolve_traversals && '..' === $part;
// Retain this part of the path.
$this->path_parts[] = $part;
}
// Protect against empty relative paths.
if ( count( $this->path_parts ) === 0 && ! $this->is_absolute ) {
$this->path_parts = array( '.' );
$this->is_non_root_directory = true;
}
// Reform the path from the processed segments, appending a leading slash if it is absolute and restoring
// the Windows drive letter if we have one.
$this->components['path'] = ( $this->is_absolute ? '/' : '' ) . implode( '/', $this->path_parts ) . ( $this->is_non_root_directory ? '/' : '' );
}
/**
* Returns the processed URL as a string.
*
* @return string
*/
public function __toString(): string {
return $this->get_url();
}
/**
* Returns all possible parent URLs for the current URL.
*
* @return string[]
*/
public function get_all_parent_urls(): array {
$max_parent = count( $this->path_parts );
$parents = array();
/*
* If we are looking at a relative path that begins with at least one traversal (example: "../../foo")
* then we should only return one parent URL (otherwise, we'd potentially have to return an infinite
* number of parent URLs since we can't know how far the tree extends).
*/
if ( $max_parent > 0 && ! $this->is_absolute && '..' === $this->path_parts[0] ) {
$max_parent = 1;
}
for ( $level = 1; $level <= $max_parent; $level++ ) {
$parents[] = $this->get_parent_url( $level );
}
return $parents;
}
/**
* Outputs the parent URL.
*
* For example, if $this->get_url() returns "https://example.com/foo/bar/baz" then
* this method will return "https://example.com/foo/bar/".
*
* When a grand-parent is needed, the optional $level parameter can be used. By default
* this is set to 1 (parent). 2 will yield the grand-parent, 3 will yield the great
* grand-parent, etc.
*
* If a level is specified that exceeds the number of path segments, this method will
* return false.
*
* @param int $level Used to indicate the level of parent.
*
* @return string|false
*/
public function get_parent_url( int $level = 1 ) {
if ( $level < 1 ) {
$level = 1;
}
$parts_count = count( $this->path_parts );
$parent_path_parts_to_keep = $parts_count - $level;
/*
* With the exception of file URLs, we do not allow obtaining (grand-)parent directories that require
* us to describe them using directory traversals. For example, given "http://hostname/foo/bar/baz.png" we do
* not permit determining anything more than 2 levels up (we cannot go beyond "http://hostname/").
*/
if ( 'file' !== $this->components['scheme'] && $parent_path_parts_to_keep < 0 ) {
return false;
}
// In the specific case of an absolute filepath describing the root directory, there can be no parent.
if ( 'file' === $this->components['scheme'] && $this->is_absolute && empty( $this->path_parts ) ) {
return false;
}
// Handle cases where the path starts with one or more 'dot segments'. Since the path has already been
// processed, we can be confident that any such segments are at the start of the path.
if ( $parts_count > 0 && ( '.' === $this->path_parts[0] || '..' === $this->path_parts[0] ) ) {
// Determine the index of the last dot segment (ex: given the path '/../../foo' it would be 1).
$single_dots = array_keys( $this->path_parts, '.', true );
$double_dots = array_keys( $this->path_parts, '..', true );
$max_dot_index = max( array_merge( $single_dots, $double_dots ) );
// Prepend the required number of traversals and discard unnessary trailing segments.
$last_traversal = $max_dot_index + ( $this->is_non_root_directory ? 1 : 0 );
$parent_path = str_repeat( '../', $level ) . join( '/', array_slice( $this->path_parts, 0, $last_traversal ) );
} elseif ( $parent_path_parts_to_keep < 0 ) {
// For relative filepaths only, we use traversals to describe the requested parent.
$parent_path = untrailingslashit( str_repeat( '../', $parent_path_parts_to_keep * -1 ) );
} else {
// Otherwise, in a very simple case, we just remove existing parts.
$parent_path = implode( '/', array_slice( $this->path_parts, 0, $parent_path_parts_to_keep ) );
}
if ( $this->is_relative() && '' === $parent_path ) {
$parent_path = '.';
}
// Append a trailing slash, since a parent is always a directory. The only exception is the current working directory.
$parent_path .= '/';
// For absolute paths, apply a leading slash (does not apply if we have a root path).
if ( $this->is_absolute && 0 !== strpos( $parent_path, '/' ) ) {
$parent_path = '/' . $parent_path;
}
// Form the parent URL (ditching the query and fragment, if set).
$parent_url = $this->get_url(
array(
'path' => $parent_path,
'query' => null,
'fragment' => null,
)
);
// We process the parent URL through a fresh instance of this class, for consistency.
return ( new self( $parent_url ) )->get_url();
}
/**
* Outputs the processed URL.
*
* Borrows from https://www.php.net/manual/en/function.parse-url.php#106731
*
* @param array $component_overrides If provided, these will override values set in $this->components.
*
* @return string
*/
public function get_url( array $component_overrides = array() ): string {
$components = array_merge( $this->components, $component_overrides );
$scheme = null !== $components['scheme'] ? $components['scheme'] . '://' : '//';
$host = null !== $components['host'] ? $components['host'] : '';
$port = null !== $components['port'] ? ':' . $components['port'] : '';
$path = $this->get_path( $components['path'] );
// Special handling for hostless URLs (typically, filepaths) referencing the current working directory.
if ( '' === $host && ( '' === $path || '.' === $path ) ) {
$path = './';
}
$user = null !== $components['user'] ? $components['user'] : '';
$pass = null !== $components['pass'] ? ':' . $components['pass'] : '';
$user_pass = ( ! empty( $user ) || ! empty( $pass ) ) ? $user . $pass . '@' : '';
$query = null !== $components['query'] ? '?' . $components['query'] : '';
$fragment = null !== $components['fragment'] ? '#' . $components['fragment'] : '';
return $scheme . $user_pass . $host . $port . $path . $query . $fragment;
}
/**
* Outputs the path. Especially useful if it was a a regular filepath that was passed in originally.
*
* @param string $path_override If provided this will be used as the URL path. Does not impact drive letter.
*
* @return string
*/
public function get_path( string $path_override = null ): string {
return ( $this->components['drive'] ? $this->components['drive'] . ':' : '' ) . ( $path_override ?? $this->components['path'] );
}
/**
* Indicates if the URL or filepath was absolute.
*
* @return bool True if absolute, else false.
*/
public function is_absolute(): bool {
return $this->is_absolute;
}
/**
* Indicates if the URL or filepath was relative.
*
* @return bool True if relative, else false.
*/
public function is_relative(): bool {
return ! $this->is_absolute;
}
}
URLException.php 0000644 00000000277 15154263032 0007605 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
use Exception;
/**
* Used to represent a problem encountered when processing a URL.
*/
class URLException extends Exception {}
Users.php 0000644 00000001460 15154263032 0006360 0 ustar 00 <?php
namespace Automattic\WooCommerce\Internal\Utilities;
/**
* Helper functions for working with users.
*/
class Users {
/**
* Indicates if the user qualifies as site administrator.
*
* In the context of multisite networks, this means that they must have the `manage_sites`
* capability. In all other cases, they must have the `manage_options` capability.
*
* @param int $user_id Optional, used to specify a specific user (otherwise we look at the current user).
*
* @return bool
*/
public static function is_site_administrator( int $user_id = 0 ): bool {
$user = 0 === $user_id ? wp_get_current_user() : get_user_by( 'id', $user_id );
if ( false === $user ) {
return false;
}
return is_multisite() ? $user->has_cap( 'manage_sites' ) : $user->has_cap( 'manage_options' );
}
}
WebhookUtil.php 0000644 00000010777 15154263032 0007526 0 ustar 00 <?php
/**
* WebhookUtil class file.
*/
namespace Automattic\WooCommerce\Internal\Utilities;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
/**
* Class with utility methods for dealing with webhooks.
*/
class WebhookUtil {
use AccessiblePrivateMethods;
/**
* Creates a new instance of the class.
*/
public function __construct() {
self::add_action( 'deleted_user', array( $this, 'reassign_webhooks_to_new_user_id' ), 10, 2 );
self::add_action( 'delete_user_form', array( $this, 'maybe_render_user_with_webhooks_warning' ), 10, 2 );
}
/**
* Whenever a user is deleted, re-assign their webhooks to the new user.
*
* If re-assignment isn't selected during deletion, assign the webhooks to user_id 0,
* so that an admin can edit and re-save them in order to get them to be assigned to a valid user.
*
* @param int $old_user_id ID of the deleted user.
* @param int|null $new_user_id ID of the user to reassign existing data to, or null if no re-assignment is requested.
*
* @return void
* @since 7.8.0
*/
private function reassign_webhooks_to_new_user_id( int $old_user_id, ?int $new_user_id ): void {
$webhook_ids = $this->get_webhook_ids_for_user( $old_user_id );
foreach ( $webhook_ids as $webhook_id ) {
$webhook = new \WC_Webhook( $webhook_id );
$webhook->set_user_id( $new_user_id ?? 0 );
$webhook->save();
}
}
/**
* When users are about to be deleted show an informative text if they have webhooks assigned.
*
* @param \WP_User $current_user The current logged in user.
* @param array $userids Array with the ids of the users that are about to be deleted.
* @return void
* @since 7.8.0
*/
private function maybe_render_user_with_webhooks_warning( \WP_User $current_user, array $userids ): void {
global $wpdb;
$at_least_one_user_with_webhooks = false;
foreach ( $userids as $user_id ) {
$webhook_ids = $this->get_webhook_ids_for_user( $user_id );
if ( empty( $webhook_ids ) ) {
continue;
}
$at_least_one_user_with_webhooks = true;
$user_data = get_userdata( $user_id );
$user_login = false === $user_data ? '' : $user_data->user_login;
$webhooks_count = count( $webhook_ids );
$text = sprintf(
/* translators: 1 = user id, 2 = user login, 3 = webhooks count */
_nx(
'User #%1$s %2$s has created %3$d WooCommerce webhook.',
'User #%1$s %2$s has created %3$d WooCommerce webhooks.',
$webhooks_count,
'user webhook count',
'woocommerce'
),
$user_id,
$user_login,
$webhooks_count
);
echo '<p>' . esc_html( $text ) . '</p>';
}
if ( ! $at_least_one_user_with_webhooks ) {
return;
}
$webhooks_settings_url = esc_url_raw( admin_url( 'admin.php?page=wc-settings&tab=advanced§ion=webhooks' ) );
// This block of code is copied from WordPress' users.php.
// phpcs:disable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
$users_have_content = (bool) apply_filters( 'users_have_additional_content', false, $userids );
if ( ! $users_have_content ) {
if ( $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_author IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
$users_have_content = true;
} elseif ( $wpdb->get_var( "SELECT link_id FROM {$wpdb->links} WHERE link_owner IN( " . implode( ',', $userids ) . ' ) LIMIT 1' ) ) {
$users_have_content = true;
}
}
// phpcs:enable WooCommerce.Commenting.CommentHooks, WordPress.DB.PreparedSQL.NotPrepared
if ( $users_have_content ) {
$text = __( 'If the "Delete all content" option is selected, the affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
} else {
$text = __( 'The affected WooCommerce webhooks will <b>not</b> be deleted and will be attributed to user id 0.<br/>', 'woocommerce' );
}
$text .= sprintf(
/* translators: 1 = url of the WooCommerce webhooks settings page */
__( 'After that they can be reassigned to the logged-in user by going to the <a href="%1$s">WooCommerce webhooks settings page</a> and re-saving them.', 'woocommerce' ),
$webhooks_settings_url
);
echo '<p>' . wp_kses_post( $text ) . '</p>';
}
/**
* Get the ids of the webhooks assigned to a given user.
*
* @param int $user_id User id.
* @return int[] Array of webhook ids.
*/
private function get_webhook_ids_for_user( int $user_id ): array {
$data_store = \WC_Data_Store::load( 'webhook' );
return $data_store->search_webhooks(
array(
'user_id' => $user_id,
)
);
}
}
ImageAttachment.php 0000644 00000003705 15154777055 0010335 0 ustar 00 <?php
/**
* Helper to upload files via the REST API.
*
* @package WooCommerce\Utilities
*/
namespace Automattic\WooCommerce\RestApi\Utilities;
/**
* ImageAttachment class.
*/
class ImageAttachment {
/**
* Attachment ID.
*
* @var integer
*/
public $id = 0;
/**
* Object attached to.
*
* @var integer
*/
public $object_id = 0;
/**
* Constructor.
*
* @param integer $id Attachment ID.
* @param integer $object_id Object ID.
*/
public function __construct( $id = 0, $object_id = 0 ) {
$this->id = (int) $id;
$this->object_id = (int) $object_id;
}
/**
* Upload an attachment file.
*
* @throws \WC_REST_Exception REST API exceptions.
* @param string $src URL to file.
*/
public function upload_image_from_src( $src ) {
$upload = wc_rest_upload_image_from_url( esc_url_raw( $src ) );
if ( is_wp_error( $upload ) ) {
if ( ! apply_filters( 'woocommerce_rest_suppress_image_upload_error', false, $upload, $this->object_id, $images ) ) {
throw new \WC_REST_Exception( 'woocommerce_product_image_upload_error', $upload->get_error_message(), 400 );
} else {
return;
}
}
$this->id = wc_rest_set_uploaded_image_as_attachment( $upload, $this->object_id );
if ( ! wp_attachment_is_image( $this->id ) ) {
/* translators: %s: image ID */
throw new \WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $this->id ), 400 );
}
}
/**
* Update attachment alt text.
*
* @param string $text Text to set.
*/
public function update_alt_text( $text ) {
if ( ! $this->id ) {
return;
}
update_post_meta( $this->id, '_wp_attachment_image_alt', wc_clean( $text ) );
}
/**
* Update attachment name.
*
* @param string $text Text to set.
*/
public function update_name( $text ) {
if ( ! $this->id ) {
return;
}
wp_update_post(
array(
'ID' => $this->id,
'post_title' => $text,
)
);
}
}
SingletonTrait.php 0000644 00000001537 15154777055 0010251 0 ustar 00 <?php
/**
* Singleton class trait.
*
* @package WooCommerce\Utilities
*/
namespace Automattic\WooCommerce\RestApi\Utilities;
/**
* Singleton trait.
*/
trait SingletonTrait {
/**
* The single instance of the class.
*
* @var object
*/
protected static $instance = null;
/**
* Constructor
*
* @return void
*/
protected function __construct() {}
/**
* Get class instance.
*
* @return object Instance.
*/
final public static function instance() {
if ( null === static::$instance ) {
static::$instance = new static();
}
return static::$instance;
}
/**
* Prevent cloning.
*/
private function __clone() {}
/**
* Prevent unserializing.
*/
final public function __wakeup() {
wc_doing_it_wrong( __FUNCTION__, __( 'Unserializing instances of this class is forbidden.', 'woocommerce' ), '4.6' );
die();
}
}
ArrayIntersector.php 0000644 00000003560 15155320507 0010564 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\Utilities;
/**
* When computing many array intersections using the same array, it is more efficient to use `array_flip()` first and
* then `array_intersect_key()`, than `array_intersect()`. See the discussion at
* {@link https://stackoverflow.com/questions/6329211/php-array-intersect-efficiency Stack Overflow} for more
* information.
*
* Of course, this is only possible if the arrays contain integer or string values, and either don't contain duplicates,
* or that fact that duplicates will be removed does not matter.
*
* This class takes care of the detail.
*
* @internal
*/
class ArrayIntersector
{
/**
* the array with which the object was constructed, with all its keys exchanged with their associated values
*
* @var array<array-key, array-key>
*/
private $invertedArray;
/**
* Constructs the object with the array that will be reused for many intersection computations.
*
* @param array<array-key, array-key> $array
*/
public function __construct(array $array)
{
$this->invertedArray = \array_flip($array);
}
/**
* Computes the intersection of `$array` and the array with which this object was constructed.
*
* @param array<array-key, array-key> $array
*
* @return array<array-key, array-key>
* Returns an array containing all of the values in `$array` whose values exist in the array
* with which this object was constructed. Note that keys are preserved, order is maintained, but
* duplicates are removed.
*/
public function intersectWith(array $array): array
{
$invertedArray = \array_flip($array);
$invertedIntersection = \array_intersect_key($invertedArray, $this->invertedArray);
return \array_flip($invertedIntersection);
}
}
CssConcatenator.php 0000644 00000015116 15155320507 0010355 0 ustar 00 <?php
declare(strict_types=1);
namespace Pelago\Emogrifier\Utilities;
/**
* Facilitates building a CSS string by appending rule blocks one at a time, checking whether the media query,
* selectors, or declarations block are the same as those from the preceding block and combining blocks in such cases.
*
* Example:
* $concatenator = new CssConcatenator();
* $concatenator->append(['body'], 'color: blue;');
* $concatenator->append(['body'], 'font-size: 16px;');
* $concatenator->append(['p'], 'margin: 1em 0;');
* $concatenator->append(['ul', 'ol'], 'margin: 1em 0;');
* $concatenator->append(['body'], 'font-size: 14px;', '@media screen and (max-width: 400px)');
* $concatenator->append(['ul', 'ol'], 'margin: 0.75em 0;', '@media screen and (max-width: 400px)');
* $css = $concatenator->getCss();
*
* `$css` (if unminified) would contain the following CSS:
* ` body {
* ` color: blue;
* ` font-size: 16px;
* ` }
* ` p, ul, ol {
* ` margin: 1em 0;
* ` }
* ` @media screen and (max-width: 400px) {
* ` body {
* ` font-size: 14px;
* ` }
* ` ul, ol {
* ` margin: 0.75em 0;
* ` }
* ` }
*
* @internal
*/
class CssConcatenator
{
/**
* Array of media rules in order. Each element is an object with the following properties:
* - string `media` - The media query string, e.g. "@media screen and (max-width:639px)", or an empty string for
* rules not within a media query block;
* - object[] `ruleBlocks` - Array of rule blocks in order, where each element is an object with the following
* properties:
* - mixed[] `selectorsAsKeys` - Array whose keys are selectors for the rule block (values are of no
* significance);
* - string `declarationsBlock` - The property declarations, e.g. "margin-top: 0.5em; padding: 0".
*
* @var array<int, object{
* media: string,
* ruleBlocks: array<int, object{
* selectorsAsKeys: array<string, array-key>,
* declarationsBlock: string
* }>
* }>
*/
private $mediaRules = [];
/**
* Appends a declaration block to the CSS.
*
* @param array<array-key, string> $selectors
* array of selectors for the rule, e.g. ["ul", "ol", "p:first-child"]
* @param string $declarationsBlock
* the property declarations, e.g. "margin-top: 0.5em; padding: 0"
* @param string $media
* the media query for the rule, e.g. "@media screen and (max-width:639px)", or an empty string if none
*/
public function append(array $selectors, string $declarationsBlock, string $media = ''): void
{
$selectorsAsKeys = \array_flip($selectors);
$mediaRule = $this->getOrCreateMediaRuleToAppendTo($media);
$ruleBlocks = $mediaRule->ruleBlocks;
$lastRuleBlock = \end($ruleBlocks);
$hasSameDeclarationsAsLastRule = \is_object($lastRuleBlock)
&& $declarationsBlock === $lastRuleBlock->declarationsBlock;
if ($hasSameDeclarationsAsLastRule) {
$lastRuleBlock->selectorsAsKeys += $selectorsAsKeys;
} else {
$lastRuleBlockSelectors = \is_object($lastRuleBlock) ? $lastRuleBlock->selectorsAsKeys : [];
$hasSameSelectorsAsLastRule = \is_object($lastRuleBlock)
&& self::hasEquivalentSelectors($selectorsAsKeys, $lastRuleBlockSelectors);
if ($hasSameSelectorsAsLastRule) {
$lastDeclarationsBlockWithoutSemicolon = \rtrim(\rtrim($lastRuleBlock->declarationsBlock), ';');
$lastRuleBlock->declarationsBlock = $lastDeclarationsBlockWithoutSemicolon . ';' . $declarationsBlock;
} else {
$mediaRule->ruleBlocks[] = (object)\compact('selectorsAsKeys', 'declarationsBlock');
}
}
}
/**
* @return string
*/
public function getCss(): string
{
return \implode('', \array_map([self::class, 'getMediaRuleCss'], $this->mediaRules));
}
/**
* @param string $media The media query for rules to be appended, e.g. "@media screen and (max-width:639px)",
* or an empty string if none.
*
* @return object{
* media: string,
* ruleBlocks: array<int, object{
* selectorsAsKeys: array<string, array-key>,
* declarationsBlock: string
* }>
* }
*/
private function getOrCreateMediaRuleToAppendTo(string $media): object
{
$lastMediaRule = \end($this->mediaRules);
if (\is_object($lastMediaRule) && $media === $lastMediaRule->media) {
return $lastMediaRule;
}
$newMediaRule = (object)[
'media' => $media,
'ruleBlocks' => [],
];
$this->mediaRules[] = $newMediaRule;
return $newMediaRule;
}
/**
* Tests if two sets of selectors are equivalent (i.e. the same selectors, possibly in a different order).
*
* @param array<string, array-key> $selectorsAsKeys1
* array in which the selectors are the keys, and the values are of no significance
* @param array<string, array-key> $selectorsAsKeys2 another such array
*
* @return bool
*/
private static function hasEquivalentSelectors(array $selectorsAsKeys1, array $selectorsAsKeys2): bool
{
return \count($selectorsAsKeys1) === \count($selectorsAsKeys2)
&& \count($selectorsAsKeys1) === \count($selectorsAsKeys1 + $selectorsAsKeys2);
}
/**
* @param object{
* media: string,
* ruleBlocks: array<int, object{
* selectorsAsKeys: array<string, array-key>,
* declarationsBlock: string
* }>
* } $mediaRule
*
* @return string CSS for the media rule.
*/
private static function getMediaRuleCss(object $mediaRule): string
{
$ruleBlocks = $mediaRule->ruleBlocks;
$css = \implode('', \array_map([self::class, 'getRuleBlockCss'], $ruleBlocks));
$media = $mediaRule->media;
if ($media !== '') {
$css = $media . '{' . $css . '}';
}
return $css;
}
/**
* @param object{selectorsAsKeys: array<string, array-key>, declarationsBlock: string} $ruleBlock
*
* @return string CSS for the rule block.
*/
private static function getRuleBlockCss(object $ruleBlock): string
{
$selectorsAsKeys = $ruleBlock->selectorsAsKeys;
$selectors = \array_keys($selectorsAsKeys);
$declarationsBlock = $ruleBlock->declarationsBlock;
return \implode(',', $selectors) . '{' . $declarationsBlock . '}';
}
}
ArrayUtils.php 0000644 00000002032 15155613047 0007360 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* ArrayUtils class used for custom functions to operate on arrays
*/
class ArrayUtils {
/**
* Join a string with a natural language conjunction at the end.
*
* @param array $array The array to join together with the natural language conjunction.
* @param bool $enclose_items_with_quotes Whether each item in the array should be enclosed within quotation marks.
*
* @return string a string containing a list of items and a natural language conjuction.
*/
public static function natural_language_join( $array, $enclose_items_with_quotes = false ) {
if ( true === $enclose_items_with_quotes ) {
$array = array_map(
function( $item ) {
return '"' . $item . '"';
},
$array
);
}
$last = array_pop( $array );
if ( $array ) {
return sprintf(
/* translators: 1: The first n-1 items of a list 2: the last item in the list. */
__( '%1$s and %2$s', 'woocommerce' ),
implode( ', ', $array ),
$last
);
}
return $last;
}
}
CartController.php 0000644 00000130432 15155613050 0010216 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\NotPurchasableException;
use Automattic\WooCommerce\StoreApi\Exceptions\OutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\PartialOutOfStockException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Exceptions\TooManyInCartException;
use Automattic\WooCommerce\StoreApi\Utilities\ArrayUtils;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\NoticeHandler;
use Automattic\WooCommerce\StoreApi\Utilities\QuantityLimits;
use Automattic\WooCommerce\Blocks\Package;
use WP_Error;
/**
* Woo Cart Controller class.
*
* Helper class to bridge the gap between the cart API and Woo core.
*/
class CartController {
use DraftOrderTrait;
/**
* Makes the cart and sessions available to a route by loading them from core.
*/
public function load_cart() {
if ( ! did_action( 'woocommerce_load_cart_from_session' ) && function_exists( 'wc_load_cart' ) ) {
wc_load_cart();
}
}
/**
* Recalculates the cart totals.
*/
public function calculate_totals() {
$cart = $this->get_cart_instance();
$cart->get_cart();
$cart->calculate_fees();
$cart->calculate_shipping();
$cart->calculate_totals();
}
/**
* Based on the core cart class but returns errors rather than rendering notices directly.
*
* @todo Overriding the core add_to_cart method was necessary because core outputs notices when an item is added to
* the cart. For us this would cause notices to build up and output on the store, out of context. Core would need
* refactoring to split notices out from other cart actions.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return string
*/
public function add_to_cart( $request ) {
$cart = $this->get_cart_instance();
$request = wp_parse_args(
$request,
[
'id' => 0,
'quantity' => 1,
'variation' => [],
'cart_item_data' => [],
]
);
$request = $this->filter_request_data( $this->parse_variation_data( $request ) );
$product = $this->get_product_for_cart( $request );
$cart_id = $cart->generate_cart_id(
$this->get_product_id( $product ),
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
$this->validate_add_to_cart( $product, $request );
$quantity_limits = new QuantityLimits();
$existing_cart_id = $cart->find_product_in_cart( $cart_id );
if ( $existing_cart_id ) {
$cart_item = $cart->cart_contents[ $existing_cart_id ];
$quantity_validation = $quantity_limits->validate_cart_item_quantity( $request['quantity'] + $cart_item['quantity'], $cart_item );
if ( is_wp_error( $quantity_validation ) ) {
throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
}
$cart->set_quantity( $existing_cart_id, $request['quantity'] + $cart->cart_contents[ $existing_cart_id ]['quantity'], true );
return $existing_cart_id;
}
// Normalize quantity.
$add_to_cart_limits = $quantity_limits->get_add_to_cart_limits( $product );
$request_quantity = (int) $request['quantity'];
if ( $add_to_cart_limits['maximum'] ) {
$request_quantity = min( $request_quantity, $add_to_cart_limits['maximum'] );
}
$request_quantity = max( $request_quantity, $add_to_cart_limits['minimum'] );
$request_quantity = $quantity_limits->limit_to_multiple( $request_quantity, $add_to_cart_limits['multiple_of'] );
/**
* Filters the item being added to the cart.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_item_data Array of cart item data being added to the cart.
* @param string $cart_id Id of the item in the cart.
* @return array Updated cart item data.
*/
$cart->cart_contents[ $cart_id ] = apply_filters(
'woocommerce_add_cart_item',
array_merge(
$request['cart_item_data'],
array(
'key' => $cart_id,
'product_id' => $this->get_product_id( $product ),
'variation_id' => $this->get_variation_id( $product ),
'variation' => $request['variation'],
'quantity' => $request_quantity,
'data' => $product,
'data_hash' => wc_get_cart_item_data_hash( $product ),
)
),
$cart_id
);
/**
* Filters the entire cart contents when the cart changes.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_contents Array of all cart items.
* @return array Updated array of all cart items.
*/
$cart->cart_contents = apply_filters( 'woocommerce_cart_contents_changed', $cart->cart_contents );
/**
* Fires when an item is added to the cart.
*
* This hook fires when an item is added to the cart. This is triggered from the Store API in this context, but
* WooCommerce core add to cart events trigger the same hook.
*
* @since 2.5.0
*
* @internal Matches action name in WooCommerce core.
*
* @param string $cart_id ID of the item in the cart.
* @param integer $product_id ID of the product added to the cart.
* @param integer $request_quantity Quantity of the item added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param array $variation Array of variation data.
* @param array $cart_item_data Array of other cart item data.
*/
do_action(
'woocommerce_add_to_cart',
$cart_id,
$this->get_product_id( $product ),
$request_quantity,
$this->get_variation_id( $product ),
$request['variation'],
$request['cart_item_data']
);
return $cart_id;
}
/**
* Based on core `set_quantity` method, but validates if an item is sold individually first and enforces any limits in
* place.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param string $item_id Cart item id.
* @param integer $quantity Cart quantity.
*/
public function set_cart_item_quantity( $item_id, $quantity = 1 ) {
$cart_item = $this->get_cart_item( $item_id );
if ( empty( $cart_item ) ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woocommerce' ), 409 );
}
$product = $cart_item['data'];
if ( ! $product instanceof \WC_Product ) {
throw new RouteException( 'woocommerce_rest_cart_invalid_product', __( 'Cart item is invalid.', 'woocommerce' ), 404 );
}
$quantity_validation = ( new QuantityLimits() )->validate_cart_item_quantity( $quantity, $cart_item );
if ( is_wp_error( $quantity_validation ) ) {
throw new RouteException( $quantity_validation->get_error_code(), $quantity_validation->get_error_message(), 400 );
}
$cart = $this->get_cart_instance();
$cart->set_quantity( $item_id, $quantity );
}
/**
* Validate all items in the cart and check for errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Product $product Product object associated with the cart item.
* @param array $request Add to cart request params.
*/
public function validate_add_to_cart( \WC_Product $product, $request ) {
if ( ! $product->is_purchasable() ) {
$this->throw_default_product_exception( $product );
}
if ( ! $product->is_in_stock() ) {
throw new RouteException(
'woocommerce_rest_product_out_of_stock',
sprintf(
/* translators: %s: product name */
__( 'You cannot add "%s" to the cart because the product is out of stock.', 'woocommerce' ),
$product->get_name()
),
400
);
}
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart + $request['quantity'] ) {
throw new RouteException(
'woocommerce_rest_product_partially_out_of_stock',
sprintf(
/* translators: 1: product name 2: quantity in stock */
__( 'You cannot add that amount of "%1$s" to the cart because there is not enough stock (%2$s remaining).', 'woocommerce' ),
$product->get_name(),
wc_format_stock_quantity_for_display( $qty_remaining, $product )
),
400
);
}
}
/**
* Filters if an item being added to the cart passed validation checks.
*
* Allow 3rd parties to validate if an item can be added to the cart. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to exceptions instead.
*
* @since 7.2.0
*
* @deprecated
* @param boolean $passed_validation True if the item passed validation.
* @param integer $product_id Product ID being validated.
* @param integer $quantity Quantity added to the cart.
* @param integer $variation_id Variation ID being added to the cart.
* @param array $variation Variation data.
* @return boolean
*/
$passed_validation = apply_filters(
'woocommerce_add_to_cart_validation',
true,
$this->get_product_id( $product ),
$request['quantity'],
$this->get_variation_id( $product ),
$request['variation']
);
if ( ! $passed_validation ) {
// Validation did not pass - see if an error notice was thrown.
NoticeHandler::convert_notices_to_exceptions( 'woocommerce_rest_add_to_cart_error' );
// If no notice was thrown, throw the default notice instead.
$this->throw_default_product_exception( $product );
}
/**
* Fires during validation when adding an item to the cart via the Store API.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $request Add to cart request params including id, quantity, and variation attributes.
* @deprecated 7.1.0 Use woocommerce_store_api_validate_add_to_cart instead.
*/
wc_do_deprecated_action(
'wooocommerce_store_api_validate_add_to_cart',
array(
$product,
$request,
),
'7.1.0',
'woocommerce_store_api_validate_add_to_cart',
'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_add_to_cart instead.'
);
/**
* Fires during validation when adding an item to the cart via the Store API.
*
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from happening.
*
* @since 7.1.0
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $request Add to cart request params including id, quantity, and variation attributes.
*/
do_action( 'woocommerce_store_api_validate_add_to_cart', $product, $request );
}
/**
* Generates the error message for out of stock products and adds product names to it.
*
* @param string $singular The message to use when only one product is in the list.
* @param string $plural The message to use when more than one product is in the list.
* @param array $items The list of cart items whose names should be inserted into the message.
* @returns string The translated and correctly pluralised message.
*/
private function add_product_names_to_message( $singular, $plural, $items ) {
$product_names = wc_list_pluck( $items, 'getProductName' );
$message = ( count( $items ) > 1 ) ? $plural : $singular;
return sprintf(
$message,
ArrayUtils::natural_language_join( $product_names, true )
);
}
/**
* Takes a string describing the type of stock extension, whether there is a single product or multiple products
* causing this exception and returns an appropriate error message.
*
* @param string $exception_type The type of exception encountered.
* @param string $singular_or_plural Whether to get the error message for a single product or multiple.
*
* @return string
*/
private function get_error_message_for_stock_exception_type( $exception_type, $singular_or_plural ) {
$stock_error_messages = [
'out_of_stock' => [
/* translators: %s: product name. */
'singular' => __(
'%s is out of stock and cannot be purchased. Please remove it from your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'%s are out of stock and cannot be purchased. Please remove them from your cart.',
'woocommerce'
),
],
'not_purchasable' => [
/* translators: %s: product name. */
'singular' => __(
'%s cannot be purchased. Please remove it from your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'%s cannot be purchased. Please remove them from your cart.',
'woocommerce'
),
],
'too_many_in_cart' => [
/* translators: %s: product names. */
'singular' => __(
'There are too many %s in the cart. Only 1 can be purchased. Please reduce the quantity in your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'There are too many %s in the cart. Only 1 of each can be purchased. Please reduce the quantities in your cart.',
'woocommerce'
),
],
'partial_out_of_stock' => [
/* translators: %s: product names. */
'singular' => __(
'There is not enough %s in stock. Please reduce the quantity in your cart.',
'woocommerce'
),
/* translators: %s: product names. */
'plural' => __(
'There are not enough %s in stock. Please reduce the quantities in your cart.',
'woocommerce'
),
],
];
if (
isset( $stock_error_messages[ $exception_type ] ) &&
isset( $stock_error_messages[ $exception_type ][ $singular_or_plural ] )
) {
return $stock_error_messages[ $exception_type ][ $singular_or_plural ];
}
return __( 'There was an error with an item in your cart.', 'woocommerce' );
}
/**
* Validate cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected in the cart.
*/
public function validate_cart() {
$this->validate_cart_items();
$this->validate_cart_coupons();
$cart = $this->get_cart_instance();
$cart_errors = new WP_Error();
/**
* Fires an action to validate the cart.
*
* Functions hooking into this should add custom errors using the provided WP_Error instance.
*
* @since 7.2.0
*
* @example See docs/examples/validate-cart.md
*
* @param \WP_Error $errors WP_Error object.
* @param \WC_Cart $cart Cart object.
*/
do_action( 'woocommerce_store_api_cart_errors', $cart_errors, $cart );
if ( $cart_errors->has_errors() ) {
throw new InvalidCartException(
'woocommerce_cart_error',
$cart_errors,
409
);
}
// Before running the woocommerce_check_cart_items hook, unhook validation from the core cart.
remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_items' ), 1 );
remove_action( 'woocommerce_check_cart_items', array( $cart, 'check_cart_coupons' ), 1 );
/**
* Fires when cart items are being validated.
*
* Allow 3rd parties to validate cart items. This is a legacy hook from Woo core.
* This filter will be deprecated because it encourages usage of wc_add_notice. For the API we need to capture
* notices and convert to wp errors instead.
*
* @since 7.2.0
*
* @deprecated
* @internal Matches action name in WooCommerce core.
*/
do_action( 'woocommerce_check_cart_items' );
$cart_errors = NoticeHandler::convert_notices_to_wp_errors( 'woocommerce_rest_cart_item_error' );
if ( $cart_errors->has_errors() ) {
throw new InvalidCartException(
'woocommerce_cart_error',
$cart_errors,
409
);
}
}
/**
* Validate all items in the cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected due to insufficient stock levels.
*/
public function validate_cart_items() {
$cart = $this->get_cart_instance();
$cart_items = $this->get_cart_items();
$errors = [];
$out_of_stock_products = [];
$too_many_in_cart_products = [];
$partial_out_of_stock_products = [];
$not_purchasable_products = [];
foreach ( $cart_items as $cart_item_key => $cart_item ) {
try {
$this->validate_cart_item( $cart_item );
} catch ( RouteException $error ) {
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
} catch ( TooManyInCartException $error ) {
$too_many_in_cart_products[] = $error;
} catch ( NotPurchasableException $error ) {
$not_purchasable_products[] = $error;
} catch ( PartialOutOfStockException $error ) {
$partial_out_of_stock_products[] = $error;
} catch ( OutOfStockException $error ) {
$out_of_stock_products[] = $error;
}
}
if ( count( $errors ) > 0 ) {
$error = new WP_Error();
foreach ( $errors as $wp_error ) {
$error->merge_from( $wp_error );
}
throw new InvalidCartException(
'woocommerce_cart_error',
$error,
409
);
}
$error = $this->stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products );
if ( $error->has_errors() ) {
throw new InvalidCartException(
'woocommerce_stock_availability_error',
$error,
409
);
}
}
/**
* This method will take arrays of exceptions relating to stock, and will convert them to a WP_Error object.
*
* @param TooManyInCartException[] $too_many_in_cart_products Array of TooManyInCartExceptions.
* @param NotPurchasableException[] $not_purchasable_products Array of NotPurchasableExceptions.
* @param PartialOutOfStockException[] $partial_out_of_stock_products Array of PartialOutOfStockExceptions.
* @param OutOfStockException[] $out_of_stock_products Array of OutOfStockExceptions.
*
* @return WP_Error The WP_Error object returned. Will have errors if any exceptions were in the args. It will be empty if they do not.
*/
private function stock_exceptions_to_wp_errors( $too_many_in_cart_products, $not_purchasable_products, $partial_out_of_stock_products, $out_of_stock_products ) {
$error = new WP_Error();
if ( count( $out_of_stock_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'out_of_stock', 'plural' );
$error->add(
'woocommerce_rest_product_out_of_stock',
$this->add_product_names_to_message( $singular_error, $plural_error, $out_of_stock_products )
);
}
if ( count( $not_purchasable_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'not_purchasable', 'plural' );
$error->add(
'woocommerce_rest_product_not_purchasable',
$this->add_product_names_to_message( $singular_error, $plural_error, $not_purchasable_products )
);
}
if ( count( $too_many_in_cart_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'too_many_in_cart', 'plural' );
$error->add(
'woocommerce_rest_product_too_many_in_cart',
$this->add_product_names_to_message( $singular_error, $plural_error, $too_many_in_cart_products )
);
}
if ( count( $partial_out_of_stock_products ) > 0 ) {
$singular_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'singular' );
$plural_error = $this->get_error_message_for_stock_exception_type( 'partial_out_of_stock', 'plural' );
$error->add(
'woocommerce_rest_product_partially_out_of_stock',
$this->add_product_names_to_message( $singular_error, $plural_error, $partial_out_of_stock_products )
);
}
return $error;
}
/**
* Validates an existing cart item and returns any errors.
*
* @throws TooManyInCartException Exception if more than one product that can only be purchased individually is in
* the cart.
* @throws PartialOutOfStockException Exception if an item has a quantity greater than what is available in stock.
* @throws OutOfStockException Exception thrown when an item is entirely out of stock.
* @throws NotPurchasableException Exception thrown when an item is not purchasable.
* @param array $cart_item Cart item array.
*/
public function validate_cart_item( $cart_item ) {
$product = $cart_item['data'];
if ( ! $product instanceof \WC_Product ) {
return;
}
if ( ! $product->is_purchasable() ) {
throw new NotPurchasableException(
'woocommerce_rest_product_not_purchasable',
$product->get_name()
);
}
if ( $product->is_sold_individually() && $cart_item['quantity'] > 1 ) {
throw new TooManyInCartException(
'woocommerce_rest_product_too_many_in_cart',
$product->get_name()
);
}
if ( ! $product->is_in_stock() ) {
throw new OutOfStockException(
'woocommerce_rest_product_out_of_stock',
$product->get_name()
);
}
if ( $product->managing_stock() && ! $product->backorders_allowed() ) {
$qty_remaining = $this->get_remaining_stock_for_product( $product );
$qty_in_cart = $this->get_product_quantity_in_cart( $product );
if ( $qty_remaining < $qty_in_cart ) {
throw new PartialOutOfStockException(
'woocommerce_rest_product_partially_out_of_stock',
$product->get_name()
);
}
}
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occurring.
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $cart_item Cart item array.
* @deprecated 7.1.0 Use woocommerce_store_api_validate_cart_item instead.
*/
wc_do_deprecated_action(
'wooocommerce_store_api_validate_cart_item',
array(
$product,
$cart_item,
),
'7.1.0',
'woocommerce_store_api_validate_cart_item',
'This action was deprecated in WooCommerce Blocks version 7.1.0. Please use woocommerce_store_api_validate_cart_item instead.'
);
/**
* Fire action to validate add to cart. Functions hooking into this should throw an \Exception to prevent
* add to cart from occurring.
*
* @since 7.1.0
*
* @param \WC_Product $product Product object being added to the cart.
* @param array $cart_item Cart item array.
*/
do_action( 'woocommerce_store_api_validate_cart_item', $product, $cart_item );
}
/**
* Validate all coupons in the cart and check for errors.
*
* @throws InvalidCartException Exception if invalid data is detected.
*/
public function validate_cart_coupons() {
$cart_coupons = $this->get_cart_coupons();
$errors = [];
foreach ( $cart_coupons as $code ) {
$coupon = new \WC_Coupon( $code );
try {
$this->validate_cart_coupon( $coupon );
} catch ( RouteException $error ) {
$errors[] = new WP_Error( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
}
}
if ( ! empty( $errors ) ) {
$error = new WP_Error();
foreach ( $errors as $wp_error ) {
$error->merge_from( $wp_error );
}
throw new InvalidCartException(
'woocommerce_coupons_error',
$error,
409
);
}
}
/**
* Validate the cart and get a list of errors.
*
* @return WP_Error A WP_Error instance containing the cart's errors.
*/
public function get_cart_errors() {
$errors = new WP_Error();
try {
$this->validate_cart();
} catch ( RouteException $error ) {
$errors->add( $error->getErrorCode(), $error->getMessage(), $error->getAdditionalData() );
} catch ( InvalidCartException $error ) {
$errors->merge_from( $error->getError() );
} catch ( \Exception $error ) {
$errors->add( $error->getCode(), $error->getMessage() );
}
return $errors;
}
/**
* Get main instance of cart class.
*
* @throws RouteException When cart cannot be loaded.
* @return \WC_Cart
*/
public function get_cart_instance() {
$cart = wc()->cart;
if ( ! $cart || ! $cart instanceof \WC_Cart ) {
throw new RouteException( 'woocommerce_rest_cart_error', __( 'Unable to retrieve cart.', 'woocommerce' ), 500 );
}
return $cart;
}
/**
* Return a cart item from the woo core cart class.
*
* @param string $item_id Cart item id.
* @return array
*/
public function get_cart_item( $item_id ) {
$cart = $this->get_cart_instance();
return isset( $cart->cart_contents[ $item_id ] ) ? $cart->cart_contents[ $item_id ] : [];
}
/**
* Returns all cart items.
*
* @param callable $callback Optional callback to apply to the array filter.
* @return array
*/
public function get_cart_items( $callback = null ) {
$cart = $this->get_cart_instance();
return $callback ? array_filter( $cart->get_cart(), $callback ) : array_filter( $cart->get_cart() );
}
/**
* Get hashes for items in the current cart. Useful for tracking changes.
*
* @return array
*/
public function get_cart_hashes() {
$cart = $this->get_cart_instance();
return [
'line_items' => $cart->get_cart_hash(),
'shipping' => md5( wp_json_encode( $cart->shipping_methods ) ),
'fees' => md5( wp_json_encode( $cart->get_fees() ) ),
'coupons' => md5( wp_json_encode( $cart->get_applied_coupons() ) ),
'taxes' => md5( wp_json_encode( $cart->get_taxes() ) ),
];
}
/**
* Empty cart contents.
*/
public function empty_cart() {
$cart = $this->get_cart_instance();
$cart->empty_cart();
}
/**
* See if cart has applied coupon by code.
*
* @param string $coupon_code Cart coupon code.
* @return bool
*/
public function has_coupon( $coupon_code ) {
$cart = $this->get_cart_instance();
return $cart->has_discount( $coupon_code );
}
/**
* Returns all applied coupons.
*
* @param callable $callback Optional callback to apply to the array filter.
* @return array
*/
public function get_cart_coupons( $callback = null ) {
$cart = $this->get_cart_instance();
return $callback ? array_filter( $cart->get_applied_coupons(), $callback ) : array_filter( $cart->get_applied_coupons() );
}
/**
* Get shipping packages from the cart with calculated shipping rates.
*
* @todo this can be refactored once https://github.com/woocommerce/woocommerce/pull/26101 lands.
*
* @param bool $calculate_rates Should rates for the packages also be returned.
* @return array
*/
public function get_shipping_packages( $calculate_rates = true ) {
$cart = $this->get_cart_instance();
// See if we need to calculate anything.
if ( ! $cart->needs_shipping() ) {
return [];
}
$packages = $cart->get_shipping_packages();
// Add extra package data to array.
if ( count( $packages ) ) {
$packages = array_map(
function( $key, $package, $index ) {
$package['package_id'] = isset( $package['package_id'] ) ? $package['package_id'] : $key;
$package['package_name'] = isset( $package['package_name'] ) ? $package['package_name'] : $this->get_package_name( $package, $index );
return $package;
},
array_keys( $packages ),
$packages,
range( 1, count( $packages ) )
);
}
$packages = $calculate_rates ? wc()->shipping()->calculate_shipping( $packages ) : $packages;
return $packages;
}
/**
* Creates a name for a package.
*
* @param array $package Shipping package from WooCommerce.
* @param int $index Package number.
* @return string
*/
protected function get_package_name( $package, $index ) {
/**
* Filters the shipping package name.
*
* @since 4.3.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param string $shipping_package_name Shipping package name.
* @param string $package_id Shipping package ID.
* @param array $package Shipping package from WooCommerce.
* @return string Shipping package name.
*/
return apply_filters(
'woocommerce_shipping_package_name',
$index > 1 ?
sprintf(
/* translators: %d: shipping package number */
_x( 'Shipment %d', 'shipping packages', 'woocommerce' ),
$index
) :
_x( 'Shipment 1', 'shipping packages', 'woocommerce' ),
$package['package_id'],
$package
);
}
/**
* Selects a shipping rate.
*
* @param int|string $package_id ID of the package to choose a rate for.
* @param string $rate_id ID of the rate being chosen.
*/
public function select_shipping_rate( $package_id, $rate_id ) {
$cart = $this->get_cart_instance();
$session_data = wc()->session->get( 'chosen_shipping_methods' ) ? wc()->session->get( 'chosen_shipping_methods' ) : [];
$session_data[ $package_id ] = $rate_id;
wc()->session->set( 'chosen_shipping_methods', $session_data );
}
/**
* Based on the core cart class but returns errors rather than rendering notices directly.
*
* @todo Overriding the core apply_coupon method was necessary because core outputs notices when a coupon gets
* applied. For us this would cause notices to build up and output on the store, out of context. Core would need
* refactoring to split notices out from other cart actions.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param string $coupon_code Coupon code.
*/
public function apply_coupon( $coupon_code ) {
$cart = $this->get_cart_instance();
$applied_coupons = $this->get_cart_coupons();
$coupon = new \WC_Coupon( $coupon_code );
if ( $coupon->get_code() !== $coupon_code ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s coupon code */
__( '"%s" is an invalid coupon code.', 'woocommerce' ),
esc_html( $coupon_code )
),
400
);
}
if ( $this->has_coupon( $coupon_code ) ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s coupon code */
__( 'Coupon code "%s" has already been applied.', 'woocommerce' ),
esc_html( $coupon_code )
),
400
);
}
if ( ! $coupon->is_valid() ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
wp_strip_all_tags( $coupon->get_error_message() ),
400
);
}
// Prevents new coupons being added if individual use coupons are already in the cart.
$individual_use_coupons = $this->get_cart_coupons(
function( $code ) {
$coupon = new \WC_Coupon( $code );
return $coupon->get_individual_use();
}
);
foreach ( $individual_use_coupons as $code ) {
$individual_use_coupon = new \WC_Coupon( $code );
/**
* Filters if a coupon can be applied alongside other individual use coupons.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param boolean $apply_with_individual_use_coupon Defaults to false.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param \WC_Coupon $individual_use_coupon Individual use coupon already applied to the cart.
* @param array $applied_coupons Array of applied coupons already applied to the cart.
* @return boolean
*/
if ( false === apply_filters( 'woocommerce_apply_with_individual_use_coupon', false, $coupon, $individual_use_coupon, $applied_coupons ) ) {
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %s: coupon code */
__( '"%s" has already been applied and cannot be used in conjunction with other coupons.', 'woocommerce' ),
$code
),
400
);
}
}
if ( $coupon->get_individual_use() ) {
/**
* Filter coupons to remove when applying an individual use coupon.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $coupons Array of coupons to remove from the cart.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param array $applied_coupons Array of applied coupons already applied to the cart.
* @return array
*/
$coupons_to_remove = array_diff( $applied_coupons, apply_filters( 'woocommerce_apply_individual_use_coupon', array(), $coupon, $applied_coupons ) );
foreach ( $coupons_to_remove as $code ) {
$cart->remove_coupon( $code );
}
$applied_coupons = array_diff( $applied_coupons, $coupons_to_remove );
}
$applied_coupons[] = $coupon_code;
$cart->set_applied_coupons( $applied_coupons );
/**
* Fires after a coupon has been applied to the cart.
*
* @since 2.6.0
*
* @internal Matches action name in WooCommerce core.
*
* @param string $coupon_code The coupon code that was applied.
*/
do_action( 'woocommerce_applied_coupon', $coupon_code );
}
/**
* Validates an existing cart coupon and returns any errors.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param \WC_Coupon $coupon Coupon object applied to the cart.
*/
protected function validate_cart_coupon( \WC_Coupon $coupon ) {
if ( ! $coupon->is_valid() ) {
$cart = $this->get_cart_instance();
$cart->remove_coupon( $coupon->get_code() );
$cart->calculate_totals();
throw new RouteException(
'woocommerce_rest_cart_coupon_error',
sprintf(
/* translators: %1$s coupon code, %2$s reason. */
__( 'The "%1$s" coupon has been removed from your cart: %2$s', 'woocommerce' ),
$coupon->get_code(),
wp_strip_all_tags( $coupon->get_error_message() )
),
409
);
}
}
/**
* Gets the qty of a product across line items.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_product_quantity_in_cart( $product ) {
$cart = $this->get_cart_instance();
$product_quantities = $cart->get_cart_item_quantities();
$product_id = $product->get_stock_managed_by_id();
return isset( $product_quantities[ $product_id ] ) ? $product_quantities[ $product_id ] : 0;
}
/**
* Gets remaining stock for a product.
*
* @param \WC_Product $product Product object.
* @return int
*/
protected function get_remaining_stock_for_product( $product ) {
$reserve_stock = new ReserveStock();
$qty_reserved = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );
return $product->get_stock_quantity() - $qty_reserved;
}
/**
* Get a product object to be added to the cart.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return \WC_Product|Error Returns a product object if purchasable.
*/
protected function get_product_for_cart( $request ) {
$product = wc_get_product( $request['id'] );
if ( ! $product || 'trash' === $product->get_status() ) {
throw new RouteException(
'woocommerce_rest_cart_invalid_product',
__( 'This product cannot be added to the cart.', 'woocommerce' ),
400
);
}
return $product;
}
/**
* For a given product, get the product ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_product_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id();
}
/**
* For a given product, get the variation ID.
*
* @param \WC_Product $product Product object associated with the cart item.
* @return int
*/
protected function get_variation_id( \WC_Product $product ) {
return $product->is_type( 'variation' ) ? $product->get_id() : 0;
}
/**
* Default exception thrown when an item cannot be added to the cart.
*
* @throws RouteException Exception with code woocommerce_rest_product_not_purchasable.
*
* @param \WC_Product $product Product object associated with the cart item.
*/
protected function throw_default_product_exception( \WC_Product $product ) {
throw new RouteException(
'woocommerce_rest_product_not_purchasable',
sprintf(
/* translators: %s: product name */
__( '"%s" is not available for purchase.', 'woocommerce' ),
$product->get_name()
),
400
);
}
/**
* Filter data for add to cart requests.
*
* @param array $request Add to cart request params.
* @return array Updated request array.
*/
protected function filter_request_data( $request ) {
$product_id = $request['id'];
$variation_id = 0;
$product = wc_get_product( $product_id );
if ( $product->is_type( 'variation' ) ) {
$product_id = $product->get_parent_id();
$variation_id = $product->get_id();
}
/**
* Filter cart item data for add to cart requests.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param array $cart_item_data Array of other cart item data.
* @param integer $product_id ID of the product added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param integer $quantity Quantity of the item added to the cart.
* @return array
*/
$request['cart_item_data'] = (array) apply_filters(
'woocommerce_add_cart_item_data',
$request['cart_item_data'],
$product_id,
$variation_id,
$request['quantity']
);
if ( $product->is_sold_individually() ) {
/**
* Filter sold individually quantity for add to cart requests.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param integer $sold_individually_quantity Defaults to 1.
* @param integer $quantity Quantity of the item added to the cart.
* @param integer $product_id ID of the product added to the cart.
* @param integer $variation_id Variation ID of the product added to the cart.
* @param array $cart_item_data Array of other cart item data.
* @return integer
*/
$request['quantity'] = apply_filters( 'woocommerce_add_to_cart_sold_individually_quantity', 1, $request['quantity'], $product_id, $variation_id, $request['cart_item_data'] );
}
return $request;
}
/**
* If variations are set, validate and format the values ready to add to the cart.
*
* @throws RouteException Exception if invalid data is detected.
*
* @param array $request Add to cart request params.
* @return array Updated request array.
*/
protected function parse_variation_data( $request ) {
$product = $this->get_product_for_cart( $request );
// Remove variation request if not needed.
if ( ! $product->is_type( array( 'variation', 'variable' ) ) ) {
$request['variation'] = [];
return $request;
}
// Flatten data and format posted values.
$variable_product_attributes = $this->get_variable_product_attributes( $product );
$request['variation'] = $this->sanitize_variation_data( wp_list_pluck( $request['variation'], 'value', 'attribute' ), $variable_product_attributes );
// If we have a parent product, find the variation ID.
if ( $product->is_type( 'variable' ) ) {
$request['id'] = $this->get_variation_id_from_variation_data( $request, $product );
}
// Now we have a variation ID, get the valid set of attributes for this variation. They will have an attribute_ prefix since they are from meta.
$expected_attributes = wc_get_product_variation_attributes( $request['id'] );
$missing_attributes = [];
foreach ( $variable_product_attributes as $attribute ) {
if ( ! $attribute['is_variation'] ) {
continue;
}
$prefixed_attribute_name = 'attribute_' . sanitize_title( $attribute['name'] );
$expected_value = isset( $expected_attributes[ $prefixed_attribute_name ] ) ? $expected_attributes[ $prefixed_attribute_name ] : '';
$attribute_label = wc_attribute_label( $attribute['name'] );
if ( isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
$given_value = $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ];
if ( $expected_value === $given_value ) {
continue;
}
// If valid values are empty, this is an 'any' variation so get all possible values.
if ( '' === $expected_value && in_array( $given_value, $attribute->get_slugs(), true ) ) {
continue;
}
throw new RouteException(
'woocommerce_rest_invalid_variation_data',
/* translators: %1$s: Attribute name, %2$s: Allowed values. */
sprintf( __( 'Invalid value posted for %1$s. Allowed values: %2$s', 'woocommerce' ), $attribute_label, implode( ', ', $attribute->get_slugs() ) ),
400
);
}
// Fills request array with unspecified attributes that have default values. This ensures the variation always has full data.
if ( '' !== $expected_value && ! isset( $request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] ) ) {
$request['variation'][ wc_variation_attribute_name( $attribute['name'] ) ] = $expected_value;
}
// If no attribute was posted, only error if the variation has an 'any' attribute which requires a value.
if ( '' === $expected_value ) {
$missing_attributes[] = $attribute_label;
}
}
if ( ! empty( $missing_attributes ) ) {
throw new RouteException(
'woocommerce_rest_missing_variation_data',
/* translators: %s: Attribute name. */
__( 'Missing variation data for variable product.', 'woocommerce' ) . ' ' . sprintf( _n( '%s is a required field', '%s are required fields', count( $missing_attributes ), 'woocommerce' ), wc_format_list_of_items( $missing_attributes ) ),
400
);
}
ksort( $request['variation'] );
return $request;
}
/**
* Try to match request data to a variation ID and return the ID.
*
* @throws RouteException Exception if variation cannot be found.
*
* @param array $request Add to cart request params.
* @param \WC_Product $product Product being added to the cart.
* @return int Matching variation ID.
*/
protected function get_variation_id_from_variation_data( $request, $product ) {
$data_store = \WC_Data_Store::load( 'product' );
$match_attributes = $request['variation'];
$variation_id = $data_store->find_matching_product_variation( $product, $match_attributes );
if ( empty( $variation_id ) ) {
throw new RouteException(
'woocommerce_rest_variation_id_from_variation_data',
__( 'No matching variation found.', 'woocommerce' ),
400
);
}
return $variation_id;
}
/**
* Format and sanitize variation data posted to the API.
*
* Labels are converted to names (e.g. Size to pa_size), and values are cleaned.
*
* @throws RouteException Exception if variation cannot be found.
*
* @param array $variation_data Key value pairs of attributes and values.
* @param array $variable_product_attributes Product attributes we're expecting.
* @return array
*/
protected function sanitize_variation_data( $variation_data, $variable_product_attributes ) {
$return = [];
foreach ( $variable_product_attributes as $attribute ) {
if ( ! $attribute['is_variation'] ) {
continue;
}
$attribute_label = wc_attribute_label( $attribute['name'] );
$variation_attribute_name = wc_variation_attribute_name( $attribute['name'] );
// Attribute labels e.g. Size.
if ( isset( $variation_data[ $attribute_label ] ) ) {
$return[ $variation_attribute_name ] =
$attribute['is_taxonomy']
?
sanitize_title( $variation_data[ $attribute_label ] )
:
html_entity_decode(
wc_clean( $variation_data[ $attribute_label ] ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
continue;
}
// Attribute slugs e.g. pa_size.
if ( isset( $variation_data[ $attribute['name'] ] ) ) {
$return[ $variation_attribute_name ] =
$attribute['is_taxonomy']
?
sanitize_title( $variation_data[ $attribute['name'] ] )
:
html_entity_decode(
wc_clean( $variation_data[ $attribute['name'] ] ),
ENT_QUOTES,
get_bloginfo( 'charset' )
);
}
}
return $return;
}
/**
* Get product attributes from the variable product (which may be the parent if the product object is a variation).
*
* @throws RouteException Exception if product is invalid.
*
* @param \WC_Product $product Product being added to the cart.
* @return array
*/
protected function get_variable_product_attributes( $product ) {
if ( $product->is_type( 'variation' ) ) {
$product = wc_get_product( $product->get_parent_id() );
}
if ( ! $product || 'trash' === $product->get_status() ) {
throw new RouteException(
'woocommerce_rest_cart_invalid_parent_product',
__( 'This product cannot be added to the cart.', 'woocommerce' ),
400
);
}
return $product->get_attributes();
}
}
CheckoutTrait.php 0000644 00000014754 15155613053 0010045 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Payments\PaymentContext;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
/**
* CheckoutTrait
*
* Shared functionality for checkout route.
*/
trait CheckoutTrait {
/**
* Prepare a single item for response. Handles setting the status based on the payment result.
*
* @param mixed $item Item to format to schema.
* @param \WP_REST_Request $request Request object.
* @return \WP_REST_Response $response Response data.
*/
public function prepare_item_for_response( $item, \WP_REST_Request $request ) {
$response = parent::prepare_item_for_response( $item, $request );
$status_codes = [
'success' => 200,
'pending' => 202,
'failure' => 400,
'error' => 500,
];
if ( isset( $item->payment_result ) && $item->payment_result instanceof PaymentResult ) {
$response->set_status( $status_codes[ $item->payment_result->status ] ?? 200 );
}
return $response;
}
/**
* For orders which do not require payment, just update status.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
*/
private function process_without_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
// Transition the order to pending, and then completed. This ensures transactional emails fire for pending_to_complete events.
$this->order->update_status( 'pending' );
$this->order->payment_complete();
// Mark the payment as successful.
$payment_result->set_status( 'success' );
$payment_result->set_redirect_url( $this->order->get_checkout_order_received_url() );
}
/**
* Fires an action hook instructing active payment gateways to process the payment for an order and provide a result.
*
* @throws RouteException On error.
*
* @param \WP_REST_Request $request Request object.
* @param PaymentResult $payment_result Payment result object.
*/
private function process_payment( \WP_REST_Request $request, PaymentResult $payment_result ) {
try {
// Transition the order to pending before making payment.
$this->order->update_status( 'pending' );
// Prepare the payment context object to pass through payment hooks.
$context = new PaymentContext();
$context->set_payment_method( $this->get_request_payment_method_id( $request ) );
$context->set_payment_data( $this->get_request_payment_data( $request ) );
$context->set_order( $this->order );
/**
* Process payment with context.
*
* @hook woocommerce_rest_checkout_process_payment_with_context
*
* @throws \Exception If there is an error taking payment, an \Exception object can be thrown with an error message.
*
* @param PaymentContext $context Holds context for the payment, including order ID and payment method.
* @param PaymentResult $payment_result Result object for the transaction.
*/
do_action_ref_array( 'woocommerce_rest_checkout_process_payment_with_context', [ $context, &$payment_result ] );
if ( ! $payment_result instanceof PaymentResult ) {
throw new RouteException( 'woocommerce_rest_checkout_invalid_payment_result', __( 'Invalid payment result received from payment method.', 'woocommerce' ), 500 );
}
} catch ( \Exception $e ) {
throw new RouteException( 'woocommerce_rest_checkout_process_payment_error', $e->getMessage(), 402 );
}
}
/**
* Gets the chosen payment method ID from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return string
*/
private function get_request_payment_method_id( \WP_REST_Request $request ) {
$payment_method = $this->get_request_payment_method( $request );
return is_null( $payment_method ) ? '' : $payment_method->id;
}
/**
* Gets and formats payment request data.
*
* @param \WP_REST_Request $request Request object.
* @return array
*/
private function get_request_payment_data( \WP_REST_Request $request ) {
static $payment_data = [];
if ( ! empty( $payment_data ) ) {
return $payment_data;
}
if ( ! empty( $request['payment_data'] ) ) {
foreach ( $request['payment_data'] as $data ) {
$payment_data[ sanitize_key( $data['key'] ) ] = wc_clean( $data['value'] );
}
}
return $payment_data;
}
/**
* Update the current order using the posted values from the request.
*
* @param \WP_REST_Request $request Full details about the request.
*/
private function update_order_from_request( \WP_REST_Request $request ) {
$this->order->set_customer_note( $request['customer_note'] ?? '' );
$this->order->set_payment_method( $this->get_request_payment_method_id( $request ) );
$this->order->set_payment_method_title( $this->get_request_payment_method_title( $request ) );
wc_do_deprecated_action(
'__experimental_woocommerce_blocks_checkout_update_order_from_request',
array(
$this->order,
$request,
),
'6.3.0',
'woocommerce_store_api_checkout_update_order_from_request',
'This action was deprecated in WooCommerce Blocks version 6.3.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
);
wc_do_deprecated_action(
'woocommerce_blocks_checkout_update_order_from_request',
array(
$this->order,
$request,
),
'7.2.0',
'woocommerce_store_api_checkout_update_order_from_request',
'This action was deprecated in WooCommerce Blocks version 7.2.0. Please use woocommerce_store_api_checkout_update_order_from_request instead.'
);
/**
* Fires when the Checkout Block/Store API updates an order's from the API request data.
*
* This hook gives extensions the chance to update orders based on the data in the request. This can be used in
* conjunction with the ExtendSchema class to post custom data and then process it.
*
* @since 7.2.0
*
* @param \WC_Order $order Order object.
* @param \WP_REST_Request $request Full details about the request.
*/
do_action( 'woocommerce_store_api_checkout_update_order_from_request', $this->order, $request );
$this->order->save();
}
/**
* Gets the chosen payment method title from the request.
*
* @throws RouteException On error.
* @param \WP_REST_Request $request Request object.
* @return string
*/
private function get_request_payment_method_title( \WP_REST_Request $request ) {
$payment_method = $this->get_request_payment_method( $request );
return is_null( $payment_method ) ? '' : $payment_method->get_title();
}
}
DraftOrderTrait.php 0000644 00000003417 15155613053 0010326 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* DraftOrderTrait
*
* Shared functionality for getting and setting draft order IDs from session.
*/
trait DraftOrderTrait {
/**
* Gets draft order data from the customer session.
*
* @return integer
*/
protected function get_draft_order_id() {
if ( ! wc()->session ) {
wc()->initialize_session();
}
return wc()->session->get( 'store_api_draft_order', 0 );
}
/**
* Updates draft order data in the customer session.
*
* @param integer $order_id Draft order ID.
*/
protected function set_draft_order_id( $order_id ) {
if ( ! wc()->session ) {
wc()->initialize_session();
}
wc()->session->set( 'store_api_draft_order', $order_id );
}
/**
* Uses the draft order ID to return an order object, if valid.
*
* @return \WC_Order|null;
*/
protected function get_draft_order() {
$draft_order_id = $this->get_draft_order_id();
$draft_order = $draft_order_id ? wc_get_order( $draft_order_id ) : false;
return $this->is_valid_draft_order( $draft_order ) ? $draft_order : null;
}
/**
* Whether the passed argument is a draft order or an order that is
* pending/failed and the cart hasn't changed.
*
* @param \WC_Order $order_object Order object to check.
* @return boolean Whether the order is valid as a draft order.
*/
protected function is_valid_draft_order( $order_object ) {
if ( ! $order_object instanceof \WC_Order ) {
return false;
}
// Draft orders are okay.
if ( $order_object->has_status( 'checkout-draft' ) ) {
return true;
}
// Pending and failed orders can be retried if the cart hasn't changed.
if ( $order_object->needs_payment() && $order_object->has_cart_hash( wc()->cart->get_cart_hash() ) ) {
return true;
}
return false;
}
}
JsonWebToken.php 0000644 00000012040 15155613053 0007626 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* JsonWebToken class.
*
* Simple Json Web Token generator & verifier static utility class, currently supporting only HS256 signatures.
*/
final class JsonWebToken {
/**
* JWT header type.
*
* @var string
*/
private static $type = 'JWT';
/**
* JWT algorithm to generate signature.
*
* @var string
*/
private static $algorithm = 'HS256';
/**
* Generates a token from provided data and secret.
*
* @param array $payload Payload data.
* @param string $secret The secret used to generate the signature.
*
* @return string
*/
public static function create( array $payload, string $secret ) {
$header = self::to_base_64_url( self::generate_header() );
$payload = self::to_base_64_url( self::generate_payload( $payload ) );
$signature = self::to_base_64_url( self::generate_signature( $header . '.' . $payload, $secret ) );
return $header . '.' . $payload . '.' . $signature;
}
/**
* Validates a provided token against the provided secret.
* Checks for format, valid header for our class, expiration claim validity and signature.
* https://datatracker.ietf.org/doc/html/rfc7519#section-7.2
*
* @param string $token Full token string.
* @param string $secret The secret used to generate the signature.
*
* @return bool
*/
public static function validate( string $token, string $secret ) {
/**
* Confirm the structure of a JSON Web Token, it has three parts separated
* by dots and complies with Base64URL standards.
*/
if ( preg_match( '/^[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+$/', $token ) !== 1 ) {
return false;
}
$parts = self::get_parts( $token );
/**
* Check if header declares a supported JWT by this class.
*/
if (
! is_object( $parts->header ) ||
! property_exists( $parts->header, 'typ' ) ||
! property_exists( $parts->header, 'alg' ) ||
self::$type !== $parts->header->typ ||
self::$algorithm !== $parts->header->alg
) {
return false;
}
/**
* Check if token is expired.
*/
if ( ! property_exists( $parts->payload, 'exp' ) || time() > (int) $parts->payload->exp ) {
return false;
}
/**
* Check if the token is based on our secret.
*/
$encoded_regenerated_signature = self::to_base_64_url(
self::generate_signature( $parts->header_encoded . '.' . $parts->payload_encoded, $secret )
);
return hash_equals( $encoded_regenerated_signature, $parts->signature_encoded );
}
/**
* Returns the decoded/encoded header, payload and signature from a token string.
*
* @param string $token Full token string.
*
* @return object
*/
public static function get_parts( string $token ) {
$parts = explode( '.', $token );
return (object) array(
'header' => json_decode( self::from_base_64_url( $parts[0] ) ),
'header_encoded' => $parts[0],
'payload' => json_decode( self::from_base_64_url( $parts[1] ) ),
'payload_encoded' => $parts[1],
'signature' => self::from_base_64_url( $parts[2] ),
'signature_encoded' => $parts[2],
);
}
/**
* Generates the json formatted header for our HS256 JWT token.
*
* @return string|bool
*/
private static function generate_header() {
return wp_json_encode(
array(
'alg' => self::$algorithm,
'typ' => self::$type,
)
);
}
/**
* Generates a sha256 signature for the provided string using the provided secret.
*
* @param string $string Header + Payload token substring.
* @param string $secret The secret used to generate the signature.
*
* @return false|string
*/
private static function generate_signature( string $string, string $secret ) {
return hash_hmac(
'sha256',
$string,
$secret,
true
);
}
/**
* Generates the payload in json formatted string.
*
* @param array $payload Payload data.
*
* @return string|bool
*/
private static function generate_payload( array $payload ) {
return wp_json_encode( array_merge( $payload, [ 'iat' => time() ] ) );
}
/**
* Encodes a string to url safe base64.
*
* @param string $string The string to be encoded.
*
* @return string
*/
private static function to_base_64_url( string $string ) {
return str_replace(
array( '+', '/', '=' ),
array( '-', '_', '' ),
base64_encode( $string ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
);
}
/**
* Decodes a string encoded using url safe base64, supporting auto padding.
*
* @param string $string the string to be decoded.
*
* @return string
*/
private static function from_base_64_url( string $string ) {
/**
* Add padding to base64 strings which require it. Some base64 URL strings
* which are decoded will have missing padding which is represented by the
* equals sign.
*/
if ( strlen( $string ) % 4 !== 0 ) {
return self::from_base_64_url( $string . '=' );
}
return base64_decode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
str_replace(
array( '-', '_' ),
array( '+', '/' ),
$string
)
);
}
}
LocalPickupUtils.php 0000644 00000002574 15155613054 0010521 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* Util class for local pickup related functionality, this contains methods that need to be accessed from places besides
* the ShippingController, i.e. the OrderController.
*/
class LocalPickupUtils {
/**
* Checks if WC Blocks local pickup is enabled.
*
* @return bool True if local pickup is enabled.
*/
public static function is_local_pickup_enabled() {
$pickup_location_settings = get_option( 'woocommerce_pickup_location_settings', [] );
return wc_string_to_bool( $pickup_location_settings['enabled'] ?? 'no' );
}
/**
* Gets a list of payment method ids that support the 'local-pickup' feature.
*
* @return string[] List of payment method ids that support the 'local-pickup' feature.
*/
public static function get_local_pickup_method_ids() {
$all_methods_supporting_local_pickup = array_reduce(
WC()->shipping()->get_shipping_methods(),
function( $methods, $method ) {
if ( $method->supports( 'local-pickup' ) ) {
$methods[] = $method->id;
}
return $methods;
},
array()
);
// We use array_values because this will be used in JS, so we don't need the (numerical) keys.
return array_values(
// This array_unique is necessary because WC()->shipping()->get_shipping_methods() can return duplicates.
array_unique(
$all_methods_supporting_local_pickup
)
);
}
}
NoticeHandler.php 0000644 00000004165 15155613054 0010007 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use WP_Error;
/**
* NoticeHandler class.
* Helper class to handle notices.
*/
class NoticeHandler {
/**
* Convert queued error notices into an exception.
*
* For example, Payment methods may add error notices during validate_fields call to prevent checkout.
* Since we're not rendering notices at all, we need to convert them to exceptions.
*
* This method will find the first error message and thrown an exception instead. Discards notices once complete.
*
* @throws RouteException If an error notice is detected, Exception is thrown.
*
* @param string $error_code Error code for the thrown exceptions.
*/
public static function convert_notices_to_exceptions( $error_code = 'unknown_server_error' ) {
if ( 0 === wc_notice_count( 'error' ) ) {
wc_clear_notices();
return;
}
$error_notices = wc_get_notices( 'error' );
// Prevent notices from being output later on.
wc_clear_notices();
foreach ( $error_notices as $error_notice ) {
throw new RouteException( $error_code, wp_strip_all_tags( $error_notice['notice'] ), 400 );
}
}
/**
* Collects queued error notices into a \WP_Error.
*
* For example, cart validation processes may add error notices to prevent checkout.
* Since we're not rendering notices at all, we need to catch them and group them in a single WP_Error instance.
*
* This method will discard notices once complete.
*
* @param string $error_code Error code for the thrown exceptions.
*
* @return \WP_Error The WP_Error object containing all error notices.
*/
public static function convert_notices_to_wp_errors( $error_code = 'unknown_server_error' ) {
$errors = new WP_Error();
if ( 0 === wc_notice_count( 'error' ) ) {
wc_clear_notices();
return $errors;
}
$error_notices = wc_get_notices( 'error' );
// Prevent notices from being output later on.
wc_clear_notices();
foreach ( $error_notices as $error_notice ) {
$errors->add( $error_code, wp_strip_all_tags( $error_notice['notice'] ) );
}
return $errors;
}
}
OrderAuthorizationTrait.php 0000644 00000004352 15155613054 0012126 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* OrderAuthorizationTrait
*
* Shared functionality for getting order authorization.
*/
trait OrderAuthorizationTrait {
/**
* Check if authorized to get the order.
*
* @throws RouteException If the order is not found or the order key is invalid.
*
* @param \WP_REST_Request $request Request object.
* @return boolean|WP_Error
*/
public function is_authorized( \WP_REST_Request $request ) {
$order_id = absint( $request['id'] );
$order_key = sanitize_text_field( wp_unslash( $request->get_param( 'key' ) ) );
$billing_email = sanitize_text_field( wp_unslash( $request->get_param( 'billing_email' ) ) );
try {
// In this context, pay_for_order capability checks that the current user ID matches the customer ID stored
// within the order, or if the order was placed by a guest.
// See https://github.com/woocommerce/woocommerce/blob/abcedbefe02f9e89122771100c42ff588da3e8e0/plugins/woocommerce/includes/wc-user-functions.php#L458.
if ( ! current_user_can( 'pay_for_order', $order_id ) ) {
throw new RouteException( 'woocommerce_rest_invalid_user', __( 'This order belongs to a different customer.', 'woocommerce' ), 403 );
}
if ( get_current_user_id() === 0 ) {
$this->order_controller->validate_order_key( $order_id, $order_key );
$this->validate_billing_email_matches_order( $order_id, $billing_email );
}
} catch ( RouteException $error ) {
return new \WP_Error(
$error->getErrorCode(),
$error->getMessage(),
array( 'status' => $error->getCode() )
);
}
return true;
}
/**
* Validate a given billing email against an existing order.
*
* @throws RouteException Exception if invalid data is detected.
* @param integer $order_id Order ID.
* @param string $billing_email Billing email.
*/
public function validate_billing_email_matches_order( $order_id, $billing_email ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! $billing_email || $order->get_billing_email() !== $billing_email ) {
throw new RouteException( 'woocommerce_rest_invalid_billing_email', __( 'Invalid billing email provided.', 'woocommerce' ), 401 );
}
}
}
OrderController.php 0000644 00000056121 15155613055 0010407 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use \Exception;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
/**
* OrderController class.
* Helper class which creates and syncs orders with the cart.
*/
class OrderController {
/**
* Create order and set props based on global settings.
*
* @throws RouteException Exception if invalid data is detected.
*
* @return \WC_Order A new order object.
*/
public function create_order_from_cart() {
if ( wc()->cart->is_empty() ) {
throw new RouteException(
'woocommerce_rest_cart_empty',
__( 'Cannot create order from empty cart.', 'woocommerce' ),
400
);
}
add_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
$order = new \WC_Order();
$order->set_status( 'checkout-draft' );
$order->set_created_via( 'store-api' );
$this->update_order_from_cart( $order );
remove_filter( 'woocommerce_default_order_status', array( $this, 'default_order_status' ) );
return $order;
}
/**
* Update an order using data from the current cart.
*
* @param \WC_Order $order The order object to update.
* @param boolean $update_totals Whether to update totals or not.
*/
public function update_order_from_cart( \WC_Order $order, $update_totals = true ) {
/**
* This filter ensures that local pickup locations are still used for order taxes by forcing the address used to
* calculate tax for an order to match the current address of the customer.
*
* - The method `$customer->get_taxable_address()` runs the filter `woocommerce_customer_taxable_address`.
* - While we have a session, our `ShippingController::filter_taxable_address` function uses this hook to set
* the customer address to the pickup location address if local pickup is the chosen method.
*
* Without this code in place, `$customer->get_taxable_address()` is not used when order taxes are calculated,
* resulting in the wrong taxes being applied with local pickup.
*
* The alternative would be to instead use `woocommerce_order_get_tax_location` to return the pickup location
* address directly, however since we have the customer filter in place we don't need to duplicate effort.
*
* @see \WC_Abstract_Order::get_tax_location()
*/
add_filter(
'woocommerce_order_get_tax_location',
function( $location ) {
if ( ! is_null( wc()->customer ) ) {
$taxable_address = wc()->customer->get_taxable_address();
$location = array(
'country' => $taxable_address[0],
'state' => $taxable_address[1],
'postcode' => $taxable_address[2],
'city' => $taxable_address[3],
);
}
return $location;
}
);
// Ensure cart is current.
if ( $update_totals ) {
wc()->cart->calculate_shipping();
wc()->cart->calculate_totals();
}
// Update the current order to match the current cart.
$this->update_line_items_from_cart( $order );
$this->update_addresses_from_cart( $order );
$order->set_currency( get_woocommerce_currency() );
$order->set_prices_include_tax( 'yes' === get_option( 'woocommerce_prices_include_tax' ) );
$order->set_customer_id( get_current_user_id() );
$order->set_customer_ip_address( \WC_Geolocation::get_ip_address() );
$order->set_customer_user_agent( wc_get_user_agent() );
$order->update_meta_data( 'is_vat_exempt', wc()->cart->get_customer()->get_is_vat_exempt() ? 'yes' : 'no' );
$order->calculate_totals();
}
/**
* Copies order data to customer object (not the session), so values persist for future checkouts.
*
* @param \WC_Order $order Order object.
*/
public function sync_customer_data_with_order( \WC_Order $order ) {
if ( $order->get_customer_id() ) {
$customer = new \WC_Customer( $order->get_customer_id() );
$customer->set_props(
[
'billing_first_name' => $order->get_billing_first_name(),
'billing_last_name' => $order->get_billing_last_name(),
'billing_company' => $order->get_billing_company(),
'billing_address_1' => $order->get_billing_address_1(),
'billing_address_2' => $order->get_billing_address_2(),
'billing_city' => $order->get_billing_city(),
'billing_state' => $order->get_billing_state(),
'billing_postcode' => $order->get_billing_postcode(),
'billing_country' => $order->get_billing_country(),
'billing_email' => $order->get_billing_email(),
'billing_phone' => $order->get_billing_phone(),
'shipping_first_name' => $order->get_shipping_first_name(),
'shipping_last_name' => $order->get_shipping_last_name(),
'shipping_company' => $order->get_shipping_company(),
'shipping_address_1' => $order->get_shipping_address_1(),
'shipping_address_2' => $order->get_shipping_address_2(),
'shipping_city' => $order->get_shipping_city(),
'shipping_state' => $order->get_shipping_state(),
'shipping_postcode' => $order->get_shipping_postcode(),
'shipping_country' => $order->get_shipping_country(),
'shipping_phone' => $order->get_shipping_phone(),
]
);
$customer->save();
};
}
/**
* Final validation ran before payment is taken.
*
* By this point we have an order populated with customer data and items.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
public function validate_order_before_payment( \WC_Order $order ) {
$needs_shipping = wc()->cart->needs_shipping();
$chosen_shipping_methods = wc()->session->get( 'chosen_shipping_methods' );
$this->validate_coupons( $order );
$this->validate_email( $order );
$this->validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods );
$this->validate_addresses( $order );
}
/**
* Convert a coupon code to a coupon object.
*
* @param string $coupon_code Coupon code.
* @return \WC_Coupon Coupon object.
*/
protected function get_coupon( $coupon_code ) {
return new \WC_Coupon( $coupon_code );
}
/**
* Validate coupons applied to the order and remove those that are not valid.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
protected function validate_coupons( \WC_Order $order ) {
$coupon_codes = $order->get_coupon_codes();
$coupons = array_filter( array_map( [ $this, 'get_coupon' ], $coupon_codes ) );
$validators = [ 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' ];
$coupon_errors = [];
foreach ( $coupons as $coupon ) {
try {
array_walk(
$validators,
function( $validator, $index, $params ) {
call_user_func_array( [ $this, $validator ], $params );
},
[ $coupon, $order ]
);
} catch ( Exception $error ) {
$coupon_errors[ $coupon->get_code() ] = $error->getMessage();
}
}
if ( $coupon_errors ) {
// Remove all coupons that were not valid.
foreach ( $coupon_errors as $coupon_code => $message ) {
wc()->cart->remove_coupon( $coupon_code );
}
// Recalculate totals.
wc()->cart->calculate_totals();
// Re-sync order with cart.
$this->update_order_from_cart( $order );
// Return exception so customer can review before payment.
throw new RouteException(
'woocommerce_rest_cart_coupon_errors',
sprintf(
/* translators: %s Coupon codes. */
__( 'Invalid coupons were removed from the cart: "%s"', 'woocommerce' ),
implode( '", "', array_keys( $coupon_errors ) )
),
409,
[
'removed_coupons' => $coupon_errors,
]
);
}
}
/**
* Validates the customer email. This is a required field.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
protected function validate_email( \WC_Order $order ) {
$email = $order->get_billing_email();
if ( empty( $email ) ) {
throw new RouteException(
'woocommerce_rest_missing_email_address',
__( 'A valid email address is required', 'woocommerce' ),
400
);
}
if ( ! is_email( $email ) ) {
throw new RouteException(
'woocommerce_rest_invalid_email_address',
sprintf(
/* translators: %s provided email. */
__( 'The provided email address (%s) is not valid—please provide a valid email address', 'woocommerce' ),
esc_html( $email )
),
400
);
}
}
/**
* Validates customer address data based on the locale to ensure required fields are set.
*
* @throws RouteException Exception if invalid data is detected.
* @param \WC_Order $order Order object.
*/
protected function validate_addresses( \WC_Order $order ) {
$errors = new \WP_Error();
$needs_shipping = wc()->cart->needs_shipping();
$billing_address = $order->get_address( 'billing' );
$shipping_address = $order->get_address( 'shipping' );
if ( $needs_shipping && ! $this->validate_allowed_country( $shipping_address['country'], (array) wc()->countries->get_shipping_countries() ) ) {
throw new RouteException(
'woocommerce_rest_invalid_address_country',
sprintf(
/* translators: %s country code. */
__( 'Sorry, we do not ship orders to the provided country (%s)', 'woocommerce' ),
$shipping_address['country']
),
400,
[
'allowed_countries' => array_keys( wc()->countries->get_shipping_countries() ),
]
);
}
if ( ! $this->validate_allowed_country( $billing_address['country'], (array) wc()->countries->get_allowed_countries() ) ) {
throw new RouteException(
'woocommerce_rest_invalid_address_country',
sprintf(
/* translators: %s country code. */
__( 'Sorry, we do not allow orders from the provided country (%s)', 'woocommerce' ),
$billing_address['country']
),
400,
[
'allowed_countries' => array_keys( wc()->countries->get_allowed_countries() ),
]
);
}
if ( $needs_shipping ) {
$this->validate_address_fields( $shipping_address, 'shipping', $errors );
}
$this->validate_address_fields( $billing_address, 'billing', $errors );
if ( ! $errors->has_errors() ) {
return;
}
$errors_by_code = [];
$error_codes = $errors->get_error_codes();
foreach ( $error_codes as $code ) {
$errors_by_code[ $code ] = $errors->get_error_messages( $code );
}
// Surface errors from first code.
foreach ( $errors_by_code as $code => $error_messages ) {
throw new RouteException(
'woocommerce_rest_invalid_address',
sprintf(
/* translators: %s Address type. */
__( 'There was a problem with the provided %s:', 'woocommerce' ) . ' ' . implode( ', ', $error_messages ),
'shipping' === $code ? __( 'shipping address', 'woocommerce' ) : __( 'billing address', 'woocommerce' )
),
400,
[
'errors' => $errors_by_code,
]
);
}
}
/**
* Check all required address fields are set and return errors if not.
*
* @param string $country Country code.
* @param array $allowed_countries List of valid country codes.
* @return boolean True if valid.
*/
protected function validate_allowed_country( $country, array $allowed_countries ) {
return array_key_exists( $country, $allowed_countries );
}
/**
* Check all required address fields are set and return errors if not.
*
* @param array $address Address array.
* @param string $address_type billing or shipping address, used in error messages.
* @param \WP_Error $errors Error object.
*/
protected function validate_address_fields( $address, $address_type, \WP_Error $errors ) {
$all_locales = wc()->countries->get_country_locale();
$current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : [];
/**
* We are not using wc()->counties->get_default_address_fields() here because that is filtered. Instead, this array
* is based on assets/js/base/components/cart-checkout/address-form/default-address-fields.js
*/
$address_fields = [
'first_name' => [
'label' => __( 'First name', 'woocommerce' ),
'required' => true,
],
'last_name' => [
'label' => __( 'Last name', 'woocommerce' ),
'required' => true,
],
'company' => [
'label' => __( 'Company', 'woocommerce' ),
'required' => false,
],
'address_1' => [
'label' => __( 'Address', 'woocommerce' ),
'required' => true,
],
'address_2' => [
'label' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'required' => false,
],
'country' => [
'label' => __( 'Country/Region', 'woocommerce' ),
'required' => true,
],
'city' => [
'label' => __( 'City', 'woocommerce' ),
'required' => true,
],
'state' => [
'label' => __( 'State/County', 'woocommerce' ),
'required' => true,
],
'postcode' => [
'label' => __( 'Postal code', 'woocommerce' ),
'required' => true,
],
];
if ( $current_locale ) {
foreach ( $current_locale as $key => $field ) {
if ( isset( $address_fields[ $key ] ) ) {
$address_fields[ $key ]['label'] = isset( $field['label'] ) ? $field['label'] : $address_fields[ $key ]['label'];
$address_fields[ $key ]['required'] = isset( $field['required'] ) ? $field['required'] : $address_fields[ $key ]['required'];
}
}
}
foreach ( $address_fields as $address_field_key => $address_field ) {
if ( empty( $address[ $address_field_key ] ) && $address_field['required'] ) {
/* translators: %s Field label. */
$errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key );
}
}
}
/**
* Check email restrictions of a coupon against the order.
*
* @throws Exception Exception if invalid data is detected.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param \WC_Order $order Order object.
*/
protected function validate_coupon_email_restriction( \WC_Coupon $coupon, \WC_Order $order ) {
$restrictions = $coupon->get_email_restrictions();
if ( ! empty( $restrictions ) && $order->get_billing_email() && ! wc()->cart->is_coupon_emails_allowed( [ $order->get_billing_email() ], $restrictions ) ) {
throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ) );
}
}
/**
* Check usage restrictions of a coupon against the order.
*
* @throws Exception Exception if invalid data is detected.
* @param \WC_Coupon $coupon Coupon object applied to the cart.
* @param \WC_Order $order Order object.
*/
protected function validate_coupon_usage_limit( \WC_Coupon $coupon, \WC_Order $order ) {
$coupon_usage_limit = $coupon->get_usage_limit_per_user();
if ( $coupon_usage_limit > 0 ) {
$data_store = $coupon->get_data_store();
$usage_count = $order->get_customer_id() ? $data_store->get_usage_by_user_id( $coupon, $order->get_customer_id() ) : $data_store->get_usage_by_email( $coupon, $order->get_billing_email() );
if ( $usage_count >= $coupon_usage_limit ) {
throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_USAGE_LIMIT_REACHED ) );
}
}
}
/**
* Check there is a shipping method if it requires shipping.
*
* @throws RouteException Exception if invalid data is detected.
* @param boolean $needs_shipping Current order needs shipping.
* @param array $chosen_shipping_methods Array of shipping methods.
*/
public function validate_selected_shipping_methods( $needs_shipping, $chosen_shipping_methods = array() ) {
if ( ! $needs_shipping || ! is_array( $chosen_shipping_methods ) ) {
return;
}
foreach ( $chosen_shipping_methods as $chosen_shipping_method ) {
if ( false === $chosen_shipping_method ) {
throw new RouteException(
'woocommerce_rest_invalid_shipping_option',
__( 'Sorry, this order requires a shipping option.', 'woocommerce' ),
400,
[]
);
}
}
}
/**
* Validate a given order key against an existing order.
*
* @throws RouteException Exception if invalid data is detected.
* @param integer $order_id Order ID.
* @param string $order_key Order key.
*/
public function validate_order_key( $order_id, $order_key ) {
$order = wc_get_order( $order_id );
if ( ! $order || ! $order_key || $order->get_id() !== $order_id || ! hash_equals( $order->get_order_key(), $order_key ) ) {
throw new RouteException( 'woocommerce_rest_invalid_order', __( 'Invalid order ID or key provided.', 'woocommerce' ), 401 );
}
}
/**
* Get errors for order stock on failed orders.
*
* @throws RouteException Exception if invalid data is detected.
* @param integer $order_id Order ID.
*/
public function get_failed_order_stock_error( $order_id ) {
$order = wc_get_order( $order_id );
// Ensure order items are still stocked if paying for a failed order. Pending orders do not need this check because stock is held.
if ( ! $order->has_status( wc_get_is_pending_statuses() ) ) {
$quantities = array();
foreach ( $order->get_items() as $item_key => $item ) {
if ( $item && is_callable( array( $item, 'get_product' ) ) ) {
$product = $item->get_product();
if ( ! $product ) {
continue;
}
$quantities[ $product->get_stock_managed_by_id() ] = isset( $quantities[ $product->get_stock_managed_by_id() ] ) ? $quantities[ $product->get_stock_managed_by_id() ] + $item->get_quantity() : $item->get_quantity();
}
}
// Stock levels may already have been adjusted for this order (in which case we don't need to worry about checking for low stock).
if ( ! $order->get_data_store()->get_stock_reduced( $order->get_id() ) ) {
foreach ( $order->get_items() as $item_key => $item ) {
if ( $item && is_callable( array( $item, 'get_product' ) ) ) {
$product = $item->get_product();
if ( ! $product ) {
continue;
}
/**
* Filters whether or not the product is in stock for this pay for order.
*
* @param boolean True if in stock.
* @param \WC_Product $product Product.
* @param \WC_Order $order Order.
*
* @since 9.8.0-dev
*/
if ( ! apply_filters( 'woocommerce_pay_order_product_in_stock', $product->is_in_stock(), $product, $order ) ) {
return array(
'code' => 'woocommerce_rest_out_of_stock',
/* translators: %s: product name */
'message' => sprintf( __( 'Sorry, "%s" is no longer in stock so this order cannot be paid for. We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name() ),
);
}
// We only need to check products managing stock, with a limited stock qty.
if ( ! $product->managing_stock() || $product->backorders_allowed() ) {
continue;
}
// Check stock based on all items in the cart and consider any held stock within pending orders.
$held_stock = wc_get_held_stock_quantity( $product, $order->get_id() );
$required_stock = $quantities[ $product->get_stock_managed_by_id() ];
/**
* Filters whether or not the product has enough stock.
*
* @param boolean True if has enough stock.
* @param \WC_Product $product Product.
* @param \WC_Order $order Order.
*
* @since 9.8.0-dev
*/
if ( ! apply_filters( 'woocommerce_pay_order_product_has_enough_stock', ( $product->get_stock_quantity() >= ( $held_stock + $required_stock ) ), $product, $order ) ) {
/* translators: 1: product name 2: quantity in stock */
return array(
'code' => 'woocommerce_rest_out_of_stock',
/* translators: %s: product name */
'message' => sprintf( __( 'Sorry, we do not have enough "%1$s" in stock to fulfill your order (%2$s available). We apologize for any inconvenience caused.', 'woocommerce' ), $product->get_name(), wc_format_stock_quantity_for_display( $product->get_stock_quantity() - $held_stock, $product ) ),
);
}
}
}
}
}
return null;
}
/**
* Changes default order status to draft for orders created via this API.
*
* @return string
*/
public function default_order_status() {
return 'checkout-draft';
}
/**
* Create order line items.
*
* @param \WC_Order $order The order object to update.
*/
protected function update_line_items_from_cart( \WC_Order $order ) {
$cart_controller = new CartController();
$cart = $cart_controller->get_cart_instance();
$cart_hashes = $cart_controller->get_cart_hashes();
if ( $order->get_cart_hash() !== $cart_hashes['line_items'] ) {
$order->set_cart_hash( $cart_hashes['line_items'] );
$order->remove_order_items( 'line_item' );
wc()->checkout->create_order_line_items( $order, $cart );
}
if ( $order->get_meta_data( '_shipping_hash' ) !== $cart_hashes['shipping'] ) {
$order->update_meta_data( '_shipping_hash', $cart_hashes['shipping'] );
$order->remove_order_items( 'shipping' );
wc()->checkout->create_order_shipping_lines( $order, wc()->session->get( 'chosen_shipping_methods' ), wc()->shipping()->get_packages() );
}
if ( $order->get_meta_data( '_coupons_hash' ) !== $cart_hashes['coupons'] ) {
$order->remove_order_items( 'coupon' );
$order->update_meta_data( '_coupons_hash', $cart_hashes['coupons'] );
wc()->checkout->create_order_coupon_lines( $order, $cart );
}
if ( $order->get_meta_data( '_fees_hash' ) !== $cart_hashes['fees'] ) {
$order->update_meta_data( '_fees_hash', $cart_hashes['fees'] );
$order->remove_order_items( 'fee' );
wc()->checkout->create_order_fee_lines( $order, $cart );
}
if ( $order->get_meta_data( '_taxes_hash' ) !== $cart_hashes['taxes'] ) {
$order->update_meta_data( '_taxes_hash', $cart_hashes['taxes'] );
$order->remove_order_items( 'tax' );
wc()->checkout->create_order_tax_lines( $order, $cart );
}
}
/**
* Update address data from cart and/or customer session data.
*
* @param \WC_Order $order The order object to update.
*/
protected function update_addresses_from_cart( \WC_Order $order ) {
$order->set_props(
[
'billing_first_name' => wc()->customer->get_billing_first_name(),
'billing_last_name' => wc()->customer->get_billing_last_name(),
'billing_company' => wc()->customer->get_billing_company(),
'billing_address_1' => wc()->customer->get_billing_address_1(),
'billing_address_2' => wc()->customer->get_billing_address_2(),
'billing_city' => wc()->customer->get_billing_city(),
'billing_state' => wc()->customer->get_billing_state(),
'billing_postcode' => wc()->customer->get_billing_postcode(),
'billing_country' => wc()->customer->get_billing_country(),
'billing_email' => wc()->customer->get_billing_email(),
'billing_phone' => wc()->customer->get_billing_phone(),
'shipping_first_name' => wc()->customer->get_shipping_first_name(),
'shipping_last_name' => wc()->customer->get_shipping_last_name(),
'shipping_company' => wc()->customer->get_shipping_company(),
'shipping_address_1' => wc()->customer->get_shipping_address_1(),
'shipping_address_2' => wc()->customer->get_shipping_address_2(),
'shipping_city' => wc()->customer->get_shipping_city(),
'shipping_state' => wc()->customer->get_shipping_state(),
'shipping_postcode' => wc()->customer->get_shipping_postcode(),
'shipping_country' => wc()->customer->get_shipping_country(),
'shipping_phone' => wc()->customer->get_shipping_phone(),
]
);
}
}
Pagination.php 0000644 00000004064 15155613056 0007361 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* Pagination class.
*/
class Pagination {
/**
* Add pagination headers to a response object.
*
* @param \WP_REST_Response $response Reference to the response object.
* @param \WP_REST_Request $request The request object.
* @param int $total_items Total items found.
* @param int $total_pages Total pages found.
* @return \WP_REST_Response
*/
public function add_headers( $response, $request, $total_items, $total_pages ) {
$response->header( 'X-WP-Total', $total_items );
$response->header( 'X-WP-TotalPages', $total_pages );
$current_page = $this->get_current_page( $request );
$link_base = $this->get_link_base( $request );
if ( $current_page > 1 ) {
$previous_page = $current_page - 1;
if ( $previous_page > $total_pages ) {
$previous_page = $total_pages;
}
$this->add_page_link( $response, 'prev', $previous_page, $link_base );
}
if ( $total_pages > $current_page ) {
$this->add_page_link( $response, 'next', ( $current_page + 1 ), $link_base );
}
return $response;
}
/**
* Get current page.
*
* @param \WP_REST_Request $request The request object.
* @return int Get the page from the request object.
*/
protected function get_current_page( $request ) {
return (int) $request->get_param( 'page' );
}
/**
* Get base for links from the request object.
*
* @param \WP_REST_Request $request The request object.
* @return string
*/
protected function get_link_base( $request ) {
return esc_url( add_query_arg( $request->get_query_params(), rest_url( $request->get_route() ) ) );
}
/**
* Add a page link.
*
* @param \WP_REST_Response $response Reference to the response object.
* @param string $name Page link name. e.g. prev.
* @param int $page Page number.
* @param string $link_base Base URL.
*/
protected function add_page_link( &$response, $name, $page, $link_base ) {
$response->link_header( $name, add_query_arg( 'page', $page, $link_base ) );
}
}
ProductItemTrait.php 0000644 00000005715 15155613057 0010540 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* ProductItemTrait
*
* Shared functionality for formating product item data.
*/
trait ProductItemTrait {
/**
* Get an array of pricing data.
*
* @param \WC_Product $product Product instance.
* @param string $tax_display_mode If returned prices are incl or excl of tax.
* @return array
*/
protected function prepare_product_price_response( \WC_Product $product, $tax_display_mode = '' ) {
$tax_display_mode = $this->get_tax_display_mode( $tax_display_mode );
$price_function = $this->get_price_function_from_tax_display_mode( $tax_display_mode );
$prices = parent::prepare_product_price_response( $product, $tax_display_mode );
// Add raw prices (prices with greater precision).
$prices['raw_prices'] = [
'precision' => wc_get_rounding_precision(),
'price' => $this->prepare_money_response( $price_function( $product ), wc_get_rounding_precision() ),
'regular_price' => $this->prepare_money_response( $price_function( $product, [ 'price' => $product->get_regular_price() ] ), wc_get_rounding_precision() ),
'sale_price' => $this->prepare_money_response( $price_function( $product, [ 'price' => $product->get_sale_price() ] ), wc_get_rounding_precision() ),
];
return $prices;
}
/**
* Format variation data, for example convert slugs such as attribute_pa_size to Size.
*
* @param array $variation_data Array of data from the cart.
* @param \WC_Product $product Product data.
* @return array
*/
protected function format_variation_data( $variation_data, $product ) {
$return = [];
if ( ! is_iterable( $variation_data ) ) {
return $return;
}
foreach ( $variation_data as $key => $value ) {
$taxonomy = wc_attribute_taxonomy_name( str_replace( 'attribute_pa_', '', urldecode( $key ) ) );
if ( taxonomy_exists( $taxonomy ) ) {
// If this is a term slug, get the term's nice name.
$term = get_term_by( 'slug', $value, $taxonomy );
if ( ! is_wp_error( $term ) && $term && $term->name ) {
$value = $term->name;
}
$label = wc_attribute_label( $taxonomy );
} else {
/**
* Filters the variation option name.
*
* Filters the variation option name for custom option slugs.
*
* @since 2.5.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param string $value The name to display.
* @param null $unused Unused because this is not a variation taxonomy.
* @param string $taxonomy Taxonomy or product attribute name.
* @param \WC_Product $product Product data.
* @return string
*/
$value = apply_filters( 'woocommerce_variation_option_name', $value, null, $taxonomy, $product );
$label = wc_attribute_label( str_replace( 'attribute_', '', $key ), $product );
}
$return[] = [
'attribute' => $this->prepare_html_response( $label ),
'value' => $this->prepare_html_response( $value ),
];
}
return $return;
}
}
ProductQuery.php 0000644 00000042477 15155613057 0007751 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use WC_Tax;
/**
* Product Query class.
*
* Helper class to handle product queries for the API.
*/
class ProductQuery {
/**
* Prepare query args to pass to WP_Query for a REST API request.
*
* @param \WP_REST_Request $request Request data.
* @return array
*/
public function prepare_objects_query( $request ) {
$args = [
'offset' => $request['offset'],
'order' => $request['order'],
'orderby' => $request['orderby'],
'paged' => $request['page'],
'post__in' => $request['include'],
'post__not_in' => $request['exclude'],
'posts_per_page' => $request['per_page'] ? $request['per_page'] : -1,
'post_parent__in' => $request['parent'],
'post_parent__not_in' => $request['parent_exclude'],
'search' => $request['search'], // This uses search rather than s intentionally to handle searches internally.
'slug' => $request['slug'],
'fields' => 'ids',
'ignore_sticky_posts' => true,
'post_status' => 'publish',
'date_query' => [],
'post_type' => 'product',
];
// If searching for a specific SKU or slug, allow any post type.
if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) {
$args['post_type'] = [ 'product', 'product_variation' ];
}
// Taxonomy query to filter products by type, category, tag, shipping class, and attribute.
$tax_query = [];
// Filter product type by slug.
if ( ! empty( $request['type'] ) ) {
if ( 'variation' === $request['type'] ) {
$args['post_type'] = 'product_variation';
} else {
$args['post_type'] = 'product';
$tax_query[] = [
'taxonomy' => 'product_type',
'field' => 'slug',
'terms' => $request['type'],
];
}
}
if ( 'date' === $args['orderby'] ) {
$args['orderby'] = 'date ID';
}
// Set before into date query. Date query must be specified as an array of an array.
if ( isset( $request['before'] ) ) {
$args['date_query'][0]['before'] = $request['before'];
}
// Set after into date query. Date query must be specified as an array of an array.
if ( isset( $request['after'] ) ) {
$args['date_query'][0]['after'] = $request['after'];
}
// Set date query column. Defaults to post_date.
if ( isset( $request['date_column'] ) && ! empty( $args['date_query'][0] ) ) {
$args['date_query'][0]['column'] = 'post_' . $request['date_column'];
}
// Set custom args to handle later during clauses.
$custom_keys = [
'sku',
'min_price',
'max_price',
'stock_status',
];
foreach ( $custom_keys as $key ) {
if ( ! empty( $request[ $key ] ) ) {
$args[ $key ] = $request[ $key ];
}
}
$operator_mapping = [
'in' => 'IN',
'not_in' => 'NOT IN',
'and' => 'AND',
];
// Gets all registered product taxonomies and prefixes them with `tax_`.
// This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field.
// eg an `sku` taxonomy will be mapped to `tax_sku`.
$all_product_taxonomies = array_map(
function ( $value ) {
return '_unstable_tax_' . $value;
},
get_taxonomies( array( 'object_type' => array( 'product' ) ), 'names' )
);
// Map between taxonomy name and arg key.
$default_taxonomies = [
'product_cat' => 'category',
'product_tag' => 'tag',
];
$taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies );
// Set tax_query for each passed arg.
foreach ( $taxonomies as $taxonomy => $key ) {
if ( ! empty( $request[ $key ] ) ) {
$operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN';
$tax_query[] = [
'taxonomy' => $taxonomy,
'field' => 'term_id',
'terms' => $request[ $key ],
'operator' => $operator,
];
}
}
// Filter by attributes.
if ( ! empty( $request['attributes'] ) ) {
$att_queries = [];
foreach ( $request['attributes'] as $attribute ) {
if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) {
continue;
}
if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) {
$operator = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN';
$att_queries[] = [
'taxonomy' => $attribute['attribute'],
'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug',
'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'],
'operator' => $operator,
];
}
}
if ( 1 < count( $att_queries ) ) {
// Add relation arg when using multiple attributes.
$relation = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN';
$tax_query[] = [
'relation' => $relation,
$att_queries,
];
} else {
$tax_query = array_merge( $tax_query, $att_queries );
}
}
// Build tax_query if taxonomies are set.
if ( ! empty( $tax_query ) ) {
if ( ! empty( $args['tax_query'] ) ) {
$args['tax_query'] = array_merge( $tax_query, $args['tax_query'] ); // phpcs:ignore
} else {
$args['tax_query'] = $tax_query; // phpcs:ignore
}
}
// Filter featured.
if ( is_bool( $request['featured'] ) ) {
$args['tax_query'][] = [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => 'featured',
'operator' => true === $request['featured'] ? 'IN' : 'NOT IN',
];
}
// Filter by on sale products.
if ( is_bool( $request['on_sale'] ) ) {
$on_sale_key = $request['on_sale'] ? 'post__in' : 'post__not_in';
$on_sale_ids = wc_get_product_ids_on_sale();
// Use 0 when there's no on sale products to avoid return all products.
$on_sale_ids = empty( $on_sale_ids ) ? [ 0 ] : $on_sale_ids;
$args[ $on_sale_key ] += $on_sale_ids;
}
$catalog_visibility = $request->get_param( 'catalog_visibility' );
$rating = $request->get_param( 'rating' );
$visibility_options = wc_get_product_visibility_options();
if ( in_array( $catalog_visibility, array_keys( $visibility_options ), true ) ) {
$exclude_from_catalog = 'search' === $catalog_visibility ? '' : 'exclude-from-catalog';
$exclude_from_search = 'catalog' === $catalog_visibility ? '' : 'exclude-from-search';
$args['tax_query'][] = [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => [ $exclude_from_catalog, $exclude_from_search ],
'operator' => 'hidden' === $catalog_visibility ? 'AND' : 'NOT IN',
'rating_filter' => true,
];
}
if ( $rating ) {
$rating_terms = [];
foreach ( $rating as $value ) {
$rating_terms[] = 'rated-' . $value;
}
$args['tax_query'][] = [
'taxonomy' => 'product_visibility',
'field' => 'name',
'terms' => $rating_terms,
];
}
$orderby = $request->get_param( 'orderby' );
$order = $request->get_param( 'order' );
$ordering_args = wc()->query->get_catalog_ordering_args( $orderby, $order );
$args['orderby'] = $ordering_args['orderby'];
$args['order'] = $ordering_args['order'];
if ( 'include' === $orderby ) {
$args['orderby'] = 'post__in';
} elseif ( 'id' === $orderby ) {
$args['orderby'] = 'ID'; // ID must be capitalized.
} elseif ( 'slug' === $orderby ) {
$args['orderby'] = 'name';
}
if ( $ordering_args['meta_key'] ) {
$args['meta_key'] = $ordering_args['meta_key']; // phpcs:ignore
}
return $args;
}
/**
* Get results of query.
*
* @param \WP_REST_Request $request Request data.
* @return array
*/
public function get_results( $request ) {
$query_args = $this->prepare_objects_query( $request );
add_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10, 2 );
$query = new \WP_Query();
$results = $query->query( $query_args );
$total_posts = $query->found_posts;
// Out-of-bounds, run the query again without LIMIT for total count.
if ( $total_posts < 1 && $query_args['paged'] > 1 ) {
unset( $query_args['paged'] );
$count_query = new \WP_Query();
$count_query->query( $query_args );
$total_posts = $count_query->found_posts;
}
remove_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10 );
return [
'results' => $results,
'total' => (int) $total_posts,
'pages' => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1,
];
}
/**
* Get objects.
*
* @param \WP_REST_Request $request Request data.
* @return array
*/
public function get_objects( $request ) {
$results = $this->get_results( $request );
return [
'objects' => array_map( 'wc_get_product', $results['results'] ),
'total' => $results['total'],
'pages' => $results['pages'],
];
}
/**
* Get last modified date for all products.
*
* @return int timestamp.
*/
public function get_last_modified() {
global $wpdb;
return strtotime( $wpdb->get_var( "SELECT MAX( post_modified_gmt ) FROM {$wpdb->posts} WHERE post_type IN ( 'product', 'product_variation' );" ) );
}
/**
* Add in conditional search filters for products.
*
* @param array $args Query args.
* @param \WC_Query $wp_query WC_Query object.
* @return array
*/
public function add_query_clauses( $args, $wp_query ) {
global $wpdb;
if ( $wp_query->get( 'search' ) ) {
$search = '%' . $wpdb->esc_like( $wp_query->get( 'search' ) ) . '%';
$search_query = wc_product_sku_enabled()
? $wpdb->prepare( " AND ( $wpdb->posts.post_title LIKE %s OR wc_product_meta_lookup.sku LIKE %s ) ", $search, $search )
: $wpdb->prepare( " AND $wpdb->posts.post_title LIKE %s ", $search );
$args['where'] .= $search_query;
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
}
if ( $wp_query->get( 'sku' ) ) {
$skus = explode( ',', $wp_query->get( 'sku' ) );
// Include the current string as a SKU too.
if ( 1 < count( $skus ) ) {
$skus[] = $wp_query->get( 'sku' );
}
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$args['where'] .= ' AND wc_product_meta_lookup.sku IN ("' . implode( '","', array_map( 'esc_sql', $skus ) ) . '")';
}
if ( $wp_query->get( 'slug' ) ) {
$slugs = explode( ',', $wp_query->get( 'slug' ) );
// Include the current string as a slug too.
if ( 1 < count( $slugs ) ) {
$slugs[] = $wp_query->get( 'slug' );
}
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$post_name__in = implode( '","', array_map( 'esc_sql', $slugs ) );
$args['where'] .= " AND $wpdb->posts.post_name IN (\"$post_name__in\")";
}
if ( $wp_query->get( 'stock_status' ) ) {
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$args['where'] .= ' AND wc_product_meta_lookup.stock_status IN ("' . implode( '","', array_map( 'esc_sql', $wp_query->get( 'stock_status' ) ) ) . '")';
} elseif ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
$args['where'] .= ' AND wc_product_meta_lookup.stock_status NOT IN ("outofstock")';
}
if ( $wp_query->get( 'min_price' ) || $wp_query->get( 'max_price' ) ) {
$args = $this->add_price_filter_clauses( $args, $wp_query );
}
return $args;
}
/**
* Add in conditional price filters.
*
* @param array $args Query args.
* @param \WC_Query $wp_query WC_Query object.
* @return array
*/
protected function add_price_filter_clauses( $args, $wp_query ) {
global $wpdb;
$adjust_for_taxes = $this->adjust_price_filters_for_displayed_taxes();
$args['join'] = $this->append_product_sorting_table_join( $args['join'] );
if ( $wp_query->get( 'min_price' ) ) {
$min_price_filter = $this->prepare_price_filter( $wp_query->get( 'min_price' ) );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $min_price_filter, 'min_price', '>=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.min_price >= %f ', $min_price_filter );
}
}
if ( $wp_query->get( 'max_price' ) ) {
$max_price_filter = $this->prepare_price_filter( $wp_query->get( 'max_price' ) );
if ( $adjust_for_taxes ) {
$args['where'] .= $this->get_price_filter_query_for_displayed_taxes( $max_price_filter, 'max_price', '<=' );
} else {
$args['where'] .= $wpdb->prepare( ' AND wc_product_meta_lookup.max_price <= %f ', $max_price_filter );
}
}
return $args;
}
/**
* Get query for price filters when dealing with displayed taxes.
*
* @param float $price_filter Price filter to apply.
* @param string $column Price being filtered (min or max).
* @param string $operator Comparison operator for column.
* @return string Constructed query.
*/
protected function get_price_filter_query_for_displayed_taxes( $price_filter, $column = 'min_price', $operator = '>=' ) {
global $wpdb;
// Select only used tax classes to avoid unwanted calculations.
$product_tax_classes = $wpdb->get_col( "SELECT DISTINCT tax_class FROM {$wpdb->wc_product_meta_lookup};" );
if ( empty( $product_tax_classes ) ) {
return '';
}
$or_queries = [];
// We need to adjust the filter for each possible tax class and combine the queries into one.
foreach ( $product_tax_classes as $tax_class ) {
$adjusted_price_filter = $this->adjust_price_filter_for_tax_class( $price_filter, $tax_class );
$or_queries[] = $wpdb->prepare(
'( wc_product_meta_lookup.tax_class = %s AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )',
$tax_class,
$adjusted_price_filter
);
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
return $wpdb->prepare(
' AND (
wc_product_meta_lookup.tax_status = "taxable" AND ( 0=1 OR ' . implode( ' OR ', $or_queries ) . ')
OR ( wc_product_meta_lookup.tax_status != "taxable" AND wc_product_meta_lookup.`' . esc_sql( $column ) . '` ' . esc_sql( $operator ) . ' %f )
) ',
$price_filter
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
}
/**
* If price filters need adjustment to work with displayed taxes, this returns true.
*
* This logic is used when prices are stored in the database differently to how they are being displayed, with regards
* to taxes.
*
* @return boolean
*/
protected function adjust_price_filters_for_displayed_taxes() {
$display = get_option( 'woocommerce_tax_display_shop' );
$database = wc_prices_include_tax() ? 'incl' : 'excl';
return $display !== $database;
}
/**
* Converts price filter from subunits to decimal.
*
* @param string|int $price_filter Raw price filter in subunit format.
* @return float Price filter in decimal format.
*/
protected function prepare_price_filter( $price_filter ) {
return floatval( $price_filter / ( 10 ** wc_get_price_decimals() ) );
}
/**
* Adjusts a price filter based on a tax class and whether or not the amount includes or excludes taxes.
*
* This calculation logic is based on `wc_get_price_excluding_tax` and `wc_get_price_including_tax` in core.
*
* @param float $price_filter Price filter amount as entered.
* @param string $tax_class Tax class for adjustment.
* @return float
*/
protected function adjust_price_filter_for_tax_class( $price_filter, $tax_class ) {
$tax_display = get_option( 'woocommerce_tax_display_shop' );
$tax_rates = WC_Tax::get_rates( $tax_class );
$base_tax_rates = WC_Tax::get_base_tax_rates( $tax_class );
// If prices are shown incl. tax, we want to remove the taxes from the filter amount to match prices stored excl. tax.
if ( 'incl' === $tax_display ) {
/**
* Filters if taxes should be removed from locations outside the store base location.
*
* The woocommerce_adjust_non_base_location_prices filter can stop base taxes being taken off when dealing
* with out of base locations. e.g. If a product costs 10 including tax, all users will pay 10
* regardless of location and taxes.
*
* @since 2.6.0
*
* @internal Matches filter name in WooCommerce core.
*
* @param boolean $adjust_non_base_location_prices True by default.
* @return boolean
*/
$taxes = apply_filters( 'woocommerce_adjust_non_base_location_prices', true ) ? WC_Tax::calc_tax( $price_filter, $base_tax_rates, true ) : WC_Tax::calc_tax( $price_filter, $tax_rates, true );
return $price_filter - array_sum( $taxes );
}
// If prices are shown excl. tax, add taxes to match the prices stored in the DB.
$taxes = WC_Tax::calc_tax( $price_filter, $tax_rates, false );
return $price_filter + array_sum( $taxes );
}
/**
* Join wc_product_meta_lookup to posts if not already joined.
*
* @param string $sql SQL join.
* @return string
*/
protected function append_product_sorting_table_join( $sql ) {
global $wpdb;
if ( ! strstr( $sql, 'wc_product_meta_lookup' ) ) {
$sql .= " LEFT JOIN {$wpdb->wc_product_meta_lookup} wc_product_meta_lookup ON $wpdb->posts.ID = wc_product_meta_lookup.product_id ";
}
return $sql;
}
}
ProductQueryFilters.php 0000644 00000036452 15155613060 0011270 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
use Exception;
use WP_REST_Request;
/**
* Product Query filters class.
*/
class ProductQueryFilters {
/**
* Get filtered min price for current products.
*
* @param \WP_REST_Request $request The request object.
* @return object
*/
public function get_filtered_price( $request ) {
global $wpdb;
// Regenerate the products query without min/max price request params.
unset( $request['min_price'], $request['max_price'] );
// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
$product_query = new ProductQuery();
add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
add_filter( 'posts_pre_query', '__return_empty_array' );
$query_args = $product_query->prepare_objects_query( $request );
$query_args['no_found_rows'] = true;
$query_args['posts_per_page'] = -1;
$query = new \WP_Query();
$result = $query->query( $query_args );
$product_query_sql = $query->request;
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
$price_filter_sql = "
SELECT min( min_price ) as min_price, MAX( max_price ) as max_price
FROM {$wpdb->wc_product_meta_lookup}
WHERE product_id IN ( {$product_query_sql} )
";
return $wpdb->get_row( $price_filter_sql ); // phpcs:ignore
}
/**
* Get stock status counts for the current products.
*
* @param \WP_REST_Request $request The request object.
* @return array status=>count pairs.
*/
public function get_stock_status_counts( $request ) {
global $wpdb;
$product_query = new ProductQuery();
$stock_status_options = array_map( 'esc_sql', array_keys( wc_get_product_stock_status_options() ) );
$hide_outofstock_items = get_option( 'woocommerce_hide_out_of_stock_items' );
if ( 'yes' === $hide_outofstock_items ) {
unset( $stock_status_options['outofstock'] );
}
add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
add_filter( 'posts_pre_query', '__return_empty_array' );
$query_args = $product_query->prepare_objects_query( $request );
unset( $query_args['stock_status'] );
$query_args['no_found_rows'] = true;
$query_args['posts_per_page'] = -1;
$query = new \WP_Query();
$result = $query->query( $query_args );
$product_query_sql = $query->request;
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
$stock_status_counts = array();
foreach ( $stock_status_options as $status ) {
$stock_status_count_sql = $this->generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options );
$result = $wpdb->get_row( $stock_status_count_sql ); // phpcs:ignore
$stock_status_counts[ $status ] = $result->status_count;
}
return $stock_status_counts;
}
/**
* Generate calculate query by stock status.
*
* @param string $status status to calculate.
* @param string $product_query_sql product query for current filter state.
* @param array $stock_status_options available stock status options.
*
* @return false|string
*/
private function generate_stock_status_count_query( $status, $product_query_sql, $stock_status_options ) {
if ( ! in_array( $status, $stock_status_options, true ) ) {
return false;
}
global $wpdb;
$status = esc_sql( $status );
return "
SELECT COUNT( DISTINCT posts.ID ) as status_count
FROM {$wpdb->posts} as posts
INNER JOIN {$wpdb->postmeta} as postmeta ON posts.ID = postmeta.post_id
AND postmeta.meta_key = '_stock_status'
AND postmeta.meta_value = '{$status}'
WHERE posts.ID IN ( {$product_query_sql} )
";
}
/**
* Get terms list for a given taxonomy.
*
* @param string $taxonomy Taxonomy name.
*
* @return array
*/
public function get_terms_list( string $taxonomy ) {
global $wpdb;
return $wpdb->get_results(
$wpdb->prepare(
"SELECT term_id as term_count_id,
count(DISTINCT product_or_parent_id) as term_count
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s
GROUP BY term_id",
$taxonomy
)
);
}
/**
* Get the empty terms list for a given taxonomy.
*
* @param string $taxonomy Taxonomy name.
*
* @return array
*/
public function get_empty_terms_list( string $taxonomy ) {
global $wpdb;
return $wpdb->get_results(
$wpdb->prepare(
"SELECT DISTINCT term_id as term_count_id,
0 as term_count
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s",
$taxonomy
)
);
}
/**
* Get attribute and meta counts.
*
* @param WP_REST_Request $request Request data.
* @param string $filtered_attribute The attribute to count.
*
* @return array
*/
public function get_attribute_counts( $request, $filtered_attribute ) {
if ( is_array( $filtered_attribute ) ) {
wc_deprecated_argument( 'attributes', 'TBD', 'get_attribute_counts does not require an array of attributes as the second parameter anymore. Provide the filtered attribute as a string instead.' );
$filtered_attribute = ! empty( $filtered_attribute[0] ) ? $filtered_attribute[0] : '';
if ( empty( $filtered_attribute ) ) {
return array();
}
}
$attributes_data = $request->get_param( 'attributes' );
$calculate_attribute_counts = $request->get_param( 'calculate_attribute_counts' );
$min_price = $request->get_param( 'min_price' );
$max_price = $request->get_param( 'max_price' );
$rating = $request->get_param( 'rating' );
$stock_status = $request->get_param( 'stock_status' );
$transient_key = 'wc_get_attribute_and_meta_counts_' . md5(
wp_json_encode(
array(
'attributes_data' => $attributes_data,
'calculate_attribute_counts' => $calculate_attribute_counts,
'min_price' => $min_price,
'max_price' => $max_price,
'rating' => $rating,
'stock_status' => $stock_status,
'filtered_attribute' => $filtered_attribute,
)
)
);
$cached_results = get_transient( $transient_key );
if ( ! empty( $cached_results ) && defined( 'WP_DEBUG' ) && ! WP_DEBUG ) {
return $cached_results;
}
if ( empty( $attributes_data ) && empty( $min_price ) && empty( $max_price ) && empty( $rating ) && empty( $stock_status ) ) {
$counts = $this->get_terms_list( $filtered_attribute );
return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
}
$where_clause = '';
if ( ! empty( $min_price ) || ! empty( $max_price ) || ! empty( $rating ) || ! empty( $stock_status ) ) {
$product_metas = [
'min_price' => $min_price,
'max_price' => $max_price,
'average_rating' => $rating,
'stock_status' => $stock_status,
];
$filtered_products_by_metas = $this->get_product_by_metas( $product_metas );
$formatted_filtered_products_by_metas = implode( ',', array_map( 'intval', $filtered_products_by_metas ) );
if ( ! empty( $formatted_filtered_products_by_metas ) ) {
if ( ! empty( $rating ) ) {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_metas );
} else {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_id IN (%1s)', $formatted_filtered_products_by_metas );
}
} else {
$counts = $this->get_empty_terms_list( $filtered_attribute );
return array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
}
}
$join_type = 'LEFT';
foreach ( $attributes_data as $attribute ) {
$filtered_terms = $attribute['slug'] ?? '';
if ( empty( $filtered_terms ) ) {
continue;
}
$taxonomy = $attribute['attribute'] ?? '';
$term_ids = [];
if ( in_array( $taxonomy, wc_get_attribute_taxonomy_names(), true ) ) {
foreach ( $filtered_terms as $filtered_term ) {
$term = get_term_by( 'slug', $filtered_term, $taxonomy );
if ( is_object( $term ) ) {
$term_ids[] = $term->term_id;
}
}
}
if ( empty( $term_ids ) ) {
continue;
}
foreach ( $calculate_attribute_counts as $calculate_attribute_count ) {
if ( ! isset( $calculate_attribute_count['taxonomy'] ) && ! isset( $calculate_attribute_count['query_type'] ) ) {
continue;
}
$query_type = $calculate_attribute_count['query_type'];
$filtered_products_by_terms = $this->get_product_by_filtered_terms( $calculate_attribute_count['taxonomy'], $term_ids, $query_type );
$formatted_filtered_products_by_terms = implode( ',', array_map( 'intval', $filtered_products_by_terms ) );
if ( ! empty( $formatted_filtered_products_by_terms ) ) {
$where_clause .= sprintf( ' AND product_attribute_lookup.product_or_parent_id IN (%1s)', $formatted_filtered_products_by_terms );
}
if ( $calculate_attribute_count['taxonomy'] === $filtered_attribute ) {
$join_type = 'or' === $query_type ? 'LEFT' : 'INNER';
}
}
}
global $wpdb;
$counts = $wpdb->get_results(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT attributes.term_id as term_count_id, coalesce(term_count, 0) as term_count
FROM (SELECT DISTINCT term_id
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE taxonomy = %s) as attributes %1s JOIN (
SELECT COUNT(DISTINCT product_attribute_lookup.product_or_parent_id) as term_count, product_attribute_lookup.term_id
FROM {$wpdb->prefix}wc_product_attributes_lookup product_attribute_lookup
INNER JOIN {$wpdb->posts} posts
ON posts.ID = product_attribute_lookup.product_id
WHERE posts.post_type IN ('product', 'product_variation') AND posts.post_status = 'publish'%1s
GROUP BY product_attribute_lookup.term_id
) summarize
ON attributes.term_id = summarize.term_id
",
$filtered_attribute,
$join_type,
$where_clause
)
);
// phpcs:enable
$results = array_map( 'absint', wp_list_pluck( $counts, 'term_count', 'term_count_id' ) );
set_transient( $transient_key, $results, 24 * HOUR_IN_SECONDS );
return $results;
}
/**
* Get rating counts for the current products.
*
* @param \WP_REST_Request $request The request object.
* @return array rating=>count pairs.
*/
public function get_rating_counts( $request ) {
global $wpdb;
// Regenerate the products query without rating request params.
unset( $request['rating'] );
// Grab the request from the WP Query object, and remove SQL_CALC_FOUND_ROWS and Limits so we get a list of all products.
$product_query = new ProductQuery();
add_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10, 2 );
add_filter( 'posts_pre_query', '__return_empty_array' );
$query_args = $product_query->prepare_objects_query( $request );
$query_args['no_found_rows'] = true;
$query_args['posts_per_page'] = -1;
$query = new \WP_Query();
$result = $query->query( $query_args );
$product_query_sql = $query->request;
remove_filter( 'posts_clauses', array( $product_query, 'add_query_clauses' ), 10 );
remove_filter( 'posts_pre_query', '__return_empty_array' );
$rating_count_sql = "
SELECT COUNT( DISTINCT product_id ) as product_count, ROUND( average_rating, 0 ) as rounded_average_rating
FROM {$wpdb->wc_product_meta_lookup}
WHERE product_id IN ( {$product_query_sql} )
AND average_rating > 0
GROUP BY rounded_average_rating
ORDER BY rounded_average_rating ASC
";
$results = $wpdb->get_results( $rating_count_sql ); // phpcs:ignore
return array_map( 'absint', wp_list_pluck( $results, 'product_count', 'rounded_average_rating' ) );
}
/**
* Gets product by metas.
*
* @since TBD
* @param array $metas Array of metas to query.
* @return array $results
*/
public function get_product_by_metas( $metas = array() ) {
global $wpdb;
if ( empty( $metas ) ) {
return array();
}
$where = array();
$results = array();
$params = array();
foreach ( $metas as $column => $value ) {
if ( empty( $value ) ) {
continue;
}
if ( 'stock_status' === $column ) {
$stock_product_ids = array();
foreach ( $value as $stock_status ) {
$stock_product_ids[] = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE stock_status = %s",
$stock_status
)
);
}
$where[] = 'product_id IN (' . implode( ',', array_merge( ...$stock_product_ids ) ) . ')';
continue;
}
if ( 'min_price' === $column ) {
$where[] = "{$column} >= %d";
$params[] = intval( $value ) / 100;
continue;
}
if ( 'max_price' === $column ) {
$where[] = "{$column} <= %d";
$params[] = intval( $value ) / 100;
continue;
}
if ( 'average_rating' === $column ) {
$where_rating = array();
foreach ( $value as $rating ) {
$where_rating[] = sprintf( '(average_rating >= %f - 0.5 AND average_rating < %f + 0.5)', $rating, $rating );
}
$where[] = '(' . implode( ' OR ', $where_rating ) . ')';
continue;
}
$where[] = sprintf( "%1s = '%s'", $column, $value );
$params[] = $value;
}
if ( ! empty( $where ) ) {
$where_clause = implode( ' AND ', $where );
$where_clause = sprintf( $where_clause, ...$params );
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$results = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT product_id FROM {$wpdb->prefix}wc_product_meta_lookup WHERE %1s",
$where_clause
)
);
}
// phpcs:enable
return $results;
}
/**
* Gets product by filtered terms.
*
* @since TBD
* @param string $taxonomy Taxonomy name.
* @param array $term_ids Term IDs.
* @param string $query_type or | and.
* @return array Product IDs.
*/
public function get_product_by_filtered_terms( $taxonomy = '', $term_ids = array(), $query_type = 'or' ) {
global $wpdb;
$term_count = count( $term_ids );
$results = array();
$term_ids = implode( ',', array_map( 'intval', $term_ids ) );
if ( 'or' === $query_type ) {
$results = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN (%1s)
",
$taxonomy,
$term_ids
)
// phpcs:enable
);
}
if ( 'and' === $query_type ) {
$results = $wpdb->get_col(
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
$wpdb->prepare(
"
SELECT DISTINCT `product_or_parent_id`
FROM {$wpdb->prefix}wc_product_attributes_lookup
WHERE `taxonomy` = %s
AND `term_id` IN (%1s)
GROUP BY `product_or_parent_id`
HAVING COUNT( DISTINCT `term_id` ) >= %d
",
$taxonomy,
$term_ids,
$term_count
)
// phpcs:enable
);
}
return $results;
}
}
QuantityLimits.php 0000644 00000015477 15155613060 0010275 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
/**
* QuantityLimits class.
*
* Returns limits for products and cart items when using the StoreAPI and supporting classes.
*/
final class QuantityLimits {
use DraftOrderTrait;
/**
* Get quantity limits (min, max, step/multiple) for a product or cart item.
*
* @param array $cart_item A cart item array.
* @return array
*/
public function get_cart_item_quantity_limits( $cart_item ) {
$product = $cart_item['data'] ?? false;
if ( ! $product instanceof \WC_Product ) {
return [
'minimum' => 1,
'maximum' => 9999,
'multiple_of' => 1,
'editable' => true,
];
}
$multiple_of = (int) $this->filter_value( 1, 'multiple_of', $cart_item );
$minimum = (int) $this->filter_value( 1, 'minimum', $cart_item );
$maximum = (int) $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $cart_item );
$editable = (bool) $this->filter_value( ! $product->is_sold_individually(), 'editable', $cart_item );
return [
'minimum' => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
'maximum' => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
'multiple_of' => $multiple_of,
'editable' => $editable,
];
}
/**
* Get limits for product add to cart forms.
*
* @param \WC_Product $product Product instance.
* @return array
*/
public function get_add_to_cart_limits( \WC_Product $product ) {
$multiple_of = $this->filter_value( 1, 'multiple_of', $product );
$minimum = $this->filter_value( 1, 'minimum', $product );
$maximum = $this->filter_value( $this->get_product_quantity_limit( $product ), 'maximum', $product );
return [
'minimum' => $this->limit_to_multiple( $minimum, $multiple_of, 'ceil' ),
'maximum' => $this->limit_to_multiple( $maximum, $multiple_of, 'floor' ),
'multiple_of' => $multiple_of,
];
}
/**
* Return a number using the closest multiple of another number. Used to enforce step/multiple values.
*
* @param int $number Number to round.
* @param int $multiple_of The multiple.
* @param string $rounding_function ceil, floor, or round.
* @return int
*/
public function limit_to_multiple( int $number, int $multiple_of, string $rounding_function = 'round' ) {
if ( $multiple_of <= 1 ) {
return $number;
}
$rounding_function = in_array( $rounding_function, [ 'ceil', 'floor', 'round' ], true ) ? $rounding_function : 'round';
return $rounding_function( $number / $multiple_of ) * $multiple_of;
}
/**
* Check that a given quantity is valid according to any limits in place.
*
* @param integer $quantity Quantity to validate.
* @param \WC_Product|array $cart_item Cart item.
* @return \WP_Error|true
*/
public function validate_cart_item_quantity( $quantity, $cart_item ) {
$limits = $this->get_cart_item_quantity_limits( $cart_item );
if ( ! $limits['editable'] ) {
return new \WP_Error(
'readonly_quantity',
__( 'This item is already in the cart and its quantity cannot be edited', 'woocommerce' )
);
}
if ( $quantity < $limits['minimum'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The minimum quantity that can be added to the cart is %s', 'woocommerce' ),
$limits['minimum']
)
);
}
if ( $quantity > $limits['maximum'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The maximum quantity that can be added to the cart is %s', 'woocommerce' ),
$limits['maximum']
)
);
}
if ( $quantity % $limits['multiple_of'] ) {
return new \WP_Error(
'invalid_quantity',
sprintf(
// Translators: %s amount.
__( 'The quantity added to the cart must be a multiple of %s', 'woocommerce' ),
$limits['multiple_of']
)
);
}
return true;
}
/**
* Get the limit for the total number of a product allowed in the cart.
*
* This is based on product properties, including remaining stock, and defaults to a maximum of 9999 of any product
* in the cart at once.
*
* @param \WC_Product $product Product instance.
* @return int
*/
protected function get_product_quantity_limit( \WC_Product $product ) {
$limits = [ 9999 ];
if ( $product->is_sold_individually() ) {
$limits[] = 1;
} elseif ( ! $product->backorders_allowed() ) {
$limits[] = $this->get_remaining_stock( $product );
}
/**
* Filters the quantity limit for a product being added to the cart via the Store API.
*
* Filters the variation option name for custom option slugs.
*
* @since 6.8.0
*
* @param integer $quantity_limit Quantity limit which defaults to 9999 unless sold individually.
* @param \WC_Product $product Product instance.
* @return integer
*/
return apply_filters( 'woocommerce_store_api_product_quantity_limit', max( min( array_filter( $limits ) ), 1 ), $product );
}
/**
* Returns the remaining stock for a product if it has stock.
*
* This also factors in draft orders.
*
* @param \WC_Product $product Product instance.
* @return integer|null
*/
protected function get_remaining_stock( \WC_Product $product ) {
if ( is_null( $product->get_stock_quantity() ) ) {
return null;
}
$reserve_stock = new ReserveStock();
$reserved_stock = $reserve_stock->get_reserved_stock( $product, $this->get_draft_order_id() );
return $product->get_stock_quantity() - $reserved_stock;
}
/**
* Get a quantity for a product or cart item by running it through a filter hook.
*
* @param int|null $value Value to filter.
* @param string $value_type Type of value. Used for filter suffix.
* @param \WC_Product|array $cart_item_or_product Either a cart item or a product instance.
* @return mixed
*/
protected function filter_value( $value, string $value_type, $cart_item_or_product ) {
$is_product = $cart_item_or_product instanceof \WC_Product;
$product = $is_product ? $cart_item_or_product : $cart_item_or_product['data'];
$cart_item = $is_product ? null : $cart_item_or_product;
/**
* Filters the quantity minimum for a cart item in Store API. This allows extensions to control the minimum qty
* of items already within the cart.
*
* The suffix of the hook will vary depending on the value being filtered.
* For example, minimum, maximum, multiple_of, editable.
*
* @since 6.8.0
*
* @param mixed $value The value being filtered.
* @param \WC_Product $product The product object.
* @param array|null $cart_item The cart item if the product exists in the cart, or null.
* @return mixed
*/
return apply_filters( "woocommerce_store_api_product_quantity_{$value_type}", $value, $product, $cart_item );
}
}
RateLimits.php 0000644 00000013724 15155613060 0007343 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
use WC_Rate_Limiter;
use WC_Cache_Helper;
/**
* RateLimits class.
*/
class RateLimits extends WC_Rate_Limiter {
/**
* Cache group.
*/
const CACHE_GROUP = 'store_api_rate_limit';
/**
* Rate limiting enabled default value.
*
* @var boolean
*/
const ENABLED = false;
/**
* Proxy support enabled default value.
*
* @var boolean
*/
const PROXY_SUPPORT = false;
/**
* Default amount of max requests allowed for the defined timeframe.
*
* @var int
*/
const LIMIT = 25;
/**
* Default time in seconds before rate limits are reset.
*
* @var int
*/
const SECONDS = 10;
/**
* Gets a cache prefix.
*
* @param string $action_id Identifier of the action.
* @return string
*/
protected static function get_cache_key( $action_id ) {
return WC_Cache_Helper::get_cache_prefix( 'store_api_rate_limit' . $action_id );
}
/**
* Get current rate limit row from DB and normalize types. This query is not cached, and returns
* a new rate limit row if none exists.
*
* @param string $action_id Identifier of the action.
* @return object Object containing reset and remaining.
*/
protected static function get_rate_limit_row( $action_id ) {
global $wpdb;
$row = $wpdb->get_row(
$wpdb->prepare(
"
SELECT rate_limit_expiry as reset, rate_limit_remaining as remaining
FROM {$wpdb->prefix}wc_rate_limits
WHERE rate_limit_key = %s
AND rate_limit_expiry > %s
",
$action_id,
time()
),
'OBJECT'
);
if ( empty( $row ) ) {
$options = self::get_options();
return (object) [
'reset' => (int) $options->seconds + time(),
'remaining' => (int) $options->limit,
];
}
return (object) [
'reset' => (int) $row->reset,
'remaining' => (int) $row->remaining,
];
}
/**
* Returns current rate limit values using cache where possible.
*
* @param string $action_id Identifier of the action.
* @return object
*/
public static function get_rate_limit( $action_id ) {
$current_limit = self::get_cached( $action_id );
if ( false === $current_limit ) {
$current_limit = self::get_rate_limit_row( $action_id );
self::set_cache( $action_id, $current_limit );
}
return $current_limit;
}
/**
* If exceeded, seconds until reset.
*
* @param string $action_id Identifier of the action.
*
* @return bool|int
*/
public static function is_exceeded_retry_after( $action_id ) {
$current_limit = self::get_rate_limit( $action_id );
// Before the next run is allowed, retry forbidden.
if ( time() <= $current_limit->reset && 0 === $current_limit->remaining ) {
return (int) $current_limit->reset - time();
}
// After the next run is allowed, retry allowed.
return false;
}
/**
* Sets the rate limit delay in seconds for action with identifier $id.
*
* @param string $action_id Identifier of the action.
* @return object Current rate limits.
*/
public static function update_rate_limit( $action_id ) {
global $wpdb;
$options = self::get_options();
$rate_limit_expiry = time() + $options->seconds;
$wpdb->query(
$wpdb->prepare(
"INSERT INTO {$wpdb->prefix}wc_rate_limits
(`rate_limit_key`, `rate_limit_expiry`, `rate_limit_remaining`)
VALUES
(%s, %d, %d)
ON DUPLICATE KEY UPDATE
`rate_limit_remaining` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_remaining`), GREATEST(`rate_limit_remaining` - 1, 0)),
`rate_limit_expiry` = IF(`rate_limit_expiry` < %d, VALUES(`rate_limit_expiry`), `rate_limit_expiry`);
",
$action_id,
$rate_limit_expiry,
$options->limit - 1,
time(),
time()
)
);
$current_limit = self::get_rate_limit_row( $action_id );
self::set_cache( $action_id, $current_limit );
return $current_limit;
}
/**
* Retrieve a cached store api rate limit.
*
* @param string $action_id Identifier of the action.
* @return bool|object
*/
protected static function get_cached( $action_id ) {
return wp_cache_get( self::get_cache_key( $action_id ), self::CACHE_GROUP );
}
/**
* Cache a rate limit.
*
* @param string $action_id Identifier of the action.
* @param object $current_limit Current limit object with expiry and retries remaining.
* @return bool
*/
protected static function set_cache( $action_id, $current_limit ) {
return wp_cache_set( self::get_cache_key( $action_id ), $current_limit, self::CACHE_GROUP );
}
/**
* Return options for Rate Limits, to be returned by the "woocommerce_store_api_rate_limit_options" filter.
*
* @return object Default options.
*/
public static function get_options() {
$default_options = [
/**
* Filters the Store API rate limit check, which is disabled by default.
*
* This can be used also to disable the rate limit check when testing API endpoints via a REST API client.
*/
'enabled' => self::ENABLED,
/**
* Filters whether proxy support is enabled for the Store API rate limit check. This is disabled by default.
*
* If the store is behind a proxy, load balancer, CDN etc. the user can enable this to properly obtain
* the client's IP address through standard transport headers.
*/
'proxy_support' => self::PROXY_SUPPORT,
'limit' => self::LIMIT,
'seconds' => self::SECONDS,
];
return (object) array_merge( // By using array_merge we ensure we get a properly populated options object.
$default_options,
/**
* Filters options for Rate Limits.
*
* @param array $rate_limit_options Array of option values.
* @return array
*
* @since 8.9.0
*/
apply_filters(
'woocommerce_store_api_rate_limit_options',
$default_options
)
);
}
/**
* Gets a single option through provided name.
*
* @param string $option Option name.
*
* @return mixed
*/
public static function get_option( $option ) {
if ( ! is_string( $option ) || ! defined( 'RateLimits::' . strtoupper( $option ) ) ) {
return null;
}
return self::get_options()[ $option ];
}
}
ValidationUtils.php 0000644 00000003352 15155613060 0010375 0 ustar 00 <?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* ValidationUtils class.
* Helper class which validates and update customer info.
*/
class ValidationUtils {
/**
* Get list of states for a country.
*
* @param string $country Country code.
* @return array Array of state names indexed by state keys.
*/
public function get_states_for_country( $country ) {
return $country ? array_filter( (array) \wc()->countries->get_states( $country ) ) : [];
}
/**
* Validate provided state against a countries list of defined states.
*
* If there are no defined states for a country, any given state is valid.
*
* @param string $state State name or code (sanitized).
* @param string $country Country code.
* @return boolean Valid or not valid.
*/
public function validate_state( $state, $country ) {
$states = $this->get_states_for_country( $country );
if ( count( $states ) && ! in_array( \wc_strtoupper( $state ), array_map( '\wc_strtoupper', array_keys( $states ) ), true ) ) {
return false;
}
return true;
}
/**
* Format a state based on the country. If country has defined states, will return a valid upper case state code.
*
* @param string $state State name or code (sanitized).
* @param string $country Country code.
* @return string
*/
public function format_state( $state, $country ) {
$states = $this->get_states_for_country( $country );
if ( count( $states ) ) {
$state = \wc_strtoupper( $state );
$state_values = array_map( '\wc_strtoupper', array_flip( array_map( '\wc_strtoupper', $states ) ) );
if ( isset( $state_values[ $state ] ) ) {
// Convert to state code if a state name was provided.
return $state_values[ $state ];
}
}
return $state;
}
}