PluginProbe
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO / 1.4.14
Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO v1.4.14
1.4.14 1.4.13 1.4.12 1.4.11 1.4.10 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.54 1.3.53 1.3.52 1.3.51 1.3.50 1.3.49 1.3.48 1.3.47 1.3.46 1.3.45 All 177 releases
disco / app / Analytics / Queries / CampaignQuery.php

CampaignQuery.php in Discount Rules for WooCommerce – Disco | Dynamic Pricing, Conditions, Bulk, Bundle, BOGO 1.4.14, at app/Analytics/Queries/CampaignQuery.php

415 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * CampaignQuery — focused queries for campaign data.
5 *
6 * @package Disco
7 * @subpackage Disco\App\Analytics\Queries
8 * @since 1.3.37
9 */
10
11 namespace Disco\App\Analytics\Queries;
12
13 /**
14 * Handles all single-purpose SQL queries related to campaigns.
15 *
16 * Each method does exactly ONE job.
17 * LEFT JOINs to the campaigns table preserve data for deleted campaigns.
18 */
19 class CampaignQuery extends BaseQuery {
20
21 /**
22 * Only completed orders count toward campaign metrics.
23 *
24 * Keeps orders_count / revenue / customers_count from including on-hold,
25 * cancelled, refunded, or other non-final order statuses.
26 */
27 private const STATUS_COMPLETED = 'wc-completed';
28
29 // =========================================================================
30 // List / paginated queries (used by CampaignService for REST list endpoints)
31 // =========================================================================
32
33 /**
34 * Returns a paginated campaign list with aggregate order metrics.
35 *
36 * Runs three separate queries:
37 * 1. COUNT — how many distinct campaigns match the filters.
38 * 2. AGG — paginated campaign_id + metrics (no JOIN with campaigns table).
39 * 3. META — campaign name/intent/status/data for the IDs found in step 2.
40 * PHP merges the results. No subqueries, no multi-level prepare issues.
41 *
42 * @param array $args search, date_from, date_to, sort_by, page, per_page.
43 * @return array { total: int, pages: int, rows: array }
44 */
45 public function get_campaign_list( array $args ): array {
46 $context = $this->build_list_context( $args );
47 $pagination = $this->resolve_pagination( $args );
48 $sort = $this->resolve_campaign_sort( $args );
49
50 // Name search matched no campaigns — nothing can match downstream.
51 if ( $context['no_match'] ) {
52 return $this->format_list_result( 0, $pagination['per_page'], array() );
53 }
54
55 // QUERY 1: COUNT distinct campaigns.
56 $total = $this->run_count( $this->build_count_sql( $context ), $context['params'] );
57
58 if ( 0 === $total ) {
59 return $this->format_list_result( 0, $pagination['per_page'], array() );
60 }
61
62 // QUERY 2: Aggregated metrics per campaign (paginated).
63 $agg_rows = $this->fetch_campaign_aggregates( $context, $sort, $pagination );
64
65 if ( empty( $agg_rows ) ) {
66 return $this->format_list_result( $total, $pagination['per_page'], array() );
67 }
68
69 // QUERY 3 + merge: campaign meta mapped onto aggregated rows.
70 $campaign_map = $this->fetch_campaign_meta_map( $agg_rows, $context['tables'] );
71 $rows = $this->merge_campaign_rows( $agg_rows, $campaign_map );
72
73 return $this->format_list_result( $total, $pagination['per_page'], $rows );
74 }
75
76 /**
77 * Returns aggregate metrics for a single campaign in a period.
78 *
79 * @param int $campaign_id Campaign ID.
80 * @param array $period { from: string, to: string } — empty strings skip date filter.
81 * @return object|null
82 */
83 public function get_campaign( int $campaign_id, array $period ) {
84 $tables = $this->get_tables();
85
86 /** @var object{campaign_id: int|string, orders_count: int|string, customers_count: int|string, revenue: string|null}|null $aggregates */
87 $aggregates = $this->fetch_campaign_aggregate_row( $campaign_id, $period, $tables );
88
89 if ( ! $aggregates ) {
90 return null;
91 }
92
93 // QUERY 2 + merge: campaign meta resolved separately, like the list query.
94 $campaign_map = $this->fetch_campaign_meta_map( array( $aggregates ), $tables );
95 $campaign = $campaign_map[ $campaign_id ] ?? null;
96
97 return (object) array(
98 'campaign_id' => (int) $aggregates->campaign_id,
99 'intent' => $campaign->intent ?? 'Unknown',
100 'status' => $this->resolve_campaign_status( $campaign ),
101 'is_deleted' => null === $campaign ? 1 : 0,
102 'campaign_data' => $campaign->data ?? null,
103 'orders_count' => $aggregates->orders_count,
104 'customers_count' => $aggregates->customers_count,
105 'revenue' => $aggregates->revenue,
106 );
107 }
108
109 /**
110 * Fetches the aggregate metrics row for a single campaign (no campaigns JOIN).
111 *
112 * @param int $campaign_id Campaign ID.
113 * @param array $period { from: string, to: string } — empty strings skip date filter.
114 * @param array $tables From get_tables().
115 * @return object|null Aggregate row, or null when the campaign has no matching orders.
116 */
117 private function fetch_campaign_aggregate_row( int $campaign_id, array $period, array $tables ) {
118 global $wpdb;
119
120 $clauses = $this->get_order_clauses( $tables );
121 $order_id_column = $tables['order_id_col'];
122 $where_conditions = array( 'order_meta.meta_key = %s', 'CAST(order_meta.meta_value AS UNSIGNED) = %d', 'o.status = %s', $this->get_campaign_dedup_condition( $tables ) );
123 $query_params = array( 'disco_campaign', $campaign_id, self::STATUS_COMPLETED );
124
125 if ( ! empty( $period['from'] ) ) {
126 $where_conditions[] = "{$clauses['date_col']} >= %s";
127 $query_params[] = $period['from'] . ' 00:00:00';
128 }
129
130 if ( ! empty( $period['to'] ) ) {
131 $where_conditions[] = "{$clauses['date_col']} <= %s";
132 $query_params[] = $period['to'] . ' 23:59:59';
133 }
134
135 $where_clause = 'WHERE ' . implode( ' AND ', $where_conditions );
136
137 $sql = "SELECT
138 CAST(order_meta.meta_value AS UNSIGNED) AS campaign_id,
139 COUNT(DISTINCT order_meta.{$order_id_column}) AS orders_count,
140 COUNT(DISTINCT CASE WHEN {$clauses['customer_expr']} != 0 THEN CAST({$clauses['customer_expr']} AS CHAR) ELSE o.billing_email END) AS customers_count,
141 SUM({$clauses['total_expr']} - COALESCE(order_stats.shipping_total, 0)) AS revenue
142 FROM {$tables['order_meta']} order_meta
143 JOIN {$tables['orders']} o ON o.ID = order_meta.{$order_id_column} AND {$clauses['status_where']}
144 LEFT JOIN {$wpdb->prefix}wc_order_stats order_stats ON order_stats.order_id = order_meta.{$order_id_column}
145 {$clauses['total_join']}
146 {$clauses['customer_join']}
147 {$where_clause}
148 GROUP BY CAST(order_meta.meta_value AS UNSIGNED)";
149
150 return $this->run_row( $sql, $query_params );
151 }
152
153 /**
154 * Builds WHERE conditions and params for the campaign list filters.
155 *
156 * Numeric search matches the campaign ID. Text search pre-resolves matching
157 * campaign IDs in a separate small query, so the COUNT and aggregate queries
158 * need no campaigns JOIN and no per-row JSON_EXTRACT; no_match short-circuits
159 * the list to an empty result.
160 *
161 * @param array $args search, date_from, date_to, etc.
162 * @return array { tables, clauses, id_col, where, params, no_match }
163 */
164 private function build_list_context( array $args ): array {
165 $tables = $this->get_tables();
166 $clauses = $this->get_order_clauses( $tables );
167 $common = $this->build_common_conditions( $args, $clauses, $tables );
168 $conditions = array_merge(
169 array( 'order_meta.meta_key = %s', 'o.status = %s', $this->get_campaign_dedup_condition( $tables ) ),
170 $common['conditions']
171 );
172 $params = array_merge( array( 'disco_campaign', self::STATUS_COMPLETED ), $common['params'] );
173
174 $search = $args['search'] ?? '';
175 $no_match = false;
176
177 if ( is_numeric( $search ) && '' !== $search ) {
178 // Sargable string compare so the (meta_key, meta_value) index is used.
179 $conditions[] = 'order_meta.meta_value = %s';
180 $params[] = (string) (int) $search;
181 } elseif ( ! empty( $search ) ) {
182 $matching_ids = $this->find_campaign_ids_by_name( $search, $tables );
183
184 if ( empty( $matching_ids ) ) {
185 $no_match = true;
186 } else {
187 $id_placeholders = implode( ',', array_fill( 0, count( $matching_ids ), '%s' ) );
188 // Sargable string compare so the (meta_key, meta_value) index is used.
189 $conditions[] = "order_meta.meta_value IN ({$id_placeholders})";
190 $params = array_merge( $params, $matching_ids );
191 }
192 }
193
194 return array(
195 'tables' => $tables,
196 'clauses' => $clauses,
197 'id_col' => $tables['order_id_col'],
198 'where' => 'WHERE ' . implode( ' AND ', $conditions ),
199 'params' => $params,
200 'no_match' => $no_match,
201 );
202 }
203
204 /**
205 * Returns the IDs of campaigns whose JSON name matches the search term.
206 *
207 * The campaigns table only holds user-created campaigns, so the result
208 * (and the IN list built from it) stays small and bounded.
209 *
210 * @param string $search Raw search term.
211 * @param array $tables From get_tables().
212 * @return array<string> Campaign IDs as bare digit strings (matching meta_value storage).
213 */
214 private function find_campaign_ids_by_name( string $search, array $tables ): array {
215 global $wpdb;
216
217 $rows = $this->run_rows(
218 "SELECT id FROM {$tables['campaigns']} WHERE LOWER(JSON_UNQUOTE(JSON_EXTRACT(data, '$.name'))) LIKE LOWER(%s)",
219 array( '%' . $wpdb->esc_like( $search ) . '%' )
220 );
221
222 return array_map(
223 function ( $row ) {
224 return (string) (int) $row->id;
225 },
226 $rows
227 );
228 }
229
230 /**
231 * Resolves the aggregate column and direction to sort the campaign list by.
232 *
233 * Accepts sort_by (REST param) or orderby; whitelist: orders, customers, revenue.
234 *
235 * @param array $args sort_by / orderby, order.
236 * @return array { column: string, direction: 'ASC'|'DESC' }
237 */
238 private function resolve_campaign_sort( array $args ): array {
239 $args['orderby'] = $args['sort_by'] ?? $args['orderby'] ?? '';
240
241 $sort = $this->resolve_sort( $args, array( 'orders', 'customers', 'revenue' ), 'revenue' );
242
243 $sort_col_map = array(
244 'orders' => 'total_orders',
245 'customers' => 'total_customers',
246 'revenue' => 'total_revenue',
247 );
248
249 return array(
250 'column' => $sort_col_map[ $sort['orderby'] ],
251 'direction' => $sort['direction'],
252 );
253 }
254
255 /**
256 * Builds the COUNT(DISTINCT campaign) SQL for the current filters.
257 *
258 * @param array $context From build_list_context().
259 * @return string COUNT SQL with placeholders matching context params.
260 */
261 private function build_count_sql( array $context ): string {
262 $tables = $context['tables'];
263 $clauses = $context['clauses'];
264
265 return "SELECT COUNT(DISTINCT CAST(order_meta.meta_value AS UNSIGNED))
266 FROM {$tables['order_meta']} order_meta
267 JOIN {$tables['orders']} o ON o.ID = order_meta.{$context['id_col']} AND {$clauses['status_where']}
268 {$context['where']}";
269 }
270
271 /**
272 * Fetches the paginated per-campaign aggregate rows (orders, customers, revenue).
273 *
274 * No JOIN with the campaigns table — meta is resolved separately so deleted
275 * campaigns keep their metrics.
276 *
277 * @param array $context From build_list_context().
278 * @param array $sort From resolve_campaign_sort().
279 * @param array $pagination From resolve_pagination().
280 * @return array Aggregate row objects.
281 */
282 private function fetch_campaign_aggregates( array $context, array $sort, array $pagination ): array {
283 global $wpdb;
284
285 $tables = $context['tables'];
286 $clauses = $context['clauses'];
287 $order_id_column = $context['id_col'];
288
289 $agg_sql = "SELECT
290 CAST(order_meta.meta_value AS UNSIGNED) AS campaign_id,
291 COUNT(DISTINCT order_meta.{$order_id_column}) AS total_orders,
292 COUNT(DISTINCT CASE WHEN {$clauses['customer_expr']} != 0 THEN CAST({$clauses['customer_expr']} AS CHAR) ELSE o.billing_email END) AS total_customers,
293 SUM({$clauses['total_expr']} - COALESCE(order_stats.shipping_total, 0)) AS total_revenue
294 FROM {$tables['order_meta']} order_meta
295 JOIN {$tables['orders']} o ON o.ID = order_meta.{$order_id_column} AND {$clauses['status_where']}
296 LEFT JOIN {$wpdb->prefix}wc_order_stats order_stats ON order_stats.order_id = order_meta.{$order_id_column}
297 {$context['where']}
298 GROUP BY CAST(order_meta.meta_value AS UNSIGNED)
299 ORDER BY {$sort['column']} {$sort['direction']}
300 LIMIT %d OFFSET %d";
301
302 $params = array_merge( $context['params'], array( $pagination['per_page'], $pagination['offset'] ) );
303
304 return $this->run_rows( $agg_sql, $params );
305 }
306
307 /**
308 * Fetches id/intent/status/data for the campaigns present in the aggregate rows.
309 *
310 * @param array $agg_rows Aggregate rows from fetch_campaign_aggregates().
311 * @param array $tables From get_tables().
312 * @return array Map of campaign_id => campaign row object.
313 */
314 private function fetch_campaign_meta_map( array $agg_rows, array $tables ): array {
315 $campaign_ids = array_map(
316 function ( $row ) {
317 return (int) $row->campaign_id;
318 },
319 $agg_rows
320 );
321
322 $placeholders = implode( ',', array_fill( 0, count( $campaign_ids ), '%d' ) );
323
324 $campaign_rows = $this->run_rows(
325 "SELECT id, intent, status, data FROM {$tables['campaigns']} WHERE id IN ({$placeholders})",
326 $campaign_ids
327 );
328
329 $campaign_map = array();
330
331 foreach ( $campaign_rows as $campaign_row ) {
332 $campaign_map[ (int) $campaign_row->id ] = $campaign_row;
333 }
334
335 return $campaign_map;
336 }
337
338 /**
339 * Merges campaign meta (name/intent/status) onto the aggregated metric rows.
340 *
341 * Campaigns missing from the map are flagged 'deleted' with name 'Unknown'.
342 *
343 * @param array $agg_rows Aggregate rows from fetch_campaign_aggregates().
344 * @param array $campaign_map Map from fetch_campaign_meta_map().
345 * @return array Final merged row objects.
346 */
347 private function merge_campaign_rows( array $agg_rows, array $campaign_map ): array {
348 $rows = array();
349
350 foreach ( $agg_rows as $row ) {
351 $cid = (int) $row->campaign_id;
352 $campaign = $campaign_map[ $cid ] ?? null;
353
354 $rows[] = (object) array(
355 'campaign_id' => $cid,
356 'campaign_name' => $this->resolve_campaign_name( $campaign ),
357 'intent' => $campaign->intent ?? 'Unknown',
358 'status' => $this->resolve_campaign_status( $campaign ),
359 'campaign_data' => $campaign->data ?? null,
360 'total_orders' => $row->total_orders,
361 'total_customers' => $row->total_customers,
362 'total_revenue' => $row->total_revenue,
363 );
364 }
365
366 return $rows;
367 }
368
369 /**
370 * Resolves the display name from a campaign row's JSON data column.
371 *
372 * @param object|null $campaign Campaign row, or null when deleted.
373 * @return string Campaign name, 'Unknown' when unresolvable.
374 * @phpstan-param object{data: string|null}|null $campaign
375 */
376 private function resolve_campaign_name( ?object $campaign ): string {
377 if ( null === $campaign ) {
378 return 'Unknown';
379 }
380
381 $campaign_data = json_decode( (string) $campaign->data, true );
382 $name = '';
383
384 if ( is_array( $campaign_data ) ) {
385 $name = $campaign_data['name'] ?? '';
386 }
387
388 if ( '' !== $name ) {
389 return $name;
390 }
391
392 return 'Unknown';
393 }
394
395 /**
396 * Resolves the status label for a campaign row.
397 *
398 * @param object|null $campaign Campaign row, or null when deleted.
399 * @return string One of 'deleted' | 'active' | 'inactive'.
400 * @phpstan-param object{status: string|null}|null $campaign
401 */
402 private function resolve_campaign_status( ?object $campaign ): string {
403 if ( null === $campaign ) {
404 return 'deleted';
405 }
406
407 if ( '1' === $campaign->status ) {
408 return 'active';
409 }
410
411 return 'inactive';
412 }
413
414 }
415