our type. */ const REPLACES = array( 'order_id' => 'order_number', 'customer_email' => 'billing_email', 'customers' => 'customer', 'products' => 'product', 'transaction_id' => 'transaction_id', ); /** * Enabled types, memoized: [ saved value as JSON, type => label ]. * * @var array{0: string, 1: array}|null */ private $enabled = null; /** * Why a search in this request was refused ('' = none), shown above the orders list. * * @var string */ private $notice = ''; /** * Built search conditions this request, key => [ SQL, notice ]. WooCommerce can run the same * search twice in one page load (the orders list re-counts beyond the last page), and the * SKU and missing-orders reads behind it are the costly part. * * @var array */ private $built = array(); /** * Most searches kept in $built. */ const MAX_BUILT = 20; /** * Hooks that change what a search reads (orders, lookup rows, product SKUs): each clears $built. */ const FORGET_ON = array( 'woocommerce_new_order', 'woocommerce_update_order', 'woocommerce_delete_order', 'woocommerce_trash_order', 'woocommerce_untrash_order', 'woocommerce_analytics_update_product', 'woocommerce_analytics_delete_product', 'woocommerce_new_product', 'woocommerce_update_product', 'woocommerce_delete_product', 'woocommerce_new_product_variation', 'woocommerce_update_product_variation', ); /** * {@inheritDoc} */ public function id() { return 'advanced-order-search'; } /** * {@inheritDoc} */ public function title() { return __( 'Advanced order search', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function description() { return __( 'Search orders by customer, phone, SKU, total, coupon, notes and more.', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function category() { return 'orders'; } /** * {@inheritDoc} */ public function location() { return __( 'Orders list', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function icon() { return 'search'; } /** * {@inheritDoc} */ public function autoload_settings() { return false; // Only read on the orders screen. } /** * {@inheritDoc} */ public function unmet_requirement() { if ( ! Orders::hpos() ) { return __( 'Needs High-performance order storage. Turn it on in WooCommerce → Settings → Advanced → Features.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * All search types, including ones added by other code. * * @since 1.0.0 * @return array Type => label. */ public function types() { /** * Filter the advanced order search types. Add a type here and build its SQL with * the `apwc_order_search_where` filter. * * @since 1.0.0 * @param array $types Type => label. */ return (array) apply_filters( 'apwc_order_search_types', SearchQuery::types() ); } /** * {@inheritDoc} * * @return array> */ public function fields() { return array( array( 'id' => 'types', 'type' => 'checkboxes', 'label' => __( 'Search by', 'admin-powerups-for-woocommerce' ), 'default' => array_keys( SearchQuery::types() ), 'options' => $this->types(), 'slow' => SearchQuery::slow_types(), 'warning' => __( 'Types marked Slower look inside text and can’t use database indexes. On stores with many orders a search can take several seconds. Order number, totals, order status and Customer ID stay fast.', 'admin-powerups-for-woocommerce' ), ), array( 'id' => 'replace_core', 'type' => 'toggle', 'label' => __( 'Hide the WooCommerce options these replace', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Order ID, Customer email, Customers, Products and Transaction ID.', 'admin-powerups-for-woocommerce' ), 'default' => true, ), array( 'id' => 'tolerance', 'type' => 'number', 'label' => __( 'Exact total: match within', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Also find totals this close to your search. Use 0 for exact.', 'admin-powerups-for-woocommerce' ), 'default' => 0, 'min' => 0, 'max' => 1000000, 'step' => 0.01, ), array( 'id' => 'meta_keys', 'type' => 'textarea', 'label' => __( 'Custom metadata: only these keys', 'admin-powerups-for-woocommerce' ), 'description' => __( 'One per line. Leave empty to search any key. Sensitive keys are never searched; admins choose them in the Order meta inspector.', 'admin-powerups-for-woocommerce' ), 'warning' => __( 'With no keys listed, a metadata search reads every order’s metadata. On large stores, list the keys you need.', 'admin-powerups-for-woocommerce' ), 'default' => '', ), ); } /** * {@inheritDoc} */ public function boot() { add_filter( 'woocommerce_hpos_admin_search_filters', array( $this, 'filter_options' ) ); // Late, so other code that rewrites order query args runs first. add_filter( 'woocommerce_order_query_args', array( $this, 'route_search' ), 100 ); add_filter( 'woocommerce_orders_table_query_clauses', array( $this, 'add_search_clause' ), 10, 3 ); // Fallback for queries that skip the clauses filter (suppress_filters). add_filter( 'woocommerce_hpos_generate_where_for_search_filter', array( $this, 'where' ), 10, 3 ); // The list runs its query while the page prints (after admin notices): say why above the table. add_action( 'woocommerce_order_list_table_extra_tablenav', array( $this, 'print_notice' ), 10, 2 ); // Background rebuild of the order meta keys already checked against the masked patterns. add_action( MetaMask::KEYS_JOB, array( MetaMask::class, 'refresh_checked_keys' ) ); if ( is_admin() ) { add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); } } /** * Styles for the Orders list: modules.css shows WooCommerce's "Search by" dropdown on * phones (WooCommerce hides it there) when it lists our types. * * @since 1.0.0 * @return void */ public function enqueue() { if ( ! Screen::is_order_list() || ! $this->enabled_types() ) { return; } wp_enqueue_style( 'apwc-modules', Assets::url( 'assets/css/modules.css' ), array(), Assets::ver( 'assets/css/modules.css' ) ); } /** * {@inheritDoc} */ public function deactivate() { // The checked-keys job has no handler while this power-up is off. if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( MetaMask::KEYS_JOB ); } } /** * {@inheritDoc} */ public function data() { return array_merge( parent::data(), array( Cleanup::option( 'masked-keys', MetaMask::KEYS_OPTION, __( 'Checked metadata keys', 'admin-powerups-for-woocommerce' ), __( 'Order metadata keys already checked against the sensitive fields, so Custom metadata searches stay fast. Rebuilt in the background.', 'admin-powerups-for-woocommerce' ) ), Cleanup::job_history( 'jobs', array( MetaMask::KEYS_JOB ) ), ) ); } /** * Why the current search was refused (e.g. SKU too broad), above the orders list. * * @since 1.0.0 * @param string $order_type Order type. * @param string $which "top" or "bottom". * @return void */ public function print_notice( $order_type, $which ) { if ( 'top' !== $which || '' === $this->notice ) { return; } printf( '

%s

', esc_html( $this->notice ) ); } /** * Types switched on in settings, in display order. Memoized per request (per saved value). * * @since 1.0.0 * @return array */ public function enabled_types() { $saved = (array) $this->setting( 'types', array() ); $key = (string) wp_json_encode( $saved ); if ( null === $this->enabled || $this->enabled[0] !== $key ) { $this->enabled = array( $key, array_intersect_key( $this->types(), array_flip( array_filter( $saved, 'is_string' ) ) ) ); } return $this->enabled[1]; } /** * Dropdown options. * * @since 1.0.0 * @param array $options Core options. * @return array */ public function filter_options( $options ) { $ours = $this->enabled_types(); if ( $this->setting( 'replace_core' ) ) { foreach ( self::REPLACES as $core => $type ) { if ( isset( $ours[ $type ] ) ) { unset( $options[ $core ] ); } } } foreach ( $ours as $type => $label ) { $options[ self::PREFIX . $type ] = $label; } return $options; } /** * Take our search types out of WooCommerce's search (`s`). * * WooCommerce's HPOS search ORs `orders.id = N` into every search filter when the term is a * number, so "150" as an exact total would also list order #150. Our types move the term to * `apwc_search` and add their condition in add_search_clause() instead. "All" and * WooCommerce's own options are left alone. * * @since 1.0.0 * @param array $args wc_get_orders() query args. * @return array */ public function route_search( $args ) { // Cheapest checks first: most order queries (front end included) have no search filter. $filter = $args['search_filter'] ?? null; if ( ! is_string( $filter ) || 0 !== strpos( $filter, self::PREFIX ) ) { return $args; } $term = $args['s'] ?? null; if ( ! is_string( $term ) || '' === trim( $term ) ) { return $args; } if ( ! empty( $args['suppress_filters'] ) ) { return $args; // The clauses filter won't run; keep WooCommerce's search (where() fallback). } if ( ! Orders::hpos() ) { return $args; } $type = substr( $filter, strlen( self::PREFIX ) ); if ( ! isset( $this->enabled_types()[ $type ] ) ) { return $args; } $args['apwc_search'] = array( 'type' => $type, 'term' => $term, ); unset( $args['s'], $args['search_filter'] ); return $args; } /** * Add our search condition to the HPOS orders query (main and count query share it). * * @since 1.0.0 * @param array $clauses Query clauses. * @param mixed $query OrdersTableQuery. * @param array $args Query args. * @return array */ public function add_search_clause( $clauses, $query, $args ) { $search = is_array( $args ) ? ( $args['apwc_search'] ?? null ) : null; if ( ! is_array( $search ) || ! is_string( $search['type'] ?? null ) || ! is_string( $search['term'] ?? null ) ) { return $clauses; } $sql = $this->sql_for( $search['type'], $search['term'] ); if ( '' === $sql ) { return $clauses; } $where = trim( (string) ( $clauses['where'] ?? '' ) ); $clauses['where'] = ( '' === $where ? '1=1' : $where ) . ' AND ( ' . $sql . ' )'; return $clauses; } /** * WHERE clause for one of our types, when WooCommerce's own search runs it (fallback). * * @since 1.0.0 * @param string $where Current WHERE. * @param string $term Search term. * @param string $filter Selected dropdown key. * @return string */ public function where( $where, $term, $filter ) { if ( ! is_string( $filter ) || 0 !== strpos( $filter, self::PREFIX ) ) { return $where; } $sql = $this->sql_for( substr( $filter, strlen( self::PREFIX ) ), (string) $term ); return '' === $sql ? $where : $sql; } /** * Prepared SQL condition on the HPOS orders table for a type and term. * * @since 1.0.0 * @param string $type Search type (without prefix). * @param string $term Search term as WooCommerce passed it. * @return string SQL, '1=0' for no match, or '' when the type is not switched on. */ public function sql_for( $type, $term ) { if ( ! isset( $this->enabled_types()[ $type ] ) ) { return ''; } $options = array( 'tolerance' => (float) $this->setting( 'tolerance', 0 ), 'meta_keys' => array_values( array_filter( array_map( 'trim', explode( "\n", (string) $this->setting( 'meta_keys', '' ) ) ) ) ), 'decimal' => wc_get_price_decimal_separator(), ); if ( 'meta' === $type || 'variation' === $type ) { // Keys the Order meta inspector masks stay unsearchable for people who can't reveal them // (order meta, and line item meta for Variation). $options['masked_meta'] = MetaMask::masked_for_current_user(); } // WooCommerce runs the search box through sanitize_text_field(), which turns "<20" into // "<20". Undo that so "<" / "<=" total searches (and "&" in names) match. $term = wp_specialchars_decode( $term, ENT_QUOTES ); list( $sql, $notice ) = $this->build( $type, $term, $options ); if ( '' !== $notice ) { $this->notice = $notice; } /** * Filter the WHERE fragment for an advanced order search type. Return a prepared SQL * condition on the HPOS orders table, or '1=0' for no match. Custom types start empty. * * @since 1.0.0 * @param string $sql SQL fragment ('' for types this plugin does not know). * @param string $type Search type. * @param string $term Search term (HTML entities from WooCommerce's sanitizing decoded). */ $sql = (string) apply_filters( 'apwc_order_search_where', $sql, $type, $term ); return '' === $sql ? '1=0' : $sql; } /** * Build a search condition once per request for the same type, term, options and limits. * * @since 1.0.0 * @param string $type Search type. * @param string $term Search term (decoded). * @param array $options SearchQuery options. * @return array{0: string, 1: string} SQL and notice. */ private function build( $type, $term, array $options ) { /** This filter is documented in src/Modules/AdvancedOrderSearch/SearchQuery.php */ $sku_limit = apply_filters( 'apwc_order_search_sku_limit', SearchQuery::MAX_PRODUCTS ); $key = md5( (string) wp_json_encode( array( $type, $term, $options, SearchQuery::lookup_fallback_limit(), $sku_limit, SearchQuery::resolve_limit() ) ) ); if ( isset( $this->built[ $key ] ) ) { return $this->built[ $key ]; } $query = new SearchQuery( $options ); $sql = $query->where( $type, $term ); if ( ! $this->built ) { foreach ( self::FORGET_ON as $hook ) { add_action( $hook, array( $this, 'forget_built' ) ); } } elseif ( count( $this->built ) >= self::MAX_BUILT ) { array_shift( $this->built ); } $this->built[ $key ] = array( $sql, $query->notice() ); return $this->built[ $key ]; } /** * Forget built searches (orders, lookup rows or products changed). * * @since 1.0.0 * @return void */ public function forget_built() { $this->built = array(); foreach ( self::FORGET_ON as $hook ) { remove_action( $hook, array( $this, 'forget_built' ) ); } } /** * {@inheritDoc} */ public function refresh() { parent::refresh(); $this->forget_built(); } }