Spaces:
No application file
No application file
namespace Mautic\CoreBundle\Helper\Chart; | |
use Doctrine\DBAL\Connection; | |
use Doctrine\DBAL\Query\QueryBuilder; | |
use Mautic\CoreBundle\Doctrine\Provider\GeneratedColumnsProviderInterface; | |
use Mautic\CoreBundle\Helper\DateTimeHelper; | |
/** | |
* Methods to get the chart data as native queries to get better performance and work with date/time native SQL queries. | |
*/ | |
class ChartQuery extends AbstractChart | |
{ | |
private DateTimeHelper $dateTimeHelper; | |
private ?GeneratedColumnsProviderInterface $generatedColumnProvider = null; | |
/** | |
* Match date/time unit to a SQL datetime format | |
* {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}. | |
* | |
* @var array | |
*/ | |
protected $sqlFormats = [ | |
's' => 'Y-m-d H:i:s', | |
'i' => 'Y-m-d H:i:00', | |
'H' => 'Y-m-d H:00:00', | |
'd' => 'Y-m-d 00:00:00', | |
'D' => 'Y-m-d 00:00:00', // ('D' is BC. Can be removed when all charts use this class) | |
'W' => 'Y-m-d 00:00:00', | |
'm' => 'Y-m-01 00:00:00', | |
'M' => 'Y-m-00 00:00:00', // ('M' is BC. Can be removed when all charts use this class) | |
'Y' => 'Y-01-01 00:00:00', | |
]; | |
/** | |
* Match date/time unit to a MySql datetime format | |
* {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters} | |
* {@link dev.mysql.com/doc/refman/5.5/en/date-and-time-functions.html#function_date-format}. | |
* | |
* @var array | |
*/ | |
protected $mysqlTimeUnits = [ | |
's' => '%Y-%m-%d %H:%i:%s', | |
'i' => '%Y-%m-%d %H:%i', | |
'H' => '%Y-%m-%d %H:00', | |
'd' => '%Y-%m-%d', | |
'D' => '%Y-%m-%d', // ('D' is BC. Can be removed when all charts use this class) | |
'W' => '%Y %U', | |
'm' => '%Y-%m', | |
'M' => '%Y-%m', // ('M' is BC. Can be removed when all charts use this class) | |
'Y' => '%Y', | |
]; | |
/** | |
* Possible values are 'd'/'H'/'i'/'i'/'W'/'m'/'Y'. | |
* | |
* @see \Mautic\CoreBundle\Helper\Chart\DateRangeUnitTrait::getTimeUnitFromDateRange() | |
* | |
* @param string|null $unit | |
*/ | |
public function __construct( | |
protected Connection $connection, | |
\DateTime $dateFrom, | |
\DateTime $dateTo, | |
$unit = null | |
) { | |
$this->dateTimeHelper = new DateTimeHelper(); | |
$this->unit = $unit ?? $this->getTimeUnitFromDateRange($dateFrom, $dateTo); | |
$this->isTimeUnit = in_array($this->unit, ['H', 'i', 's']); | |
$this->setDateRange($dateFrom, $dateTo); | |
} | |
public function setGeneratedColumnProvider(GeneratedColumnsProviderInterface $generatedColumnProvider): void | |
{ | |
$this->generatedColumnProvider = $generatedColumnProvider; | |
} | |
/** | |
* Apply where filters to the query. | |
* | |
* @param QueryBuilder $query | |
* @param array $filters | |
*/ | |
public function applyFilters(&$query, $filters): void | |
{ | |
if ($filters && is_array($filters)) { | |
foreach ($filters as $column => $value) { | |
$valId = $column.'_val'; | |
// Special case: Lead list filter | |
if ('leadlist_id' === $column) { | |
$query->join('t', MAUTIC_TABLE_PREFIX.'lead_lists_leads', 'lll', 'lll.lead_id = '.$value['list_column_name']); | |
$query->andWhere('lll.leadlist_id = :'.$valId); | |
$query->setParameter($valId, $value['value']); | |
} elseif (isset($value['expression']) && method_exists($query->expr(), $value['expression'])) { | |
$query->andWhere($query->expr()->{$value['expression']}($column)); | |
if (isset($value['value'])) { | |
$query->setParameter($valId, $value['value']); | |
} | |
} elseif (isset($value['subquery'])) { | |
$query->andWhere($value['subquery']); | |
} else { | |
$column = str_replace('t.', '', $column); | |
$valId = str_replace('t.', '', $valId); | |
if (is_array($value)) { | |
$query->andWhere($query->expr()->in('t.'.$column, $value)); | |
} else { | |
$query->andWhere('t.'.$column.' = :'.$valId); | |
$query->setParameter($valId, $value); | |
} | |
} | |
} | |
} | |
} | |
/** | |
* Apply date filters to the query. | |
* | |
* @param QueryBuilder $query | |
* @param string $dateColumn | |
* @param string $tablePrefix | |
*/ | |
public function applyDateFilters(&$query, $dateColumn, $tablePrefix = 't'): void | |
{ | |
// Check if the date filters have already been applied | |
if ($parameters = $query->getParameters()) { | |
if (array_key_exists('dateTo', $parameters) || array_key_exists('dateFrom', $parameters)) { | |
return; | |
} | |
} | |
if ($dateColumn) { | |
if ($this->dateFrom && $this->dateTo) { | |
// Between is faster so if we know both dates... | |
/** @var \DateTime $dateFrom */ | |
$dateFrom = clone $this->dateFrom; | |
/** @var \DateTime $dateTo */ | |
$dateTo = clone $this->dateTo; | |
if ($this->isTimeUnit) { | |
$dateFrom->setTimeZone(new \DateTimeZone('UTC')); | |
} | |
if ($this->isTimeUnit) { | |
$dateTo->setTimeZone(new \DateTimeZone('UTC')); | |
} | |
$query->andWhere($tablePrefix.'.'.$dateColumn.' BETWEEN :dateFrom AND :dateTo'); | |
$query->setParameter('dateFrom', $dateFrom->format('Y-m-d H:i:s')); | |
$query->setParameter('dateTo', $dateTo->format('Y-m-d H:i:s')); | |
} else { | |
// Apply the start date/time if set | |
if ($this->dateFrom) { | |
/** @var \DateTime $dateFrom */ | |
$dateFrom = clone $this->dateFrom; | |
if ($this->isTimeUnit) { | |
$dateFrom->setTimeZone(new \DateTimeZone('UTC')); | |
} | |
$query->andWhere($tablePrefix.'.'.$dateColumn.' >= :dateFrom'); | |
$query->setParameter('dateFrom', $dateFrom->format('Y-m-d H:i:s')); | |
} | |
// Apply the end date/time if set | |
if ($this->dateTo) { | |
/** @var \DateTime $dateTo */ | |
$dateTo = clone $this->dateTo; | |
if ($this->isTimeUnit) { | |
$dateTo->setTimeZone(new \DateTimeZone('UTC')); | |
} | |
$query->andWhere($tablePrefix.'.'.$dateColumn.' <= :dateTo'); | |
$query->setParameter('dateTo', $dateTo->format('Y-m-d H:i:s')); | |
} | |
} | |
} | |
} | |
/** | |
* Get the right unit for current database platform. | |
* | |
* @param string $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters} | |
* | |
* @return string | |
*/ | |
public function translateTimeUnit($unit = null) | |
{ | |
if (null === $unit) { | |
$unit = $this->unit; | |
} | |
if (!isset($this->mysqlTimeUnits[$unit])) { | |
throw new \UnexpectedValueException('Date/Time unit "'.$unit.'" is not available for MySql.'); | |
} | |
return $this->mysqlTimeUnits[$unit]; | |
} | |
/** | |
* Prepare database query for fetching the line time chart data. | |
* | |
* @param string $table without prefix | |
* @param string $column name. The column must be type of datetime | |
* @param array $filters will be added to where claues | |
* | |
* @return QueryBuilder | |
*/ | |
public function prepareTimeDataQuery($table, $column, $filters = [], $countColumn = '*', $isEnumerable = true, bool $useSqlOrder = true) | |
{ | |
// Convert time unitst to the right form for current database platform | |
$query = $this->connection->createQueryBuilder(); | |
$query->from($this->prepareTable($table), 't'); | |
$this->modifyTimeDataQuery($query, $column, 't', $countColumn, $isEnumerable, $useSqlOrder); | |
$this->applyFilters($query, $filters); | |
$this->applyDateFilters($query, $column); | |
return $query; | |
} | |
/** | |
* Modify database query for fetching the line time chart data. | |
* | |
* @param QueryBuilder $query | |
* @param string $column name | |
* @param string $tablePrefix | |
* @param string $countColumn | |
* @param bool|string $isEnumerable true = COUNT, string sum = SUM | |
*/ | |
public function modifyTimeDataQuery($query, $column, $tablePrefix = 't', $countColumn = '*', $isEnumerable = true, bool $useSqlOrder = true): void | |
{ | |
// Convert time units to the right form for current database platform | |
$limit = $this->countAmountFromDateRange(); | |
$dateConstruct = $this->getDateConstruct($tablePrefix, $column); | |
if (true === $isEnumerable) { | |
$count = 'COUNT('.$countColumn.') AS count'; | |
} elseif ('sum' == $isEnumerable) { | |
$count = 'SUM('.$countColumn.') AS count'; | |
} else { | |
$count = $countColumn.' AS count'; | |
} | |
$query->select($dateConstruct.' AS date, '.$count); | |
$query->groupBy($dateConstruct); | |
if ($useSqlOrder) { | |
// Some queries needs to avoid this because of query performance | |
$query->orderBy($dateConstruct, 'ASC'); | |
} | |
$query->setMaxResults($limit); | |
} | |
/** | |
* Fetch data for a time related dataset. | |
* | |
* @param string $table without prefix | |
* @param string $column name. The column must be type of datetime | |
* @param array $filters will be added to where claues | |
*/ | |
public function fetchTimeData($table, $column, $filters = []): array | |
{ | |
$query = $this->prepareTimeDataQuery($table, $column, $filters); | |
return $this->loadAndBuildTimeData($query); | |
} | |
/** | |
* Fetch data and sum it for a time related dataset. | |
* | |
* @param string $table without prefix | |
* @param string $column name. The column must be type of datetime | |
* @param array $filters will be added to where claues | |
* @param string $sumColumn name that will be summed | |
*/ | |
public function fetchSumTimeData($table, $column, $filters, $sumColumn): array | |
{ | |
$query = $this->prepareTimeDataQuery($table, $column, $filters, $sumColumn, 'sum'); | |
return $this->loadAndBuildTimeData($query); | |
} | |
/** | |
* Loads data from prepared query and builds the chart data. | |
* | |
* @param QueryBuilder $query | |
*/ | |
public function loadAndBuildTimeData($query): array | |
{ | |
$rawData = $query->executeQuery()->fetchAllAssociative(); | |
return $this->completeTimeData($rawData); | |
} | |
/** | |
* Go through the raw data and add the missing times. | |
*/ | |
public function completeTimeData($rawData, $countAverage = false): array | |
{ | |
$data = []; | |
$averageCounts = []; | |
$oneUnit = $this->getUnitInterval(); | |
$limit = $this->countAmountFromDateRange(); | |
/** @var \DateTime $previousDate */ | |
$previousDate = clone $this->dateFrom; | |
$utcTz = new \DateTimeZone('UTC'); | |
// Do not let hours to mess with date comparisions. | |
$previousDate->setTime(0, 0, 0); | |
if ('Y' === $this->unit) { | |
$previousDate->modify('first day of January'); | |
} elseif ('m' == $this->unit) { | |
$previousDate->modify('first day of this month'); | |
} elseif ('W' === $this->unit) { | |
$previousDate->modify('Monday this week'); | |
} | |
// Convert data from DB to the chart.js format | |
for ($i = 0; $i < $limit; ++$i) { | |
$nextDate = clone $previousDate; | |
if ('m' === $this->unit) { | |
$nextDate->modify('first day of next month'); | |
} elseif ('W' === $this->unit) { | |
$nextDate->modify('Monday next week'); | |
} else { | |
$nextDate->add($oneUnit); | |
} | |
foreach ($rawData as $key => &$item) { | |
if (!isset($item['date_comparison'])) { | |
if (!$item['date'] instanceof \DateTime) { | |
/* | |
* PHP DateTime cannot parse the Y W (ex 2016 09) | |
* format, so we transform it into d-M-Y. | |
*/ | |
if ('W' === $this->unit && isset($item['date'])) { | |
[$year, $week] = explode(' ', $item['date']); | |
$newDate = new \DateTime(); | |
$newDate->setISODate($year, $week); | |
$item['date'] = $newDate->format('d-M-Y'); | |
unset($newDate); | |
} | |
// Time based data from the database will always in UTC; otherwise assume local | |
// since changing the timezone could result in wrong placement | |
$itemDate = new \DateTime($item['date'], ($this->isTimeUnit) ? $utcTz : $this->timezone); | |
} else { | |
$itemDate = clone $item['date']; | |
} | |
if (!$this->isTimeUnit) { | |
// Hours do not matter so let's reset to 00:00:00 for date comparison | |
$itemDate->setTime(0, 0, 0); | |
} else { | |
// Convert to the timezone used for comparison | |
$itemDate->setTimezone($this->timezone); | |
} | |
$item['date_comparison'] = $itemDate; | |
} else { | |
$itemDate = $item['date_comparison']; | |
} | |
// Place the right suma is between the time unit and time unit +1 | |
if (isset($item['count']) && $itemDate >= $previousDate && $itemDate < $nextDate) { | |
$data[$i] = $item['count']; | |
unset($rawData[$key]); | |
continue; | |
} | |
// Add the right item is between the time unit and time unit +1 | |
if (isset($item['data']) && $itemDate >= $previousDate && $itemDate < $nextDate) { | |
if (isset($data[$i])) { | |
$data[$i] += $item['data']; | |
if ($countAverage) { | |
++$averageCounts[$i]; | |
} | |
} else { | |
$data[$i] = $item['data']; | |
if ($countAverage) { | |
$averageCounts[$i] = 1; | |
} | |
} | |
unset($rawData[$key]); | |
continue; | |
} | |
} | |
// Chart.js requires the 0 for empty data, but the array slot has to exist | |
if (!isset($data[$i])) { | |
$data[$i] = 0; | |
if ($countAverage) { | |
$averageCounts[$i] = 0; | |
} | |
} | |
$previousDate = $nextDate; | |
} | |
if ($countAverage) { | |
foreach ($data as $key => $value) { | |
if (!empty($averageCounts[$key])) { | |
$data[$key] = round($data[$key] / $averageCounts[$key], 2); | |
} | |
} | |
} | |
return $data; | |
} | |
/** | |
* Count occurences of a value in a column. | |
* | |
* @param string $table without prefix | |
* @param string $uniqueColumn name | |
* @param string $dateColumn name | |
* @param array $filters will be added to where claues | |
* @param array $options for special behavior | |
* | |
* @return QueryBuilder $query | |
*/ | |
public function getCountQuery($table, $uniqueColumn, $dateColumn = null, $filters = [], $options = [], $tablePrefix = 't') | |
{ | |
$query = $this->connection->createQueryBuilder(); | |
$query->from($this->prepareTable($table), $tablePrefix); | |
$this->modifyCountQuery($query, $uniqueColumn, $dateColumn, $tablePrefix); | |
$this->applyFilters($query, $filters); | |
$this->applyDateFilters($query, $dateColumn); | |
return $query; | |
} | |
/** | |
* Modify the query to count occurences of a value in a column. | |
* | |
* @param string $uniqueColumn name | |
* @param array $options for special behavior | |
* @param string $tablePrefix | |
*/ | |
public function modifyCountQuery(QueryBuilder &$query, $uniqueColumn, $options = [], $tablePrefix = 't') | |
{ | |
$query->select('COUNT('.$tablePrefix.'.'.$uniqueColumn.') AS count'); | |
// Count only unique values | |
if (!empty($options['getUnique'])) { | |
$selectAlso = ''; | |
if (isset($options['selectAlso'])) { | |
$selectAlso = ', '.implode(', ', $options['selectAlso']); | |
} | |
// Modify the previous query | |
$query->select($tablePrefix.'.'.$uniqueColumn.$selectAlso); | |
$query->having('COUNT(*) = 1') | |
->groupBy($tablePrefix.'.'.$uniqueColumn.$selectAlso); | |
// Create a new query with subquery of the previous query | |
$uniqueQuery = $this->connection->createQueryBuilder(); | |
$uniqueQuery->select('COUNT('.$tablePrefix.'.'.$uniqueColumn.') AS count') | |
->from('('.$query->getSql().')', $tablePrefix); | |
// Apply params from the previous query to the new query | |
$uniqueQuery->setParameters($query->getParameters()); | |
// Replace the new query with previous query | |
$query = $uniqueQuery; | |
} | |
return $query; | |
} | |
/** | |
* Count occurences of a value in a column. | |
* | |
* @param string $table without prefix | |
* @param string $uniqueColumn name | |
* @param string $dateColumn name | |
* @param array $filters will be added to where claues | |
* @param array $options for special behavior | |
*/ | |
public function count($table, $uniqueColumn, $dateColumn = null, $filters = [], $options = []): int | |
{ | |
$query = $this->getCountQuery($table, $uniqueColumn, $dateColumn, $filters); | |
return $this->fetchCount($query); | |
} | |
/** | |
* Fetch the count integet from a query. | |
*/ | |
public function fetchCount(QueryBuilder $query): int | |
{ | |
$data = $query->executeQuery()->fetchAssociative(); | |
return (int) $data['count']; | |
} | |
/** | |
* Get the query to count how many rows is between a range of date diff in seconds. | |
* | |
* @param string $table without prefix | |
* @param string $dateColumn1 | |
* @param string $dateColumn2 | |
* @param int $startSecond | |
* @param int $endSecond | |
* @param array $filters will be added to where claues | |
* @param string $tablePrefix | |
* | |
* @return QueryBuilder $query | |
*/ | |
public function getCountDateDiffQuery($table, $dateColumn1, $dateColumn2, $startSecond = 0, $endSecond = 60, $filters = [], $tablePrefix = 't') | |
{ | |
$query = $this->connection->createQueryBuilder(); | |
$query->from($this->prepareTable($table), $tablePrefix); | |
$this->modifyCountDateDiffQuery($query, $dateColumn1, $dateColumn2, $startSecond, $endSecond, $tablePrefix); | |
$this->applyFilters($query, $filters); | |
$this->applyDateFilters($query, $dateColumn1); | |
return $query; | |
} | |
/** | |
* Modify the query to count how many rows is between a range of date diff in seconds. | |
* | |
* @param string $dateColumn1 | |
* @param string $dateColumn2 | |
* @param int $startSecond | |
* @param int $endSecond | |
* @param string $tablePrefix | |
*/ | |
public function modifyCountDateDiffQuery(QueryBuilder &$query, $dateColumn1, $dateColumn2, $startSecond = 0, $endSecond = 60, $tablePrefix = 't'): void | |
{ | |
$query->select('COUNT('.$tablePrefix.'.'.$dateColumn1.') AS count'); | |
$query->where('TIMESTAMPDIFF(SECOND, '.$tablePrefix.'.'.$dateColumn1.', '.$tablePrefix.'.'.$dateColumn2.') >= :startSecond'); | |
$query->andWhere('TIMESTAMPDIFF(SECOND, '.$tablePrefix.'.'.$dateColumn1.', '.$tablePrefix.'.'.$dateColumn2.') < :endSecond'); | |
$query->setParameter('startSecond', $startSecond); | |
$query->setParameter('endSecond', $endSecond); | |
} | |
/** | |
* Count how many rows is between a range of date diff in seconds. | |
* | |
* @param string $query | |
*/ | |
public function fetchCountDateDiff($query): int | |
{ | |
$data = $query->execute()->fetchAssociative(); | |
return (int) $data['count']; | |
} | |
/** | |
* @return mixed | |
*/ | |
protected function prepareTable($table) | |
{ | |
if (MAUTIC_TABLE_PREFIX && str_starts_with($table, MAUTIC_TABLE_PREFIX)) { | |
return $table; | |
} | |
if (str_starts_with($table, '(')) { | |
return $table; | |
} | |
return MAUTIC_TABLE_PREFIX.$table; | |
} | |
/** | |
* @param string $tablePrefix | |
* @param string $column | |
*/ | |
private function getDateConstruct($tablePrefix, $column): string | |
{ | |
if ($this->generatedColumnProvider) { | |
$generatedColumns = $this->generatedColumnProvider->getGeneratedColumns(); | |
try { | |
$generatedColumn = $generatedColumns->getForOriginalDateColumnAndUnit($column, $this->unit); | |
return $tablePrefix.'.'.$generatedColumn->getColumnName(); | |
} catch (\UnexpectedValueException) { | |
// Alright. Use the original column then. | |
} | |
} | |
$dbUnit = $this->translateTimeUnit($this->unit); | |
$columnName = $tablePrefix.'.'.$column; | |
$defaultTimezoneOffset = $this->dateTimeHelper->getLocalTimezoneOffset(); | |
$columnName = "CONVERT_TZ($columnName, '+00:00', '{$defaultTimezoneOffset}')"; | |
return 'DATE_FORMAT('.$columnName.', \''.$dbUnit.'\')'; | |
} | |
} | |