PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 1.7.0.9 All 211 releases
← All changes | includes/data/class-charitable-campaign-donations-db.php +728 -0 1.8.11 → 1.8.13 View file →
@@ -361,8 +361,140 @@
361 361 return $this->get_donation_amount( $donation_id );
362 362 }
363 363
364 364 /**
365 + * Return the total donated on each of several donations, grouped by
366 + * donation id, in a single query.
367 + *
368 + * The donation-side counterpart to get_donated_amounts_by_campaign().
369 + * Added because no batched per-donation accessor existed: listing a page
370 + * of donations and calling get_donation_total_amount() per row issues one
371 + * query per donation, which is the same N+1 that had to be fixed out of
372 + * the campaign list.
373 + *
374 + * No status clause: the donation ids are already selected by status by
375 + * the caller, and a per-donation total must not be filtered by the
376 + * donation's own status — the donation IS the status.
377 + *
378 + * The donor id comes back alongside the total, and that is the point of
379 + * it rather than a convenience. Charitable_Donation::get_donor_id()
380 + * resolves it through get_campaign_donations(), which issues its own
381 + * `SELECT * FROM campaign_donations WHERE donation_id IN (N)` per
382 + * donation — so a page of 20 donations paid 20 extra queries to learn
383 + * something this one grouped query already had in hand. Measured on the
384 + * development site: dropping that call took charitable/list-donations
385 + * from 76 queries to 36.
386 + *
387 + * MIN( cd.donor_id ) rather than any aggregate cleverness: every row of
388 + * a single donation carries the same donor_id, because the donor is a
389 + * property of the donation and not of the split across campaigns.
390 + *
391 + * Ported from Charitable Pro, with one change: Pro's version reads
392 + * COALESCE( cd.base_amount, cd.amount ) because Pro's multi-currency
393 + * feature adds a base_amount column to this table. Lite has no
394 + * base_amount column and no multi-currency feature, so this sums
395 + * cd.amount directly — the same substitution every other aggregate
396 + * method in this Lite file already carries (see
397 + * get_donations_summary_by_day_range() below for the same rationale).
398 + *
399 + * @since 1.8.13
400 + *
401 + * @global WPDB $wpdb
402 + * @param int[] $donation_ids Donation IDs.
403 + * @return array Keyed by donation_id (int), each value an array with
404 + * 'amount' (float) and 'donor_id' (int). A donation with no
405 + * campaign_donations rows is simply absent — callers should
406 + * treat a missing key as zero.
407 + */
408 + public function get_donated_amounts_by_donation( $donation_ids ) {
409 + global $wpdb;
410 +
411 + list( $donations_in, $parameters ) = $this->get_in_clause_params( $donation_ids );
412 +
413 + if ( empty( $donations_in ) || empty( $parameters ) ) {
414 + return array();
415 + }
416 +
417 + $sql = "SELECT cd.donation_id,
418 + COALESCE( SUM( cd.amount ), 0 ) as total,
419 + MIN( cd.donor_id ) as donor_id
420 + FROM $this->table_name cd
421 + WHERE cd.donation_id IN ( $donations_in )
422 + GROUP BY cd.donation_id";
423 +
424 + $results = $wpdb->get_results( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
425 +
426 + $totals = array();
427 +
428 + foreach ( $results as $row ) {
429 + $totals[ (int) $row->donation_id ] = array(
430 + 'amount' => (float) $row->total,
431 + 'donor_id' => (int) $row->donor_id,
432 + );
433 + }
434 +
435 + return $totals;
436 + }
437 +
438 + /**
439 + * Return the per-campaign rows for MANY donations in one query.
440 + *
441 + * The batched counterpart to get_campaigns_for_donation() below. A
442 + * listing that wants to show what each gift was split across needs this
443 + * shape or it pays a query per row — the same reason
444 + * get_donated_amounts_by_donation() above exists, and that method is the
445 + * model for this one.
446 + *
447 + * Ordered by id so a split donation's campaigns come back in the order
448 + * they were recorded, which is the order the donation form collected
449 + * them.
450 + *
451 + * Ported from Charitable Pro, with one change: Pro's version reads
452 + * COALESCE( cd.base_amount, cd.amount ) because Pro's multi-currency
453 + * feature adds a base_amount column to this table. Lite has no
454 + * base_amount column and no multi-currency feature, so this reads
455 + * cd.amount directly — the same substitution get_donated_amounts_by_donation()
456 + * above carries.
457 + *
458 + * @since 1.8.13
459 + *
460 + * @global wpdb $wpdb
461 + * @param int[] $donation_ids The donation IDs.
462 + * @return array donation_id => array of { campaign_id, campaign_name, amount }
463 + */
464 + public function get_campaign_rows_by_donation( $donation_ids ) {
465 + global $wpdb;
466 +
467 + list( $donations_in, $parameters ) = $this->get_in_clause_params( $donation_ids );
468 +
469 + if ( empty( $donations_in ) || empty( $parameters ) ) {
470 + return array();
471 + }
472 +
473 + $sql = "SELECT cd.donation_id,
474 + cd.campaign_id,
475 + cd.campaign_name,
476 + cd.amount
477 + FROM $this->table_name cd
478 + WHERE cd.donation_id IN ( $donations_in )
479 + ORDER BY cd.donation_id ASC, cd.campaign_donation_id ASC";
480 +
481 + $results = $wpdb->get_results( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
482 +
483 + $rows = array();
484 +
485 + foreach ( (array) $results as $row ) {
486 + $rows[ (int) $row->donation_id ][] = array(
487 + 'campaign_id' => (int) $row->campaign_id,
488 + 'campaign_name' => (string) $row->campaign_name,
489 + 'amount' => (float) $row->amount,
490 + );
491 + }
492 +
493 + return $rows;
494 + }
495 +
496 + /**
365 497 * Return an array of campaigns donated to in a single donation.
366 498 *
367 499 * @since 1.2.0
368 500 *
@@ -415,8 +547,159 @@
415 547 );
416 548 }
417 549
418 550 /**
551 + * Return the total amount donated to each of several campaigns, grouped
552 + * by campaign id, in a single query.
553 + *
554 + * Exists so a caller iterating N campaigns (e.g. the charitable/list-campaigns
555 + * ability) can resolve every row's donated amount in one query instead of
556 + * N calls to get_campaign_donated_amount() / Charitable_Campaign::get_donated_amount() —
557 + * one query per campaign multiplied by a page size is the exact N+1 shape
558 + * that made the long-range Reports Overview timeout ~18,000 queries before
559 + * it was batched the same way.
560 + *
561 + * Ported from Charitable Pro, with one change: Pro's version reads
562 + * COALESCE( cd.base_amount, cd.amount ) because Pro's multi-currency
563 + * feature adds a base_amount column to this table. Lite has no
564 + * base_amount column and no multi-currency feature, so this reads
565 + * cd.amount directly — the same column every other aggregate method in
566 + * this Lite file already uses (see get_amount_by_campaign_report()).
567 + *
568 + * Deliberately mirrors get_campaign_donated_amount( $campaigns, false, false )'s
569 + * raw, un-locale-converted output (no sanitize_database_amount() pass) so a
570 + * batched row and a get_campaign_donated_amount() call for the same campaign
571 + * return numerically identical values — callers that need the site's
572 + * comma/dot display convention should convert the same way that call site
573 + * already does.
574 + *
575 + * This is a raw aggregate accessor: it does NOT apply the
576 + * `charitable_campaign_donated_amount` filter that
577 + * Charitable_Campaign::get_donated_amount() applies on every call. A
578 + * caller that wants a figure consistent with the rest of the product —
579 + * not just a faster one — must apply that filter itself, per
580 + * campaign_id, with the same arguments get_donated_amount() uses:
581 + * `apply_filters( 'charitable_campaign_donated_amount', $amount,
582 + * $campaign, $sanitize )`. See
583 + * Charitable_Campaign_Abilities::campaign_summary() for a worked example.
584 + *
585 + * @since 1.8.13
586 + *
587 + * @global WPDB $wpdb
588 + * @param int[] $campaign_ids Campaign IDs.
589 + * @param boolean $include_all Whether to include all donations (true), or only approved (false).
590 + * @return array Keyed by campaign_id (int), each value a float total. A
591 + * campaign with no matching donations is simply absent from
592 + * the array — callers should treat a missing key as 0.
593 + */
594 + public function get_donated_amounts_by_campaign( $campaign_ids, $include_all = false ) {
595 + global $wpdb;
596 +
597 + list( $campaigns_in, $campaigns_parameters ) = $this->get_in_clause_params( $campaign_ids );
598 +
599 + if ( empty( $campaigns_in ) || empty( $campaigns_parameters ) ) {
600 + return array();
601 + }
602 +
603 + $statuses = $include_all ? array() : charitable_get_approval_statuses();
604 +
605 + list( $status_clause, $status_parameters ) = $this->get_donation_status_clause( $statuses );
606 +
607 + $sql_where_clauses = array( "cd.campaign_id IN ( $campaigns_in )" );
608 +
609 + if ( ! empty( $status_clause ) ) {
610 + $sql_where_clauses[] = $status_clause;
611 + }
612 +
613 + $sql_where = 'WHERE ' . implode( ' AND ', $sql_where_clauses );
614 + $parameters = array_merge( $campaigns_parameters, $status_parameters );
615 +
616 + $sql = "SELECT cd.campaign_id, COALESCE( SUM( cd.amount ), 0 ) as total
617 + FROM $this->table_name cd
618 + INNER JOIN $wpdb->posts p ON p.ID = cd.donation_id
619 + $sql_where
620 + GROUP BY cd.campaign_id";
621 +
622 + $results = $wpdb->get_results( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
623 +
624 + $totals = array();
625 +
626 + foreach ( $results as $row ) {
627 + $totals[ (int) $row->campaign_id ] = (float) $row->total;
628 + }
629 +
630 + return $totals;
631 + }
632 +
633 + /**
634 + * Return the number of distinct donors for each of several campaigns,
635 + * grouped by campaign id, in a single query.
636 + *
637 + * Same rationale as get_donated_amounts_by_campaign(): count_campaign_donors()
638 + * has no caching layer at all (unlike the donated-amount path, which is at
639 + * least transient-cached), so calling it once per row in a list is the
640 + * more expensive half of that ability's N+1.
641 + *
642 + * Ported from Charitable Pro unchanged: this query never touches
643 + * base_amount, so Lite's lack of that column does not affect it.
644 + *
645 + * This is a raw aggregate accessor: it does NOT apply the
646 + * `charitable_campaign_donor_count` filter that
647 + * Charitable_Campaign::get_donor_count() applies on every call. A caller
648 + * that wants a figure consistent with the rest of the product must apply
649 + * that filter itself, per campaign_id: `apply_filters(
650 + * 'charitable_campaign_donor_count', $count, $campaign )`. See
651 + * Charitable_Campaign_Abilities::campaign_summary() for a worked example.
652 + *
653 + * @since 1.8.13
654 + *
655 + * @global WPDB $wpdb
656 + * @param int[] $campaign_ids Campaign IDs.
657 + * @param boolean $include_all Whether to include all donations (true), or only approved (false).
658 + * @return array Keyed by campaign_id (int), each value an int count. A
659 + * campaign with no matching donors is simply absent from the
660 + * array — callers should treat a missing key as 0.
661 + */
662 + public function count_campaign_donors_by_campaign( $campaign_ids, $include_all = false ) {
663 + global $wpdb;
664 +
665 + list( $campaigns_in, $campaigns_parameters ) = $this->get_in_clause_params( $campaign_ids );
666 +
667 + if ( empty( $campaigns_in ) || empty( $campaigns_parameters ) ) {
668 + return array();
669 + }
670 +
671 + $statuses = $include_all ? array() : charitable_get_approval_statuses();
672 +
673 + list( $status_clause, $status_parameters ) = $this->get_donation_status_clause( $statuses );
674 +
675 + $sql_where_clauses = array( "cd.campaign_id IN ( $campaigns_in )" );
676 +
677 + if ( ! empty( $status_clause ) ) {
678 + $sql_where_clauses[] = $status_clause;
679 + }
680 +
681 + $sql_where = 'WHERE ' . implode( ' AND ', $sql_where_clauses );
682 + $parameters = array_merge( $campaigns_parameters, $status_parameters );
683 +
684 + $sql = "SELECT cd.campaign_id, COUNT( DISTINCT cd.donor_id ) as donor_count
685 + FROM $this->table_name cd
686 + INNER JOIN $wpdb->posts p ON p.ID = cd.donation_id
687 + $sql_where
688 + GROUP BY cd.campaign_id";
689 +
690 + $results = $wpdb->get_results( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
691 +
692 + $counts = array();
693 +
694 + foreach ( $results as $row ) {
695 + $counts[ (int) $row->campaign_id ] = (int) $row->donor_count;
696 + }
697 +
698 + return $counts;
699 + }
700 +
701 + /**
419 702 * Get an array of all donation ids for a campaign.
420 703 *
421 704 * @uses Charitable_Campaign_Donations_DB::get_distinct_ids()
422 705 *
@@ -475,8 +758,56 @@
475 758 return $wpdb->get_var( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
476 759 }
477 760
478 761 /**
762 + * Count distinct donations (transactions) for a campaign.
763 + *
764 + * Ported from Charitable Pro unchanged: a pure COUNT query, it never
765 + * touches base_amount, so Lite's lack of that column does not affect it.
766 + * Backs Charitable_Campaign::get_donation_count(), which the
767 + * charitable/get-campaign-performance ability calls.
768 + *
769 + * @since 1.8.13
770 + *
771 + * @global wpdb $wpdb
772 + * @param int|int[] $campaign The campaign ID, or list of campaign IDs.
773 + * @param boolean $include_all Whether to include all donations (true), or only approved (false).
774 + * @return int
775 + */
776 + public function count_campaign_donations( $campaign, $include_all = false ) {
777 + global $wpdb;
778 +
779 + $statuses = $include_all ? array() : charitable_get_approval_statuses();
780 +
781 + list( $status_clause, $status_parameters ) = $this->get_donation_status_clause( $statuses );
782 + list( $campaigns_in, $campaigns_parameters ) = $this->get_in_clause_params( $campaign );
783 +
784 + $sql_where_clauses = array();
785 +
786 + if ( ! empty( $campaigns_in ) && ! empty( $campaigns_parameters ) ) {
787 + $sql_where_clauses[] = "cd.campaign_id IN ( $campaigns_in )";
788 + }
789 +
790 + if ( ! empty( $status_clause ) ) {
791 + $sql_where_clauses[] = $status_clause;
792 + }
793 +
794 + if ( empty( $sql_where_clauses ) ) {
795 + return 0;
796 + }
797 +
798 + $sql_where = 'WHERE ' . implode( ' AND ', $sql_where_clauses );
799 + $parameters = array_merge( $campaigns_parameters, $status_parameters );
800 +
801 + $sql = "SELECT COUNT( DISTINCT cd.donation_id )
802 + FROM $this->table_name cd
803 + INNER JOIN $wpdb->posts p ON p.ID = cd.donation_id
804 + $sql_where;";
805 +
806 + return (int) $wpdb->get_var( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
807 + }
808 +
809 + /**
479 810 * Return all donations made by a donor.
480 811 *
481 812 * @since 1.0.0
482 813 *
@@ -689,8 +1020,283 @@
689 1020 return $results;
690 1021 }
691 1022
692 1023 /**
1024 + * Count the distinct donations matching a report query.
1025 + *
1026 + * Counts DISTINCT donation_id, so a donation split across three
1027 + * campaigns counts once rather than three times — unlike
1028 + * count_donations_by_status(), which counts campaign_donations ROWS and
1029 + * cannot be date-ranged.
1030 + *
1031 + * Exists because the alternative is loading every matching donation id
1032 + * into PHP just to call count() on it. Filtering reuses
1033 + * get_report_sql_where_clause(), so campaigns, status and the date range
1034 + * behave exactly as they do for get_amount_report() — which means a
1035 + * total and a count taken from the same $args are always coherent, and
1036 + * average = total / count cannot disagree with itself.
1037 + *
1038 + * THE INNER JOIN IS DELIBERATE, and it is a behaviour decision worth
1039 + * stating. A donation POST with no campaign_donations row is not
1040 + * counted, because such a record carries no amount and so contributes
1041 + * nothing to any money figure. Counting it would report more donations
1042 + * than the money accounts for: on the development database, 18 donation
1043 + * posts (abandoned importer and gateway-test records) have no rows at
1044 + * all, and counting donation posts against a campaign_donations total
1045 + * produced an all-time average gift of 9.49 when the real average of
1046 + * the donations that carry money is 14.02 — an average no actual
1047 + * donation resembles. Taking the count from the same table as the total
1048 + * is what makes the reported average true.
1049 + *
1050 + * @since 1.8.13
1051 + *
1052 + * @global WPDB $wpdb
1053 + * @param array $args Query arguments: campaigns, status, start_date, end_date, donor_id.
1054 + * @return int
1055 + */
1056 + public function count_donations_report( $args = array() ) {
1057 + global $wpdb;
1058 +
1059 + list( $sql_where, $parameters ) = $this->get_report_sql_where_clause( $args );
1060 +
1061 + $sql = "SELECT COUNT( DISTINCT cd.donation_id )
1062 + FROM $this->table_name cd
1063 + INNER JOIN $wpdb->posts p
1064 + ON p.ID = cd.donation_id
1065 + $sql_where";
1066 +
1067 + if ( ! empty( $parameters ) ) {
1068 + $sql = $wpdb->prepare( $sql, $parameters ); // phpcs:ignore
1069 + }
1070 +
1071 + return (int) $wpdb->get_var( $sql ); // phpcs:ignore
1072 + }
1073 +
1074 + /**
1075 + * Count the distinct donors matching a report query.
1076 + *
1077 + * A donor_id of 0 is excluded: a row can carry it when the donation was
1078 + * recorded without a resolvable donor, and counting those as one donor
1079 + * would understate a site's supporter count while counting them
1080 + * individually would overstate it. Excluding is the only honest option.
1081 + *
1082 + * @since 1.8.13
1083 + *
1084 + * @global WPDB $wpdb
1085 + * @param array $args Query arguments: campaigns, status, start_date, end_date.
1086 + * @return int
1087 + */
1088 + public function count_donors_report( $args = array() ) {
1089 + global $wpdb;
1090 +
1091 + list( $sql_where, $parameters ) = $this->get_report_sql_where_clause( $args );
1092 +
1093 + $sql_where = '' !== $sql_where
1094 + ? $sql_where . ' AND cd.donor_id != 0'
1095 + : 'WHERE cd.donor_id != 0';
1096 +
1097 + $sql = "SELECT COUNT( DISTINCT cd.donor_id )
1098 + FROM $this->table_name cd
1099 + INNER JOIN $wpdb->posts p
1100 + ON p.ID = cd.donation_id
1101 + $sql_where";
1102 +
1103 + if ( ! empty( $parameters ) ) {
1104 + $sql = $wpdb->prepare( $sql, $parameters ); // phpcs:ignore
1105 + }
1106 +
1107 + return (int) $wpdb->get_var( $sql ); // phpcs:ignore
1108 + }
1109 +
1110 + /**
1111 + * Count the distinct donations in a report query, grouped by status.
1112 + *
1113 + * ONE query for every status, rather than one query per status. Pass no
1114 + * `status` argument: get_report_sql_where_clause() then constrains to
1115 + * all valid donation statuses, which is exactly the set to group over.
1116 + *
1117 + * Ported from Charitable Pro unchanged: this query never touches
1118 + * base_amount, so Lite's lack of that column does not affect it.
1119 + *
1120 + * @since 1.8.13
1121 + *
1122 + * @global WPDB $wpdb
1123 + * @param array $args Query arguments: campaigns, start_date, end_date, donor_id.
1124 + * @return array Keyed by post status, each value an int count. A status with
1125 + * no donations is absent — callers should treat a missing key
1126 + * as 0.
1127 + */
1128 + public function count_donations_by_status_report( $args = array() ) {
1129 + global $wpdb;
1130 +
1131 + unset( $args['status'] );
1132 +
1133 + list( $sql_where, $parameters ) = $this->get_report_sql_where_clause( $args );
1134 +
1135 + $sql = "SELECT p.post_status, COUNT( DISTINCT cd.donation_id ) as donation_count
1136 + FROM $this->table_name cd
1137 + INNER JOIN $wpdb->posts p
1138 + ON p.ID = cd.donation_id
1139 + $sql_where
1140 + GROUP BY p.post_status";
1141 +
1142 + if ( ! empty( $parameters ) ) {
1143 + $sql = $wpdb->prepare( $sql, $parameters ); // phpcs:ignore
1144 + }
1145 +
1146 + $results = $wpdb->get_results( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
1147 +
1148 + $counts = array();
1149 +
1150 + foreach ( (array) $results as $row ) {
1151 + $counts[ (string) $row->post_status ] = (int) $row->donation_count;
1152 + }
1153 +
1154 + return $counts;
1155 + }
1156 +
1157 + /**
1158 + * Return campaigns ranked by amount raised over a date range.
1159 + *
1160 + * The get_amount_by_campaign_report() accessor is the closest existing one
1161 + * and is correctly converted, but it selects `campaign_name` WITHOUT
1162 + * campaign_id and applies no ordering or limit. A caller that needs to
1163 + * link to the campaign, or that wants the top N, therefore cannot use
1164 + * it: names are not unique, and they drift when a campaign is renamed
1165 + * after the donation was recorded.
1166 + *
1167 + * Filtering reuses get_report_sql_where_clause(), so campaigns, status,
1168 + * start_date, end_date and donor_id behave exactly as they do for every
1169 + * other report query, including its default of all valid donation
1170 + * statuses when none is given.
1171 + *
1172 + * Sums cd.amount rather than Pro's COALESCE( cd.base_amount, cd.amount ):
1173 + * base_amount belongs to the multi-currency schema and does not exist on
1174 + * this table in Lite (see get_donations_summary_by_day_range() below for
1175 + * the same substitution and rationale).
1176 + *
1177 + * @since 1.8.13
1178 + *
1179 + * @global WPDB $wpdb
1180 + * @param array $args Query arguments: campaigns, status, start_date,
1181 + * end_date, donor_id, limit.
1182 + * @return object[] Rows of campaign_id, campaign_name, total, donation_count.
1183 + */
1184 + public function get_top_campaigns_report( $args = array() ) {
1185 + global $wpdb;
1186 +
1187 + list( $sql_where, $parameters ) = $this->get_report_sql_where_clause( $args );
1188 +
1189 + $limit = isset( $args['limit'] ) ? absint( $args['limit'] ) : 10;
1190 + $limit = $limit > 0 ? $limit : 10;
1191 +
1192 + $sql = "SELECT cd.campaign_id,
1193 + cd.campaign_name,
1194 + COALESCE( SUM( cd.amount ), 0 ) as total,
1195 + COUNT( DISTINCT cd.donation_id ) as donation_count
1196 + FROM $this->table_name cd
1197 + INNER JOIN $wpdb->posts p
1198 + ON p.ID = cd.donation_id
1199 + $sql_where
1200 + GROUP BY cd.campaign_id, cd.campaign_name
1201 + ORDER BY total DESC
1202 + LIMIT %d";
1203 +
1204 + $parameters[] = $limit;
1205 +
1206 + $results = $wpdb->get_results( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore
1207 +
1208 + if ( $this->is_comma_decimal() ) {
1209 + $results = array_map( array( $this, 'sanitize_amounts' ), $results );
1210 + }
1211 +
1212 + return $results;
1213 + }
1214 +
1215 + /**
1216 + * Return donors ranked by amount given over a date range.
1217 + *
1218 + * Charitable_Reports::get_top_donors_overview() is the existing
1219 + * equivalent, and is not usable here: it requires the whole donation set
1220 + * to have been loaded into PHP first via init_with_array() plus
1221 + * get_donations(), sorts it in PHP, and calls charitable_get_donation()
1222 + * once per row. This is one grouped query.
1223 + *
1224 + * Ranked on cd.amount rather than Pro's COALESCE( base_amount, amount ):
1225 + * base_amount belongs to the multi-currency schema and does not exist on
1226 + * this table in Lite (see get_donations_summary_by_day_range() below for
1227 + * the same substitution and rationale).
1228 + *
1229 + * @since 1.8.13
1230 + *
1231 + * @global WPDB $wpdb
1232 + * @param array $args Query arguments: campaigns, status, start_date,
1233 + * end_date, limit.
1234 + * @return object[] Rows of donor_id, first_name, last_name, email, total,
1235 + * donation_count.
1236 + */
1237 + public function get_top_donors_report( $args = array() ) {
1238 + global $wpdb;
1239 +
1240 + list( $sql_where, $parameters ) = $this->get_report_sql_where_clause( $args );
1241 +
1242 + $limit = isset( $args['limit'] ) ? absint( $args['limit'] ) : 10;
1243 + $limit = $limit > 0 ? $limit : 10;
1244 +
1245 + /*
1246 + * donor_id 0 must not be grouped: a campaign_donations row can carry
1247 + * it when the donation was recorded without a resolvable donor, and
1248 + * grouping those would present the sum of several unrelated gifts as
1249 + * one large anonymous top donor.
1250 + *
1251 + * The INNER JOIN on the donors table below is what actually excludes
1252 + * them, because no donors row has donor_id 0 — the column is
1253 + * AUTO_INCREMENT and starts at 1. This clause is therefore SECONDARY
1254 + * and, on any normal install, redundant. It is kept because it states
1255 + * the intent at the point the grouping happens, so a future change
1256 + * from INNER to LEFT JOIN (to include gifts whose donor record
1257 + * was erased, say) does not silently reintroduce the anonymous-group
1258 + * bug.
1259 + *
1260 + * Honest note on coverage: this clause is NOT independently testable.
1261 + * Removing it leaves every test green, because the join already
1262 + * covers the case, and a donors row with donor_id 0 cannot be created
1263 + * to exercise it — MySQL treats an inserted 0 in an AUTO_INCREMENT
1264 + * column as a request for the next value unless NO_AUTO_VALUE_ON_ZERO
1265 + * is set on the session. Verified by mutation rather than assumed.
1266 + */
1267 + $sql_where = '' !== $sql_where
1268 + ? $sql_where . ' AND cd.donor_id != 0'
1269 + : 'WHERE cd.donor_id != 0';
1270 +
1271 + $sql = "SELECT cd.donor_id,
1272 + d.first_name,
1273 + d.last_name,
1274 + d.email,
1275 + COALESCE( SUM( cd.amount ), 0 ) as total,
1276 + COUNT( DISTINCT cd.donation_id ) as donation_count
1277 + FROM $this->table_name cd
1278 + INNER JOIN $wpdb->posts p
1279 + ON p.ID = cd.donation_id
1280 + INNER JOIN {$wpdb->prefix}charitable_donors d
1281 + ON d.donor_id = cd.donor_id
1282 + $sql_where
1283 + GROUP BY cd.donor_id, d.first_name, d.last_name, d.email
1284 + ORDER BY total DESC
1285 + LIMIT %d";
1286 +
1287 + $parameters[] = $limit;
1288 +
1289 + $results = $wpdb->get_results( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore
1290 +
1291 + if ( $this->is_comma_decimal() ) {
1292 + $results = array_map( array( $this, 'sanitize_amounts' ), $results );
1293 + }
1294 +
1295 + return $results;
1296 + }
1297 +
1298 + /**
693 1299 * Return an SQL limit for the report
694 1300 *
695 1301 * @since 1.8.1.10
696 1302 *
@@ -765,8 +1371,35 @@
765 1371 $sql_where_clauses[] = 'p.post_parent = %d';
766 1372 $parameters[] = $args['post_parent'];
767 1373 }
768 1374
1375 + /*
1376 + * `author` narrows to one donation author, which for a donation is
1377 + * the donor's WordPress user id
1378 + * (Charitable_Donation_Processor::parse_donation_data() sets
1379 + * post_author from `user_id`). Added in 1.8.13 for the ownership
1380 + * scoping on charitable/get-donation-summary: a report caller without
1381 + * `edit_others_donations` must see only their own donations, which is
1382 + * the rule wp-admin's donation list already applies
1383 + * (class-charitable-donation-list-table.php:783) via WP_Query's own
1384 + * `author` argument. The report accessors do not go through WP_Query,
1385 + * so the equivalent has to exist here.
1386 + *
1387 + * isset(), not ! empty(): author 0 is a real value — guest and
1388 + * manually recorded donations carry post_author 0 — so treating it as
1389 + * "no filter" would be fail-OPEN, returning every donation to a
1390 + * caller who asked to be scoped. No caller passes 0 today, because
1391 + * the only one that sets this key is gated behind `edit_donations`
1392 + * and so is always a logged-in user; isset() is what keeps that true
1393 + * if a future caller is not.
1394 + *
1395 + * @since 1.8.13
1396 + */
1397 + if ( isset( $args['author'] ) ) {
1398 + $sql_where_clauses[] = 'p.post_author = %d';
1399 + $parameters[] = (int) $args['author'];
1400 + }
1401 +
769 1402 if ( ! empty( $sql_where_clauses ) ) {
770 1403 $sql_where = 'WHERE ' . implode( ' AND ', $sql_where_clauses );
771 1404 }
772 1405
@@ -818,8 +1451,103 @@
818 1451 $result = $this->sanitize_amounts( $result );
819 1452 }
820 1453
821 1454 return $result;
1455 + }
1456 +
1457 + /**
1458 + * Return a count and sum of donations for every day in a date range, keyed by day.
1459 + *
1460 + * Callers that need a per-day series must use this instead of calling
1461 + * get_donations_summary_by_period() once per calendar day. That pattern issued one
1462 + * uncached query per day, so the query count scaled with the LENGTH of the range
1463 + * rather than the amount of data, and a multi-year range timed out. It also wrapped
1464 + * post_date in DATE_FORMAT(), which prevented any index being used, making every
1465 + * one of those queries a full scan of the join.
1466 + *
1467 + * Days with no donations are absent from the return value; callers should treat a
1468 + * missing key as zero.
1469 + *
1470 + * @since 1.8.12.2
1471 + * @since 1.8.13 Added $campaign_id parameter.
1472 + *
1473 + * @global WPDB $wpdb
1474 + * @param string $start_date Start of the range, Y-m-d. Inclusive.
1475 + * @param string $end_date End of the range, Y-m-d. Inclusive of the whole day.
1476 + * @param string[] $statuses List of statuses.
1477 + * @param int $campaign_id Optional. Restrict to donations that include this
1478 + * campaign. 0, the default, means every campaign —
1479 + * which is what existing callers want.
1480 + * @return array Keyed by Y-m-d, each value an object with 'amount' and 'count'.
1481 + */
1482 + public function get_donations_summary_by_day_range( $start_date, $end_date, $statuses = array(), $campaign_id = 0 ) {
1483 + global $wpdb;
1484 +
1485 + if ( empty( $start_date ) || empty( $end_date ) ) {
1486 + return array();
1487 + }
1488 +
1489 + if ( empty( $statuses ) ) {
1490 + $statuses = charitable_get_approval_statuses();
1491 + }
1492 +
1493 + list( $status_clause, $status_parameters ) = $this->get_donation_status_clause( $statuses );
1494 +
1495 + // Every status was filtered out as invalid, so nothing could match. Without
1496 + // this the IN clause would be emitted empty and the query would error.
1497 + if ( empty( $status_parameters ) ) {
1498 + return array();
1499 + }
1500 +
1501 + $parameters = array_merge(
1502 + array( $start_date . ' 00:00:00', $end_date . ' 23:59:59' ),
1503 + $status_parameters
1504 + );
1505 +
1506 + /*
1507 + * Appended AFTER the status clause, so the extra parameter lands last
1508 + * in $parameters and the placeholder order still lines up. Filtering
1509 + * on cd.campaign_id rather than joining anything: a donation split
1510 + * across campaigns has one row per campaign in this table, and the
1511 + * per-campaign row is exactly what a per-campaign figure means.
1512 + */
1513 + $campaign_clause = '';
1514 + $campaign_id = (int) $campaign_id;
1515 +
1516 + if ( $campaign_id > 0 ) {
1517 + $campaign_clause = ' AND cd.campaign_id = %d';
1518 + $parameters[] = $campaign_id;
1519 + }
1520 +
1521 + // Sums cd.amount, matching get_donations_summary_by_period() above. The
1522 + // base_amount column belongs to the multi-currency schema and does not exist
1523 + // on this table in Lite, so it must not appear here.
1524 + $sql = "SELECT DATE( p.post_date ) as day,
1525 + COALESCE( SUM( cd.amount ), 0 ) as amount,
1526 + COUNT( cd.donation_id ) as count
1527 + FROM {$wpdb->prefix}charitable_campaign_donations cd
1528 + INNER JOIN $wpdb->posts p ON p.ID = cd.donation_id
1529 + WHERE p.post_date >= %s AND p.post_date <= %s AND {$status_clause}{$campaign_clause}
1530 + GROUP BY DATE( p.post_date )";
1531 +
1532 + $results = $wpdb->get_results( $wpdb->prepare( $sql, $parameters ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
1533 +
1534 + if ( empty( $results ) ) {
1535 + return array();
1536 + }
1537 +
1538 + $comma_decimal = $this->is_comma_decimal();
1539 + $summary = array();
1540 +
1541 + foreach ( $results as $result ) {
1542 + if ( $comma_decimal ) {
1543 + $result = $this->sanitize_amounts( $result );
1544 + }
1545 +
1546 + $summary[ $result->day ] = $result;
1547 + }
1548 +
1549 + return $summary;
822 1550 }
823 1551
824 1552 /**
825 1553 * Returns the orderby clause.