PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.4
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.4
4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 4.2.1 All 138 releases
learnpress / inc / Statistics / StatisticsScope.php

StatisticsScope.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.4, at inc/Statistics/StatisticsScope.php

212 lines 6.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class StatisticsScope
4 *
5 * @package LearnPress/Classes/Statistics
6 * @since 4.4.2
7 */
8
9 namespace LearnPress\Statistics;
10
11 use InvalidArgumentException;
12 use LP_Filter;
13
14 defined( 'ABSPATH' ) || exit();
15
16 /**
17 * Scope statistics queries by instructor and/or course category.
18 *
19 * Single sanitation boundary for the global dashboard filters: request data
20 * only enters via from_params(), everything downstream trusts the typed DTO.
21 *
22 * @since 4.4.2
23 */
24 class StatisticsScope {
25 /**
26 * @var int
27 */
28 public $instructor_id = 0;
29 /**
30 * @var int
31 */
32 public $category_id = 0;
33
34 /**
35 * Identifier allowlists: join fields are hardcoded per caller,
36 * never interpolated from request data.
37 * ui2 = parent course user_item row; s = learnpress_sections.
38 */
39 private const COURSE_ID_FIELDS = array( 'oi.item_id', 'ui.item_id', 'p.ID', 'ui2.item_id', 's.section_course_id' );
40 private const ORDER_ID_FIELDS = array( 'p.ID' );
41
42 /**
43 * Build a scope from request params. Negative/garbage values collapse to 0 (= unscoped).
44 *
45 * @param array $params Request params, may contain instructor_id/category_id.
46 * @return StatisticsScope
47 */
48 public static function from_params( array $params ): StatisticsScope {
49 $scope = new self();
50 $scope->instructor_id = absint( $params['instructor_id'] ?? 0 );
51 $scope->category_id = absint( $params['category_id'] ?? 0 );
52
53 /**
54 * Filter the resolved statistics scope before it shapes any query.
55 *
56 * Fires once per request at the single scope sanitation boundary, so a
57 * handler here reaches every scoped query and report across all tabs
58 * ( e.g. force a teacher role to only their own courses ).
59 *
60 * @param StatisticsScope $scope Resolved scope.
61 * @param array $params Sanitized request params.
62 * @since 4.4.2
63 */
64 $scope = apply_filters( 'learn-press/statistics/scope', $scope, $params );
65
66 // Poka-yoke: a handler returning the wrong type falls back to an unscoped scope.
67 return $scope instanceof self ? $scope : new self();
68 }
69
70 /**
71 * @return bool
72 */
73 public function is_empty(): bool {
74 return 0 === $this->instructor_id && 0 === $this->category_id;
75 }
76
77 /**
78 * Stable identity for cache keys — two scopes with the same ids share a cache entry.
79 *
80 * @return array [ int instructor_id, int category_id ]
81 */
82 public function signature(): array {
83 return array( $this->instructor_id, $this->category_id );
84 }
85
86 /**
87 * Add scope joins/where to a query whose rows already carry a course id column.
88 *
89 * @param LP_Filter $filter Query filter to extend.
90 * @param string $course_id_field One of COURSE_ID_FIELDS.
91 * @return LP_Filter
92 * @throws InvalidArgumentException On a course id field outside the allowlist.
93 */
94 public function apply( LP_Filter $filter, string $course_id_field ): LP_Filter {
95 if ( ! in_array( $course_id_field, self::COURSE_ID_FIELDS, true ) ) {
96 throw new InvalidArgumentException( 'Unknown course id field for statistics scope.' );
97 }
98
99 if ( $this->is_empty() ) {
100 return $filter;
101 }
102
103 global $wpdb;
104
105 if ( $this->instructor_id > 0 ) {
106 $filter->join[] = "INNER JOIN {$wpdb->posts} AS scope_p ON scope_p.ID = {$course_id_field}";
107 $filter->where[] = $wpdb->prepare( 'AND scope_p.post_author = %d', $this->instructor_id );
108 }
109
110 if ( $this->category_id > 0 ) {
111 $filter->join[] = "INNER JOIN {$wpdb->term_relationships} AS scope_tr ON scope_tr.object_id = {$course_id_field}";
112 $filter->join[] = $wpdb->prepare(
113 "INNER JOIN {$wpdb->term_taxonomy} AS scope_tt ON scope_tt.term_taxonomy_id = scope_tr.term_taxonomy_id AND scope_tt.taxonomy = %s",
114 LP_COURSE_CATEGORY_TAX
115 );
116 $filter->where[] = $wpdb->prepare( 'AND scope_tt.term_id = %d', $this->category_id );
117 }
118
119 return $filter;
120 }
121
122 /**
123 * Scope an orders query that has no course id column: keep orders containing
124 * at least one course item matching the scope.
125 *
126 * Uses EXISTS instead of a join so an order with several scoped items still
127 * counts once — order-level COUNT/SUM fields stay correct without DISTINCT rewrites.
128 *
129 * @param LP_Filter $filter Query filter to extend.
130 * @param string $order_id_field One of ORDER_ID_FIELDS.
131 * @return LP_Filter
132 * @throws InvalidArgumentException On an order id field outside the allowlist.
133 */
134 public function apply_to_orders( LP_Filter $filter, string $order_id_field ): LP_Filter {
135 if ( ! in_array( $order_id_field, self::ORDER_ID_FIELDS, true ) ) {
136 throw new InvalidArgumentException( 'Unknown order id field for statistics scope.' );
137 }
138
139 if ( $this->is_empty() ) {
140 return $filter;
141 }
142
143 global $wpdb;
144
145 $tb_order_items = $wpdb->prefix . 'learnpress_order_items';
146 $joins = '';
147 $conditions = '';
148
149 if ( $this->instructor_id > 0 ) {
150 $joins .= " INNER JOIN {$wpdb->posts} AS scope_p ON scope_p.ID = scope_oi.item_id";
151 $conditions .= $wpdb->prepare( ' AND scope_p.post_author = %d', $this->instructor_id );
152 }
153
154 if ( $this->category_id > 0 ) {
155 $joins .= " INNER JOIN {$wpdb->term_relationships} AS scope_tr ON scope_tr.object_id = scope_oi.item_id";
156 $joins .= $wpdb->prepare(
157 " INNER JOIN {$wpdb->term_taxonomy} AS scope_tt ON scope_tt.term_taxonomy_id = scope_tr.term_taxonomy_id AND scope_tt.taxonomy = %s",
158 LP_COURSE_CATEGORY_TAX
159 );
160 $conditions .= $wpdb->prepare( ' AND scope_tt.term_id = %d', $this->category_id );
161 }
162
163 $filter->where[] = "AND EXISTS ( SELECT 1 FROM {$tb_order_items} AS scope_oi{$joins} WHERE scope_oi.order_id = {$order_id_field}{$conditions} )";
164
165 return $filter;
166 }
167
168 /**
169 * Prepared "AND EXISTS(...)" conditions for raw SQL queries (subselects,
170 * HAVING-grouped queries) where LP_Filter joins do not reach.
171 *
172 * EXISTS-based so it is alias-collision-free and never duplicates rows.
173 * Returns '' when the scope is empty.
174 *
175 * @param string $course_id_field One of COURSE_ID_FIELDS.
176 * @return string
177 * @throws InvalidArgumentException On a course id field outside the allowlist.
178 */
179 public function sql_conditions( string $course_id_field ): string {
180 if ( ! in_array( $course_id_field, self::COURSE_ID_FIELDS, true ) ) {
181 throw new InvalidArgumentException( 'Unknown course id field for statistics scope.' );
182 }
183
184 if ( $this->is_empty() ) {
185 return '';
186 }
187
188 global $wpdb;
189
190 $conditions = '';
191
192 if ( $this->instructor_id > 0 ) {
193 $conditions .= $wpdb->prepare(
194 " AND EXISTS ( SELECT 1 FROM {$wpdb->posts} AS scope_sp WHERE scope_sp.ID = {$course_id_field} AND scope_sp.post_author = %d )",
195 $this->instructor_id
196 );
197 }
198
199 if ( $this->category_id > 0 ) {
200 $conditions .= $wpdb->prepare(
201 " AND EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} AS scope_str"
202 . " INNER JOIN {$wpdb->term_taxonomy} AS scope_stt ON scope_stt.term_taxonomy_id = scope_str.term_taxonomy_id AND scope_stt.taxonomy = %s"
203 . " WHERE scope_str.object_id = {$course_id_field} AND scope_stt.term_id = %d )",
204 LP_COURSE_CATEGORY_TAX,
205 $this->category_id
206 );
207 }
208
209 return $conditions;
210 }
211 }
212