PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.7
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.7, at inc/Statistics/StatisticsScope.php

203 lines 6.6 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 * Add scope joins/where to a query whose rows already carry a course id column.
79 *
80 * @param LP_Filter $filter Query filter to extend.
81 * @param string $course_id_field One of COURSE_ID_FIELDS.
82 * @return LP_Filter
83 * @throws InvalidArgumentException On a course id field outside the allowlist.
84 */
85 public function apply( LP_Filter $filter, string $course_id_field ): LP_Filter {
86 if ( ! in_array( $course_id_field, self::COURSE_ID_FIELDS, true ) ) {
87 throw new InvalidArgumentException( 'Unknown course id field for statistics scope.' );
88 }
89
90 if ( $this->is_empty() ) {
91 return $filter;
92 }
93
94 global $wpdb;
95
96 if ( $this->instructor_id > 0 ) {
97 $filter->join[] = "INNER JOIN {$wpdb->posts} AS scope_p ON scope_p.ID = {$course_id_field}";
98 $filter->where[] = $wpdb->prepare( 'AND scope_p.post_author = %d', $this->instructor_id );
99 }
100
101 if ( $this->category_id > 0 ) {
102 $filter->join[] = "INNER JOIN {$wpdb->term_relationships} AS scope_tr ON scope_tr.object_id = {$course_id_field}";
103 $filter->join[] = $wpdb->prepare(
104 "INNER JOIN {$wpdb->term_taxonomy} AS scope_tt ON scope_tt.term_taxonomy_id = scope_tr.term_taxonomy_id AND scope_tt.taxonomy = %s",
105 LP_COURSE_CATEGORY_TAX
106 );
107 $filter->where[] = $wpdb->prepare( 'AND scope_tt.term_id = %d', $this->category_id );
108 }
109
110 return $filter;
111 }
112
113 /**
114 * Scope an orders query that has no course id column: keep orders containing
115 * at least one course item matching the scope.
116 *
117 * Uses EXISTS instead of a join so an order with several scoped items still
118 * counts once — order-level COUNT/SUM fields stay correct without DISTINCT rewrites.
119 *
120 * @param LP_Filter $filter Query filter to extend.
121 * @param string $order_id_field One of ORDER_ID_FIELDS.
122 * @return LP_Filter
123 * @throws InvalidArgumentException On an order id field outside the allowlist.
124 */
125 public function apply_to_orders( LP_Filter $filter, string $order_id_field ): LP_Filter {
126 if ( ! in_array( $order_id_field, self::ORDER_ID_FIELDS, true ) ) {
127 throw new InvalidArgumentException( 'Unknown order id field for statistics scope.' );
128 }
129
130 if ( $this->is_empty() ) {
131 return $filter;
132 }
133
134 global $wpdb;
135
136 $tb_order_items = $wpdb->prefix . 'learnpress_order_items';
137 $joins = '';
138 $conditions = '';
139
140 if ( $this->instructor_id > 0 ) {
141 $joins .= " INNER JOIN {$wpdb->posts} AS scope_p ON scope_p.ID = scope_oi.item_id";
142 $conditions .= $wpdb->prepare( ' AND scope_p.post_author = %d', $this->instructor_id );
143 }
144
145 if ( $this->category_id > 0 ) {
146 $joins .= " INNER JOIN {$wpdb->term_relationships} AS scope_tr ON scope_tr.object_id = scope_oi.item_id";
147 $joins .= $wpdb->prepare(
148 " INNER JOIN {$wpdb->term_taxonomy} AS scope_tt ON scope_tt.term_taxonomy_id = scope_tr.term_taxonomy_id AND scope_tt.taxonomy = %s",
149 LP_COURSE_CATEGORY_TAX
150 );
151 $conditions .= $wpdb->prepare( ' AND scope_tt.term_id = %d', $this->category_id );
152 }
153
154 $filter->where[] = "AND EXISTS ( SELECT 1 FROM {$tb_order_items} AS scope_oi{$joins} WHERE scope_oi.order_id = {$order_id_field}{$conditions} )";
155
156 return $filter;
157 }
158
159 /**
160 * Prepared "AND EXISTS(...)" conditions for raw SQL queries (subselects,
161 * HAVING-grouped queries) where LP_Filter joins do not reach.
162 *
163 * EXISTS-based so it is alias-collision-free and never duplicates rows.
164 * Returns '' when the scope is empty.
165 *
166 * @param string $course_id_field One of COURSE_ID_FIELDS.
167 * @return string
168 * @throws InvalidArgumentException On a course id field outside the allowlist.
169 */
170 public function sql_conditions( string $course_id_field ): string {
171 if ( ! in_array( $course_id_field, self::COURSE_ID_FIELDS, true ) ) {
172 throw new InvalidArgumentException( 'Unknown course id field for statistics scope.' );
173 }
174
175 if ( $this->is_empty() ) {
176 return '';
177 }
178
179 global $wpdb;
180
181 $conditions = '';
182
183 if ( $this->instructor_id > 0 ) {
184 $conditions .= $wpdb->prepare(
185 " AND EXISTS ( SELECT 1 FROM {$wpdb->posts} AS scope_sp WHERE scope_sp.ID = {$course_id_field} AND scope_sp.post_author = %d )",
186 $this->instructor_id
187 );
188 }
189
190 if ( $this->category_id > 0 ) {
191 $conditions .= $wpdb->prepare(
192 " AND EXISTS ( SELECT 1 FROM {$wpdb->term_relationships} AS scope_str"
193 . " INNER JOIN {$wpdb->term_taxonomy} AS scope_stt ON scope_stt.term_taxonomy_id = scope_str.term_taxonomy_id AND scope_stt.taxonomy = %s"
194 . " WHERE scope_str.object_id = {$course_id_field} AND scope_stt.term_id = %d )",
195 LP_COURSE_CATEGORY_TAX,
196 $this->category_id
197 );
198 }
199
200 return $conditions;
201 }
202 }
203