query type. */ const ROUTES = array( '/wc-analytics/reports/orders' => 'orders', '/wc-analytics/reports/orders/stats' => 'orders_stats', '/wc-analytics/reports/orders/export' => 'orders', ); /** * Params of the requests (and export batches) running now, innermost last. * * @var array, source: string, id: int, depth: int, request: WP_REST_Request|null, fiber: object|null}> */ private $frames = array(); /** * One entry per report query running now, innermost last. Every query pushes one (armed with * our condition, or plain with sql ''), and its select_query filter pops it, so a report query * started inside another (from a clause filter, say) never takes or clears the outer one's * condition. Request and export ends cut the stack back to where they started. * * An entry also holds its Query object: an entry whose Query is no longer on the call stack (it * threw and someone caught the exception) is stale and ignored by the clause and cache filters, * so it can't filter a later query, not even a raw data-store call. Stale entries are dropped * when a query of the same context ends, and the whole stack is emptied when the outermost REST * request or an Action Scheduler action ends. * * @var array|null, join: array|null}> */ private $stack = array(); /** * {@inheritDoc} */ public function id() { return 'analytics-filters'; } /** * {@inheritDoc} */ public function title() { return __( 'Analytics order filters', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function description() { return __( 'Filter Analytics → Orders by country, state, city, payment method, shipping method and customer role.', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function category() { return 'orders'; } /** * {@inheritDoc} */ public function location() { return __( 'Analytics → Orders, under Advanced filters', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function icon() { return 'funnel'; } /** * {@inheritDoc} */ public function autoload_settings() { return false; // Only read by Analytics requests and screens. } /** * Filter labels, in display order. * * @since 1.0.0 * @return array */ public static function filter_labels() { return array( 'country' => __( 'Country', 'admin-powerups-for-woocommerce' ), 'state' => __( 'State', 'admin-powerups-for-woocommerce' ), 'city' => __( 'City', 'admin-powerups-for-woocommerce' ), 'payment' => __( 'Payment method', 'admin-powerups-for-woocommerce' ), 'shipping' => __( 'Shipping method', 'admin-powerups-for-woocommerce' ), 'role' => __( 'Customer role', 'admin-powerups-for-woocommerce' ), ); } /** * {@inheritDoc} * * @return array> */ public function fields() { return array( array( 'id' => 'filters', 'type' => 'checkboxes', 'label' => __( 'Filters', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Added to “Advanced filters” on Analytics → Orders. Summary numbers, chart, table and CSV export all follow them.', 'admin-powerups-for-woocommerce' ), 'default' => array_keys( self::filter_labels() ), 'options' => self::filter_labels(), 'slow' => array( 'shipping', 'role' ), 'warning' => __( 'Filtered reports skip WooCommerce’s report cache, so each one is counted fresh. Filters marked Slower look inside order items or customer accounts, which can’t use database indexes: on stores with many orders a year-long report can take a few seconds.', 'admin-powerups-for-woocommerce' ), 'warning_icon' => 'gauge', ), array( 'id' => 'address', 'type' => 'select', 'label' => __( 'Address to match', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Used by the country, state and city filters. Orders without a shipping address never match a shipping location.', 'admin-powerups-for-woocommerce' ), 'default' => 'billing', 'options' => array( 'billing' => __( 'Billing address', 'admin-powerups-for-woocommerce' ), 'shipping' => __( 'Shipping address', 'admin-powerups-for-woocommerce' ), ), ), ); } /** * Whether WooCommerce Analytics is on, through the public features API. (WooCommerce 11.1 * deprecated the admin feature-flag shim `Features::is_enabled( 'analytics' )`, which logs a * deprecation notice there; `FeaturesUtil` has it on every version this power-up supports.) * * @since 1.0.0 * @return bool */ private static function analytics_on() { return FeaturesUtil::feature_is_enabled( 'analytics' ); } /** * {@inheritDoc} */ public function unmet_requirement() { if ( ! function_exists( 'WC' ) || ! WC()->is_wc_admin_active() || ! self::analytics_on() ) { return __( 'Needs WooCommerce Analytics. Turn it on in WooCommerce → Settings → Advanced → Features.', 'admin-powerups-for-woocommerce' ); } return ''; } /** * {@inheritDoc} */ public function boot() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); // Keep our params while a report request or export batch runs. add_filter( 'rest_request_before_callbacks', array( $this, 'request_start' ), 10, 3 ); add_filter( 'rest_request_after_callbacks', array( $this, 'request_end' ), 10, 3 ); add_action( 'woocommerce_admin_report_export', array( $this, 'export_start' ), 5, 4 ); add_action( 'woocommerce_admin_report_export', array( $this, 'export_end' ), 15, 0 ); add_action( 'action_scheduler_after_execute', array( $this, 'export_clear' ), 10, 0 ); add_action( 'action_scheduler_failed_execution', array( $this, 'export_clear' ), 10, 0 ); // Arm (or disarm) on every report query. add_filter( 'woocommerce_analytics_orders_query_args', array( $this, 'orders_args' ), 20 ); add_filter( 'woocommerce_analytics_orders_stats_query_args', array( $this, 'stats_args' ), 20 ); add_filter( 'woocommerce_analytics_revenue_query_args', array( $this, 'revenue_args' ), 1 ); add_filter( 'woocommerce_analytics_revenue_select_query', array( $this, 'revenue_result' ), 1 ); // Add the condition. add_filter( 'woocommerce_analytics_clauses_where_orders_subquery', array( $this, 'where_orders' ) ); add_filter( 'woocommerce_analytics_clauses_where_orders_stats_total', array( $this, 'where_stats' ) ); add_filter( 'woocommerce_analytics_clauses_where_orders_stats_interval', array( $this, 'where_stats' ) ); // WooCommerce's own totals clauses for the product and coupon recount, captured first (other // PHP_INT_MIN callbacks added earlier could still run before ours). add_filter( 'woocommerce_analytics_clauses_where_orders_stats_total', array( $this, 'capture_where' ), PHP_INT_MIN ); add_filter( 'woocommerce_analytics_clauses_join_orders_stats_total', array( $this, 'capture_join' ), PHP_INT_MIN ); add_filter( 'woocommerce_analytics_report_should_use_cache', array( $this, 'use_cache' ), 10, 2 ); add_filter( 'woocommerce_analytics_orders_select_query', array( $this, 'orders_result' ), 5, 2 ); add_filter( 'woocommerce_analytics_orders_stats_select_query', array( $this, 'stats_result' ), 5, 2 ); } /** * Filters switched on in settings. * * @since 1.0.0 * @return string[] */ public function enabled_filters() { $on = array_map( 'strval', (array) $this->setting( 'filters' ) ); return array_values( array_intersect( array_keys( Params::FILTERS ), $on ) ); } /** * Address type the location filters match. * * @since 1.0.0 * @return string */ public function address() { return 'shipping' === $this->setting( 'address' ) ? 'shipping' : 'billing'; } /** * The choice lists route. * * @since 1.0.0 * @return void */ public function register_routes() { register_rest_route( 'apwc/v1', self::OPTIONS_ROUTE, array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_options' ), 'args' => array( 'filter' => array( 'description' => __( 'Only these lists (comma-separated): country, state, payment, shipping, role.', 'admin-powerups-for-woocommerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), // The same capability as the Analytics screens and their REST API. 'permission_callback' => static fn() => current_user_can( 'view_woocommerce_reports' ), ) ); } /** * Choice lists for the enabled filters (only the ones asked for with `filter`). * * @since 1.0.0 * @param WP_REST_Request|null $request Request. * @return \WP_REST_Response */ public function rest_options( $request = null ) { $filters = $this->enabled_filters(); $only = $request instanceof WP_REST_Request ? (string) $request->get_param( 'filter' ) : ''; if ( '' !== $only ) { $filters = array_values( array_intersect( $filters, array_map( 'trim', explode( ',', $only ) ) ) ); } return rest_ensure_response( Options::for_js( $filters ) ); } /** * Load the filters on WooCommerce's React admin pages. Analytics is a single-page app, so the * script loads on every wc-admin page, in the head, before WooCommerce loads the Orders report. * * @since 1.0.0 * @return void */ public function enqueue() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- screen detection only. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( 'wc-admin' !== $page || ! current_user_can( 'view_woocommerce_reports' ) ) { return; } $filters = $this->enabled_filters(); if ( ! $filters ) { return; } wp_enqueue_script( 'apwc-analytics-filters', Assets::url( 'assets/js/analytics-filters.js' ), array( 'wp-hooks', 'wp-i18n', 'wp-api-fetch' ), Assets::ver( 'assets/js/analytics-filters.js' ), false ); wp_set_script_translations( 'apwc-analytics-filters', 'admin-powerups-for-woocommerce' ); wp_localize_script( 'apwc-analytics-filters', 'apwcAnalyticsFilters', array( 'filters' => $filters, 'address' => $this->address(), 'path' => '/apwc/v1' . self::OPTIONS_ROUTE, ) ); } /** * A REST request starts: keep our raw params when it's an Orders report route. Runs before * WordPress checks permissions, so it only copies strings (no validation, no queries). * * @since 1.0.0 * @param mixed $response Response so far. * @param mixed $handler Route handler. * @param WP_REST_Request $request Request. * @return mixed Unchanged response. */ public function request_start( $response, $handler, $request ) { if ( ! $request instanceof WP_REST_Request ) { return $response; } // WordPress matches routes case-insensitively, so compare the same way. $route = strtolower( untrailingslashit( (string) $request->get_route() ) ); $type = self::ROUTES[ $route ] ?? ''; $params = array(); if ( '' !== $type ) { $source = '/wc-analytics/reports/orders/export' === $route ? $request->get_param( 'report_args' ) : $request->get_params(); $params = Params::raw( $source ); } $this->frames[] = array( 'type' => $type, 'params' => $params, 'source' => 'rest', 'id' => spl_object_id( $request ), 'depth' => count( $this->stack ), 'request' => $request, 'fiber' => self::fiber(), ); return $response; } /** * A REST request ends: forget its params and anything still armed. * * @since 1.0.0 * @param mixed $response Response. * @param mixed $handler Route handler. * @param WP_REST_Request $request Request. * @return mixed Unchanged response. */ public function request_end( $response, $handler, $request ) { $id = $request instanceof WP_REST_Request ? spl_object_id( $request ) : 0; for ( $i = count( $this->frames ) - 1; $i >= 0; $i-- ) { if ( 'rest' === $this->frames[ $i ]['source'] && $id === $this->frames[ $i ]['id'] ) { // Drop this request's frame, anything left above it, and the queries it left armed. $this->cut( $this->frames[ $i ]['depth'] ); $this->frames = array_slice( $this->frames, 0, $i ); break; } } if ( ! $this->frames ) { // The outermost request is done: nothing it (or anything before it) left may outlive it. // A filtered PHP query that dispatched this request (outside REST) loses its entry too and // answers with the fail-safe error: the safe direction. $this->stack = array(); } return $response; } /** * An export batch starts (Action Scheduler): its report args carry our params. * * @since 1.0.0 * @param mixed $page_number Batch page. * @param mixed $export_id Export ID. * @param mixed $report_type Report type. * @param mixed $report_args Report args. * @return void */ public function export_start( $page_number, $export_id, $report_type, $report_args ) { $this->frames[] = array( 'type' => 'orders' === $report_type ? 'orders' : '', 'params' => 'orders' === $report_type ? Params::raw( $report_args ) : array(), 'source' => 'export', 'id' => 0, 'depth' => count( $this->stack ), 'request' => null, 'fiber' => self::fiber(), ); } /** * An export batch ends. * * @since 1.0.0 * @return void */ public function export_end() { for ( $i = count( $this->frames ) - 1; $i >= 0; $i-- ) { if ( 'export' === $this->frames[ $i ]['source'] ) { $this->cut( $this->frames[ $i ]['depth'] ); $this->frames = array_slice( $this->frames, 0, $i ); break; } } } /** * After any Action Scheduler action (done or failed): no export params may outlive it. * * @since 1.0.0 * @return void */ public function export_clear() { foreach ( $this->frames as $i => $frame ) { if ( 'export' === $frame['source'] ) { $this->cut( $frame['depth'] ); $this->frames = array_slice( $this->frames, 0, $i ); break; } } if ( ! $this->frames ) { $this->stack = array(); // Between Action Scheduler actions nothing is running. } } /** * Orders table query args. * * @since 1.0.0 * @param mixed $args Query args. * @return mixed */ public function orders_args( $args ) { return $this->arm( 'orders', $args ); } /** * Orders stats (chart and summary) query args. * * @since 1.0.0 * @param mixed $args Query args. * @return mixed */ public function stats_args( $args ) { return $this->arm( 'orders_stats', $args ); } /** * Revenue uses the same stats queries: it's never filtered. * * @since 1.0.0 * @param mixed $args Query args. * @return mixed */ public function revenue_args( $args ) { $this->push( 'revenue' ); return $args; } /** * Revenue query done. * * @since 1.0.0 * @param mixed $results Results. * @return mixed */ public function revenue_result( $results ) { $this->pop( 'revenue' ); return $results; } /** * Check the params for a query and arm its condition, or disarm. * * @param string $context 'orders' or 'orders_stats'. * @param mixed $args Query args. * @return mixed */ private function arm( $context, $args ) { $this->push( $context ); // Plain until our condition is ready. if ( ! is_array( $args ) ) { return $args; } // Params passed to the query directly (PHP) win; else the running request's. $source = Params::has_any( $args ) ? $args : $this->frame_params( $context ); foreach ( Params::keys() as $key ) { unset( $args[ $key ] ); } unset( $args['apwc_address'] ); if ( ! $source ) { return $args; } $enabled = $this->enabled_filters(); $known = array(); foreach ( array_intersect( Params::used( $source, $enabled ), array( 'country', 'state', 'role' ) ) as $filter ) { $known[ $filter ] = Options::get( $filter ); } $max = array(); foreach ( array_keys( Params::FILTERS ) as $filter ) { /** * Filter the most values one Analytics order filter rule accepts. More values match no order. * * @since 1.0.0 * @param int $max Most values (50; 20 for city). * @param string $filter Filter: country, state, city, payment, shipping or role. */ $max[ $filter ] = (int) apply_filters( 'apwc_analytics_filters_max_values', 'city' === $filter ? Params::CITY_MAX : Params::MAX, $filter ); } $params = Params::parse( $source, $enabled, $known, $max ); if ( ! $params ) { return $args; } $address = $this->address(); $sql = Conditions::build( $params, isset( $args['match'] ) && 'any' === $args['match'] ? 'any' : 'all', $address ); if ( '' === $sql ) { return $args; } $this->push( $context, $sql, true, self::hash( $params, $args, $address ) ); // Our values join WooCommerce's cache key (the cache is skipped while armed anyway). $args = array_merge( $args, $params ); $args['apwc_address'] = $address; if ( 'orders_stats' === $context ) { $args['segmentby'] = ''; // Segments are counted without our condition. } return $args; } /** * Raw params of the innermost running request or export batch, when it's for this query. * * @param string $context 'orders' or 'orders_stats'. * @return array */ private function frame_params( $context ) { $this->drop_dead_frames(); $fiber = self::fiber(); for ( $i = count( $this->frames ) - 1; $i >= 0; $i-- ) { $frame = $this->frames[ $i ]; // The innermost frame of this Fiber (or of the main context, which every Fiber runs in); // another Fiber's frames never apply here. if ( null === $frame['fiber'] || $fiber === $frame['fiber'] ) { return $context === $frame['type'] ? $frame['params'] : array(); } } return array(); } /** * Drop REST frames whose request is no longer running. A request whose callback threw never * reaches rest_request_after_callbacks; if its caller caught the exception, its frame would * otherwise filter later, unrelated queries. A request is running while it's an argument of a * function on the call stack (the route callback, dispatch). Checked only when a frame could * apply, i.e. once per report query inside a report request. * * @return void */ private function drop_dead_frames() { $top = end( $this->frames ); if ( ! is_array( $top ) || ! $top['request'] instanceof WP_REST_Request ) { return; } $live = array(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- request liveness check, not debugging. foreach ( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT ) as $call ) { foreach ( (array) ( $call['args'] ?? array() ) as $arg ) { if ( $arg instanceof WP_REST_Request ) { $live[ spl_object_id( $arg ) ] = true; } } } $fiber = self::fiber(); for ( $i = count( $this->frames ) - 1; $i >= 0; $i-- ) { $frame = $this->frames[ $i ]; if ( $fiber !== $frame['fiber'] ) { continue; // Made in another Fiber (or outside this one): this backtrace can't see it; keep. } if ( ! $frame['request'] instanceof WP_REST_Request || isset( $live[ spl_object_id( $frame['request'] ) ] ) ) { break; // Live, or an export batch: it and everything below stay. } $this->cut( $frame['depth'] ); array_splice( $this->frames, $i, 1 ); } } /** * Fingerprint of a query's filter (normalized params, match, address), stored with its stack * entry and checked against the query's own args when it ends. * * @param array $params Normalized params (Params::parse()). * @param mixed $args Query args (for match). * @param string $address Address type. * @return string */ private static function hash( array $params, $args, $address ) { $match = is_array( $args ) && isset( $args['match'] ) && 'any' === $args['match'] ? 'any' : 'all'; return md5( (string) wp_json_encode( array( $params, $match, (string) $address ) ) ); } /** * The filter fingerprint a finished query's args carry ('' when unfiltered). * * @param mixed $args Query args. * @return string */ private static function args_hash( $args ) { if ( ! Params::has_any( $args ) || ! is_array( $args ) ) { return ''; } $params = array(); foreach ( Params::keys() as $key ) { if ( isset( $args[ $key ] ) ) { $params[ $key ] = $args[ $key ]; } } return self::hash( $params, $args, isset( $args['apwc_address'] ) ? (string) $args['apwc_address'] : '' ); } /** * Start a query's stack entry (or arm the one just pushed). * * @param string $context Query context. * @param string $sql Our condition ('' = plain). * @param bool $replace Replace the top entry instead of adding one. * @param string $hash Filter fingerprint (see hash()). * @return void */ private function push( $context, $sql = '', $replace = false, $hash = '' ) { $entry = array( 'context' => $context, 'sql' => $sql, 'hash' => $hash, 'query' => self::caller_query(), 'fiber' => self::fiber(), 'applied' => false, 'where' => null, 'join' => null, ); if ( $replace && $this->stack ) { $this->stack[ count( $this->stack ) - 1 ] = $entry; return; } $this->stack[] = $entry; } /** * End the innermost query of a context: remove its entry and anything above it. With a * fingerprint, only an entry armed for that same filter counts (an entry left on top by a nested * query that threw belongs to another filter); none found = null, i.e. "not applied". * * @param string $context Query context. * @param string|null $hash Filter fingerprint the query's args carry ('' = plain; null = any). * @return array{context: string, sql: string, hash: string, applied: bool, where: array|null, join: array|null}|null */ private function pop( $context, $hash = null ) { $last = null; $running = self::running_queries(); for ( $i = count( $this->stack ) - 1; $i >= 0; $i-- ) { if ( $context !== $this->stack[ $i ]['context'] ) { continue; } if ( ! self::visible( $this->stack[ $i ] ) ) { continue; } $last = null === $last ? $i : $last; if ( ! self::is_live( $this->stack[ $i ], $running ) ) { continue; // A query that threw (even with the same fingerprint) is never ours. } if ( null === $hash || '' === $hash || $hash === $this->stack[ $i ]['hash'] ) { $entry = $this->stack[ $i ]; $this->cut( $i ); return $entry; } } if ( null !== $last ) { $this->cut( $last ); } return null; } /** * Cut the stack back to a depth. * * @param int $depth Entries to keep. * @return void */ private function cut( $depth ) { $fiber = self::fiber(); $keep = array(); foreach ( $this->stack as $i => $entry ) { // Entries of another Fiber (suspended mid-query) aren't this context's to remove. if ( $i < $depth || $fiber !== $entry['fiber'] ) { $keep[] = $entry; } } $this->stack = $keep; } /** * The innermost query's entry when it's armed for a context, else null. * * @param string $context Query context. * @return int|null Stack index. */ private function armed_index( $context ) { if ( ! $this->stack ) { return null; } $running = self::running_queries(); for ( $i = count( $this->stack ) - 1; $i >= 0; $i-- ) { $entry = $this->stack[ $i ]; if ( ! self::visible( $entry ) || ! self::is_live( $entry, $running ) ) { continue; // Another Fiber's, or stale: its query threw and is gone. } // The innermost running query decides: armed for this context, or nothing. return $context === $entry['context'] && '' !== $entry['sql'] ? $i : null; } return null; } /** * Whether a stack entry's query is still running. Entries without a Query object (a caller * that doesn't go through WC_Object_Query::get_data()) count as running: if such a caller throws * outside REST, its entry stays until the next REST request or Action Scheduler action ends. * debug_backtrace() only sees the current Fiber's frames (or only the main ones outside a * Fiber), so only entries made in the current Fiber are checked; others count as running. * * @param array $entry Stack entry. * @param array $running Running Query objects (running_queries()). * @return bool */ private static function is_live( array $entry, array $running ) { return null === $entry['query'] || self::fiber() !== $entry['fiber'] || in_array( $entry['query'], $running, true ); } /** * Whether a stack entry belongs to this context: made in the current Fiber, or in the main * context (which every Fiber runs inside). * * @param array $entry Stack entry. * @return bool */ private static function visible( array $entry ) { return null === $entry['fiber'] || self::fiber() === $entry['fiber']; } /** * The Fiber this code runs in (null outside any Fiber). * * @return object|null */ private static function fiber() { return class_exists( \Fiber::class, false ) ? \Fiber::getCurrent() : null; } /** * The report Query object whose get_data() is running this code (null when not found). * * @return object|null */ private static function caller_query() { $running = self::running_queries(); return $running ? $running[0] : null; } /** * Report Query objects whose get_data() is on the call stack, innermost first. * * @return array */ private static function running_queries() { $out = array(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- which report query is running, not debugging. foreach ( debug_backtrace( DEBUG_BACKTRACE_PROVIDE_OBJECT | DEBUG_BACKTRACE_IGNORE_ARGS ) as $call ) { if ( 'get_data' === $call['function'] && isset( $call['object'] ) && $call['object'] instanceof \WC_Object_Query ) { $out[] = $call['object']; } } return $out; } /** * Forget every query in progress. * * @since 1.0.0 * @return void */ public function disarm() { $this->stack = array(); } /** * The context of the innermost query when it's armed (tests and add-ons). * * @since 1.0.0 * @return string '' or the armed context. */ public function armed() { $top = end( $this->stack ); return is_array( $top ) && '' !== $top['sql'] ? $top['context'] : ''; } /** * Orders table: add the condition. * * @since 1.0.0 * @param mixed $clauses Where clauses. * @return mixed */ public function where_orders( $clauses ) { return $this->append( 'orders', $clauses ); } /** * Stats totals and intervals: add the condition. * * @since 1.0.0 * @param mixed $clauses Where clauses. * @return mixed */ public function where_stats( $clauses ) { return $this->append( 'orders_stats', $clauses ); } /** * Add the armed condition to a query's where clauses. * * @param string $context Query context. * @param mixed $clauses Clauses. * @return mixed */ private function append( $context, $clauses ) { $i = $this->armed_index( $context ); if ( null === $i || ! is_array( $clauses ) ) { return $clauses; } $clauses[] = 'AND ' . $this->stack[ $i ]['sql']; $this->stack[ $i ]['applied'] = true; return $clauses; } /** * Keep WooCommerce's own totals where clauses (incl. the date range) for the recount. * * @since 1.0.0 * @param mixed $clauses Clauses. * @return mixed Unchanged. */ public function capture_where( $clauses ) { $i = $this->armed_index( 'orders_stats' ); if ( null !== $i && is_array( $clauses ) ) { $this->stack[ $i ]['where'] = $clauses; } return $clauses; } /** * Keep WooCommerce's own totals joins for the recount. * * @since 1.0.0 * @param mixed $clauses Clauses. * @return mixed Unchanged. */ public function capture_join( $clauses ) { $i = $this->armed_index( 'orders_stats' ); if ( null !== $i && is_array( $clauses ) ) { $this->stack[ $i ]['join'] = $clauses; } return $clauses; } /** * Skip the report cache while armed: the cached result wouldn't have run our condition. * * @since 1.0.0 * @param mixed $use_cache Use the cache. * @param mixed $cache_key Report cache key ('orders', 'orders_stats', …). * @return mixed */ public function use_cache( $use_cache, $cache_key ) { if ( is_string( $cache_key ) && null !== $this->armed_index( $cache_key ) ) { return false; } return $use_cache; } /** * Orders table result: never return rows our condition didn't filter. Whether it had to be * filtered is decided from the query's own args (our normalized params are in them), not from * state that a nested query could have changed. * * @since 1.0.0 * @param mixed $results Results. * @param mixed $args Query args. * @return mixed */ public function orders_result( $results, $args = array() ) { $entry = $this->pop( 'orders', self::args_hash( $args ) ); if ( ! Params::has_any( $args ) || ( $entry && '' !== $entry['sql'] && $entry['applied'] ) ) { return $results; } if ( is_object( $results ) && ( ! empty( $results->data ) || ! empty( $results->total ) ) ) { return new WP_Error( 'apwc_analytics_filters_not_applied', self::not_applied(), array( 'status' => 500 ) ); } return $results; } /** * Stats result: recount the products and coupons in the summary with our condition, and never * return numbers our condition didn't filter. * * @since 1.0.0 * @param mixed $results Results. * @param mixed $args Query args. * @return mixed * @throws ParameterException When the condition didn't run (WooCommerce turns it into an error response). */ public function stats_result( $results, $args = array() ) { $entry = $this->pop( 'orders_stats', self::args_hash( $args ) ); if ( ! Params::has_any( $args ) ) { return $results; } if ( ! $entry || '' === $entry['sql'] || ! $entry['applied'] ) { if ( self::has_stats( $results ) ) { throw new ParameterException( 'apwc_analytics_filters_not_applied', esc_html( self::not_applied() ), 500 ); } return $results; } if ( is_object( $results ) && isset( $results->totals ) && is_object( $results->totals ) ) { $fields = is_array( $args ) ? ( $args['fields'] ?? array() ) : array(); $want = array( 'products' => self::wants( $fields, 'products' ), 'coupons' => self::wants( $fields, 'coupons_count' ), ); if ( ! $want['products'] && ! $want['coupons'] ) { // Not asked for: drop WooCommerce's unfiltered counts instead of recounting. unset( $results->totals->products, $results->totals->coupons_count ); return $results; } if ( ! is_array( $entry['where'] ) ) { throw new ParameterException( 'apwc_analytics_filters_not_applied', esc_html( self::not_applied() ), 500 ); } $counts = $this->recount( $entry, $want ); foreach ( array( 'products' => 'products', 'coupons' => 'coupons_count', ) as $key => $field ) { if ( $want[ $key ] ) { $results->totals->{$field} = $counts[ $key ]; } else { unset( $results->totals->{$field} ); } } } return $results; } /** * Whether the stats request asked for a total (no field list = all). * * @param mixed $fields Requested fields. * @param string $field Field. * @return bool */ private static function wants( $fields, $field ) { if ( is_string( $fields ) ) { $fields = array_filter( array_map( 'trim', explode( ',', $fields ) ) ); } if ( ! is_array( $fields ) || ! $fields || in_array( '*', $fields, true ) ) { return true; } return in_array( $field, $fields, true ); } /** * The fail-safe message. * * @return string */ private static function not_applied() { return __( 'The Analytics order filters couldn’t be applied, so no results are shown. Remove them or try again.', 'admin-powerups-for-woocommerce' ); } /** * Whether a stats result holds numbers. * * @param mixed $results Results. * @return bool */ private static function has_stats( $results ) { if ( ! is_object( $results ) ) { return false; } $rows = array(); if ( isset( $results->totals ) ) { $rows[] = (array) $results->totals; } foreach ( (array) ( $results->intervals ?? array() ) as $interval ) { $rows[] = (array) ( ( (array) $interval )['subtotals'] ?? $interval ); } foreach ( $rows as $row ) { foreach ( array( 'orders_count', 'num_items_sold', 'net_revenue', 'total_sales' ) as $key ) { if ( ! empty( $row[ $key ] ) && 0.0 !== (float) $row[ $key ] ) { return true; } } } return false; } /** * Count distinct products and coupons like WooCommerce's summary does (its own joins, date * range and where clauses, not other plugins' clause filters), plus our condition. * * @param array $armed Armed state with the captured clauses. * @param array $want Counts to run: products, coupons. * @return array{products: int, coupons: int} * @throws ParameterException When a count query fails (never report a wrong 0). */ private function recount( array $armed, array $want ) { global $wpdb; $stats = Conditions::stats_table(); $joins = array(); foreach ( (array) $armed['join'] as $join ) { // Skip WooCommerce's per-order coupon totals subquery (left join): not needed to count. if ( is_string( $join ) && ! ( false !== stripos( $join, 'LEFT JOIN' ) && false !== strpos( $join, 'order_coupon_lookup' ) ) ) { $joins[] = $join; } } $join = implode( ' ', $joins ); $where = implode( ' ', array_filter( (array) $armed['where'], 'is_string' ) ) . ' AND ' . $armed['sql']; $tail = " {$join} WHERE 1=1 {$where}"; $products = $wpdb->prefix . 'wc_order_product_lookup'; $coupons = $wpdb->prefix . 'wc_order_coupon_lookup'; $products = $wpdb->prepare( 'SELECT COUNT( DISTINCT %i.product_id ) FROM %i JOIN %i ON %i.order_id = %i.order_id', $products, $products, $stats, $products, $stats ) . $tail; $coupons = $wpdb->prepare( 'SELECT COUNT( DISTINCT %i.coupon_id ) FROM %i JOIN %i ON %i.order_id = %i.order_id', $coupons, $coupons, $stats, $coupons, $stats ) . $tail; // $tail is WooCommerce's own report clauses (built by its data store) plus our prepared condition. // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- see above; report numbers are never cached while filtered (WooCommerce's cache is skipped too). $counts = array( 'products' => 0, 'coupons' => 0, ); foreach ( array( 'products' => $products, 'coupons' => $coupons, ) as $key => $sql ) { if ( empty( $want[ $key ] ) ) { continue; } $count = $wpdb->get_var( $sql ); // nosemgrep: apwc-wpdb-unprepared-variable -- identifiers prepared above, clauses from WooCommerce's data store + our prepared condition. if ( null === $count || '' !== $wpdb->last_error ) { // query() clears last_error first. throw new ParameterException( 'apwc_analytics_filters_count_failed', esc_html__( 'The Analytics order filters couldn’t count the summary, so no results are shown. Try again.', 'admin-powerups-for-woocommerce' ), 500 ); } $counts[ $key ] = (int) $count; } // phpcs:enable return $counts; } }