Skip to content

Commit

Permalink
Add experimental support for FTS indexes in HPOS. (woocommerce#46130)
Browse files Browse the repository at this point in the history
* Added key for sku

* Add experimental support for FTS indexes in HPOS. Additionally, revert existing HPOS search queries to use post like structure.

* Add unit tests

* Escape the error messages as per new phpcs rules.

* Fix query with index + added test.

* remove uninteded change.

* Unit test fixes.

* Remove unit test since the commit command breaks other tests.

Use message API instead of notices with defensive checks.
  • Loading branch information
vedanshujain authored Apr 26, 2024
1 parent 3d8e20c commit 94e438f
Show file tree
Hide file tree
Showing 9 changed files with 274 additions and 48 deletions.
4 changes: 4 additions & 0 deletions plugins/woocommerce/changelog/perf-indexes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: performance

Add experimental support for FTS indexes in HPOS. Additionally, revert existing HPOS search queries to use post like structure.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use Automattic\WooCommerce\Internal\BatchProcessing\BatchProcessingController;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Internal\Utilities\DatabaseUtil;
use Automattic\WooCommerce\Utilities\OrderUtil;
use Automattic\WooCommerce\Utilities\PluginUtil;
use WC_Admin_Settings;

Expand Down Expand Up @@ -46,6 +48,12 @@ class CustomOrdersTableController {

public const DEFAULT_DB_TRANSACTIONS_ISOLATION_LEVEL = 'READ UNCOMMITTED';

public const HPOS_FTS_INDEX_OPTION = 'woocommerce_hpos_fts_index_enabled';

public const HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION = 'woocommerce_hpos_address_fts_index_created';

public const HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION = 'woocommerce_hpos_order_item_fts_index_created';

/**
* The data store object to use.
*
Expand Down Expand Up @@ -109,6 +117,13 @@ class CustomOrdersTableController {
*/
private $plugin_util;

/**
* The db util object to use.
*
* @var DatabaseUtil;
*/
private $db_util;

/**
* Class constructor.
*/
Expand All @@ -124,6 +139,7 @@ private function init_hooks() {
self::add_filter( 'woocommerce_order-refund_data_store', array( $this, 'get_refunds_data_store' ), 999, 1 );
self::add_filter( 'woocommerce_debug_tools', array( $this, 'add_hpos_tools' ), 999 );
self::add_filter( 'updated_option', array( $this, 'process_updated_option' ), 999, 3 );
self::add_filter( 'updated_option', array( $this, 'process_updated_option_fts_index' ), 999, 3 );
self::add_filter( 'pre_update_option', array( $this, 'process_pre_update_option' ), 999, 3 );
self::add_action( 'woocommerce_after_register_post_type', array( $this, 'register_post_type_for_order_placeholders' ), 10, 0 );
self::add_action( 'woocommerce_sections_advanced', array( $this, 'sync_now' ) );
Expand All @@ -144,6 +160,7 @@ private function init_hooks() {
* @param OrderCache $order_cache The order cache engine to use.
* @param OrderCacheController $order_cache_controller The order cache controller to use.
* @param PluginUtil $plugin_util The plugin util to use.
* @param DatabaseUtil $db_util The database util to use.
*/
final public function init(
OrdersTableDataStore $data_store,
Expand All @@ -154,7 +171,8 @@ final public function init(
FeaturesController $features_controller,
OrderCache $order_cache,
OrderCacheController $order_cache_controller,
PluginUtil $plugin_util
PluginUtil $plugin_util,
DatabaseUtil $db_util
) {
$this->data_store = $data_store;
$this->data_synchronizer = $data_synchronizer;
Expand All @@ -165,6 +183,7 @@ final public function init(
$this->order_cache = $order_cache;
$this->order_cache_controller = $order_cache_controller;
$this->plugin_util = $plugin_util;
$this->db_util = $db_util;
}

/**
Expand Down Expand Up @@ -291,6 +310,61 @@ private function process_updated_option( $option, $old_value, $value ) {
}
}

/**
* Process option that enables FTS index on orders table. Tries to create an FTS index when option is enabled.
*
* @param string $option Option name.
* @param string $old_value Old value of the option.
* @param string $value New value of the option.
*
* @return void
*/
private function process_updated_option_fts_index( $option, $old_value, $value ) {
if ( self::HPOS_FTS_INDEX_OPTION !== $option ) {
return;
}

if ( 'yes' !== $value ) {
return;
}

if ( ! $this->custom_orders_table_usage_is_enabled() ) {
update_option( self::HPOS_FTS_INDEX_OPTION, 'no', true );
if ( class_exists( 'WC_Admin_Settings' ) ) {
WC_Admin_Settings::add_error( __( 'Failed to create FTS index on orders table. This feature is only available when High-performance order storage is enabled.', 'woocommerce' ) );
}
return;
}

if ( ! $this->db_util->fts_index_on_order_address_table_exists() ) {
$this->db_util->create_fts_index_order_address_table();
}

// Check again to see if index was actually created.
if ( $this->db_util->fts_index_on_order_address_table_exists() ) {
update_option( self::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION, 'yes', true );
} else {
update_option( self::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION, 'no', true );
if ( class_exists( 'WC_Admin_Settings ' ) ) {
WC_Admin_Settings::add_error( __( 'Failed to create FTS index on address table', 'woocommerce' ) );
}
}

if ( ! $this->db_util->fts_index_on_order_item_table_exists() ) {
$this->db_util->create_fts_index_order_item_table();
}

// Check again to see if index was actually created.
if ( $this->db_util->fts_index_on_order_item_table_exists() ) {
update_option( self::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION, 'yes', true );
} else {
update_option( self::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION, 'no', true );
if ( class_exists( 'WC_Admin_Settings ' ) ) {
WC_Admin_Settings::add_error( __( 'Failed to create FTS index on order item table', 'woocommerce' ) );
}
}
}

/**
* Handler for the setting pre-update hook.
* We use it to verify that authoritative orders table switch doesn't happen while sync is pending.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function __construct( OrdersTableQuery $query ) {
*
* @return array Array of search filters.
*/
private function sanitize_search_filters( string $search_filter ) : array {
private function sanitize_search_filters( string $search_filter ): array {
$core_filters = array(
'order_id',
'transaction_id',
Expand Down Expand Up @@ -112,15 +112,7 @@ private function generate_join(): string {
*
* @return string JOIN clause.
*/
private function generate_join_for_search_filter( $search_filter ) : string {
if ( 'products' === $search_filter ) {
$orders_table = $this->query->get_table_name( 'orders' );
$items_table = $this->query->get_table_name( 'items' );
return "
LEFT JOIN $items_table AS search_query_items ON search_query_items.order_id = $orders_table.id
";
}

private function generate_join_for_search_filter( $search_filter ): string {
/**
* Filter to support adding a custom order search filter.
* Provide a JOIN clause for a new search filter. This should be used along with `woocommerce_hpos_admin_search_filters`
Expand Down Expand Up @@ -181,7 +173,7 @@ private function generate_where(): string {
*
* @return string WHERE clause.
*/
private function generate_where_for_search_filter( string $search_filter ) : string {
private function generate_where_for_search_filter( string $search_filter ): string {
global $wpdb;

$order_table = $this->query->get_table_name( 'orders' );
Expand All @@ -208,15 +200,11 @@ private function generate_where_for_search_filter( string $search_filter ) : str
}

if ( 'products' === $search_filter ) {
return $wpdb->prepare(
'search_query_items.order_item_name LIKE %s',
'%' . $wpdb->esc_like( $this->search_term ) . '%'
);
return $this->get_where_for_products();
}

if ( 'customers' === $search_filter ) {
$meta_sub_query = $this->generate_where_for_meta_table();
return "`$order_table`.id IN ( $meta_sub_query ) ";
return $this->get_where_for_customers();
}

/**
Expand All @@ -243,6 +231,74 @@ private function generate_where_for_search_filter( string $search_filter ) : str
);
}

/**
* Helper function to generate the WHERE clause for products search. Uses FTS when available.
*
* @return string|null WHERE clause for products search.
*/
private function get_where_for_products() {
global $wpdb;
$items_table = $this->query->get_table_name( 'items' );
$orders_table = $this->query->get_table_name( 'orders' );
$fts_enabled = get_option( CustomOrdersTableController::HPOS_FTS_INDEX_OPTION ) === 'yes' && get_option( CustomOrdersTableController::HPOS_FTS_ORDER_ITEM_INDEX_CREATED_OPTION ) === 'yes';

if ( $fts_enabled ) {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orders_table and $items_table are hardcoded.
return $wpdb->prepare(
"
$orders_table.id in (
SELECT order_id FROM $items_table search_query_items WHERE
MATCH ( search_query_items.order_item_name ) AGAINST ( %s IN BOOLEAN MODE )
)
",
'*' . $wpdb->esc_like( $this->search_term ) . '*'
);
// phpcs:enable
}

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orders_table and $items_table are hardcoded.
return $wpdb->prepare(
"
$orders_table.id in (
SELECT order_id FROM $items_table search_query_items WHERE
search_query_items.order_item_name LIKE %s
)
",
'%' . $wpdb->esc_like( $this->search_term ) . '%'
);
// phpcs:enable
}

/**
* Helper function to generate the WHERE clause for customers search. Uses FTS when available.
*
* @return string|null WHERE clause for customers search.
*/
private function get_where_for_customers() {
global $wpdb;
$order_table = $this->query->get_table_name( 'orders' );
$address_table = $this->query->get_table_name( 'addresses' );

$fts_enabled = get_option( CustomOrdersTableController::HPOS_FTS_INDEX_OPTION ) === 'yes' && get_option( CustomOrdersTableController::HPOS_FTS_ADDRESS_INDEX_CREATED_OPTION ) === 'yes';

if ( $fts_enabled ) {
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $order_table and $address_table are hardcoded.
return $wpdb->prepare(
"
$order_table.id IN (
SELECT order_id FROM $address_table WHERE
MATCH( $address_table.first_name, $address_table.last_name, $address_table.company, $address_table.address_1, $address_table.address_2, $address_table.city, $address_table.state, $address_table.postcode, $address_table.country, $address_table.email ) AGAINST ( %s IN BOOLEAN MODE )
)
",
'*' . $wpdb->esc_like( $this->search_term ) . '*'
);
// phpcs:enable
}

$meta_sub_query = $this->generate_where_for_meta_table();
return "`$order_table`.id IN ( $meta_sub_query ) ";
}

/**
* Generates where clause for meta table.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public function register() {
OrderCache::class,
OrderCacheController::class,
PluginUtil::class,
DatabaseUtil::class,
)
);
$this->share( OrderCache::class );
Expand Down
45 changes: 29 additions & 16 deletions plugins/woocommerce/src/Internal/Features/FeaturesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use Automattic\WooCommerce\Internal\Admin\Analytics;
use Automattic\WooCommerce\Admin\Features\Navigation\Init;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\ArrayUtil;
Expand Down Expand Up @@ -232,6 +233,17 @@ private function get_feature_definitions() {
'is_legacy' => true,
'is_experimental' => false,
),
'hpos_fts_indexes' => array(
'name' => __( 'HPOS Full text search indexes', 'woocommerce' ),
'description' => __(
'Create and use full text search indexes for orders. This feature only works with high-performance order storage.',
'woocommerce'
),
'is_experimental' => true,
'enabled_by_default' => false,
'is_legacy' => true,
'option_key' => CustomOrdersTableController::HPOS_FTS_INDEX_OPTION,
),
);

foreach ( $legacy_features as $slug => $definition ) {
Expand Down Expand Up @@ -392,7 +404,7 @@ public function declare_compatibility( string $feature_id, string $plugin_name,
ArrayUtil::ensure_key_is_array( $this->compatibility_info_by_plugin[ $plugin_name ], $opposite_key );

if ( in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $opposite_key ], true ) ) {
throw new \Exception( "Plugin $plugin_name is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" );
throw new \Exception( esc_html( "Plugin $plugin_name is trying to declare itself as $key with the '$feature_id' feature, but it already declared itself as $opposite_key" ) );
}

if ( ! in_array( $feature_id, $this->compatibility_info_by_plugin[ $plugin_name ][ $key ], true ) ) {
Expand Down Expand Up @@ -487,14 +499,15 @@ public function get_compatible_plugins_for_feature( string $feature_id, bool $ac
/**
* Check if the 'woocommerce_init' has run or is running, do a 'wc_doing_it_wrong' if not.
*
* @param string|null $function Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running.
* @param string|null $function_name Name of the invoking method, if not null, 'wc_doing_it_wrong' will be invoked if 'woocommerce_init' has not run and is not running.
*
* @return bool True if 'woocommerce_init' has run or is running, false otherwise.
*/
private function verify_did_woocommerce_init( string $function = null ): bool {
private function verify_did_woocommerce_init( string $function_name = null ): bool {
if ( ! $this->proxy->call_function( 'did_action', 'woocommerce_init' ) &&
! $this->proxy->call_function( 'doing_action', 'woocommerce_init' ) ) {
if ( ! is_null( $function ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function;
if ( ! is_null( $function_name ) ) {
$class_and_method = ( new \ReflectionClass( $this ) )->getShortName() . '::' . $function_name;
/* translators: 1: class::method 2: plugins_loaded */
$this->proxy->call_function( 'wc_doing_it_wrong', $class_and_method, sprintf( __( '%1$s should not be called before the %2$s action.', 'woocommerce' ), $class_and_method, 'woocommerce_init' ), '7.0' );
}
Expand Down Expand Up @@ -869,41 +882,41 @@ private function handle_plugin_deactivation( $plugin_name ): void {
* if we are in the plugins page and the query string of the current request
* looks like '?plugin_status=incompatible_with_feature&feature_id=<feature id>'.
*
* @param array $list The original list of plugins.
* @param array $plugin_list The original list of plugins.
*/
private function filter_plugins_list( $list ): array {
private function filter_plugins_list( $plugin_list ): array {
if ( ! $this->verify_did_woocommerce_init() ) {
return $list;
return $plugin_list;
}

// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput
if ( ! function_exists( 'get_current_screen' ) || get_current_screen() && 'plugins' !== get_current_screen()->id || 'incompatible_with_feature' !== ArrayUtil::get_value_or_default( $_GET, 'plugin_status' ) ) {
return $list;
return $plugin_list;
}

$feature_id = $_GET['feature_id'] ?? 'all';
if ( 'all' !== $feature_id && ! $this->feature_exists( $feature_id ) ) {
return $list;
return $plugin_list;
}

return $this->get_incompatible_plugins( $feature_id, $list );
return $this->get_incompatible_plugins( $feature_id, $plugin_list );
}

/**
* Returns the list of plugins incompatible with a given feature.
*
* @param string $feature_id ID of the feature. Can also be `all` to denote all features.
* @param array $list List of plugins to filter.
* @param array $plugin_list List of plugins to filter.
*
* @return array List of plugins incompatible with the given feature.
*/
private function get_incompatible_plugins( $feature_id, $list ) {
private function get_incompatible_plugins( $feature_id, $plugin_list ) {
$incompatibles = array();

$list = array_diff_key( $list, array_flip( $this->plugins_excluded_from_compatibility_ui ) );
$plugin_list = array_diff_key( $plugin_list, array_flip( $this->plugins_excluded_from_compatibility_ui ) );

// phpcs:enable WordPress.Security.NonceVerification, WordPress.Security.ValidatedSanitizedInput
foreach ( array_keys( $list ) as $plugin_name ) {
foreach ( array_keys( $plugin_list ) as $plugin_name ) {
if ( ! $this->plugin_util->is_woocommerce_aware_plugin( $plugin_name ) || ! $this->proxy->call_function( 'is_plugin_active', $plugin_name ) ) {
continue;
}
Expand All @@ -921,7 +934,7 @@ function ( $feature_id ) {
}
}

return array_intersect_key( $list, array_flip( $incompatibles ) );
return array_intersect_key( $plugin_list, array_flip( $incompatibles ) );
}

/**
Expand Down
Loading

0 comments on commit 94e438f

Please sign in to comment.