Initial Upload

This commit is contained in:
Matt Batchelder
2025-12-02 10:32:59 -05:00
commit 05ce0da296
2240 changed files with 467811 additions and 0 deletions

View File

@@ -0,0 +1,776 @@
<?php
/*
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
use Carbon\Carbon;
use MongoDB\BSON\ObjectId;
use MongoDB\BSON\Regex;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Client;
use Xibo\Entity\Campaign;
use Xibo\Factory\CampaignFactory;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\DisplayGroupFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\WidgetFactory;
use Xibo\Service\LogServiceInterface;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
/**
* Class MongoDbTimeSeriesStore
* @package Xibo\Storage
*/
class MongoDbTimeSeriesStore implements TimeSeriesStoreInterface
{
/** @var LogServiceInterface */
private $log;
/** @var array */
private $config;
/** @var \MongoDB\Client */
private $client;
private $table = 'stat';
private $periodTable = 'period';
// Keep all stats in this array after processing
private $stats = [];
private $mediaItems = [];
private $widgets = [];
private $layouts = [];
private $displayGroups = [];
private $layoutsNotFound = [];
private $mediaItemsNotFound = [];
/** @var MediaFactory */
protected $mediaFactory;
/** @var WidgetFactory */
protected $widgetFactory;
/** @var LayoutFactory */
protected $layoutFactory;
/** @var DisplayFactory */
protected $displayFactory;
/** @var DisplayGroupFactory */
protected $displayGroupFactory;
/** @var CampaignFactory */
protected $campaignFactory;
/**
* @inheritdoc
*/
public function __construct($config = null)
{
$this->config = $config;
}
/**
* @inheritdoc
*/
public function setDependencies($log, $layoutFactory, $campaignFactory, $mediaFactory, $widgetFactory, $displayFactory, $displayGroupFactory)
{
$this->log = $log;
$this->layoutFactory = $layoutFactory;
$this->campaignFactory = $campaignFactory;
$this->mediaFactory = $mediaFactory;
$this->widgetFactory = $widgetFactory;
$this->displayFactory = $displayFactory;
$this->displayGroupFactory = $displayGroupFactory;
return $this;
}
/**
* @param \Xibo\Storage\StorageServiceInterface $store
* @return $this|\Xibo\Storage\MongoDbTimeSeriesStore
*/
public function setStore($store)
{
return $this;
}
/**
* Set Client in the event you want to completely replace the configuration options and roll your own client.
* @param \MongoDB\Client $client
*/
public function setClient($client)
{
$this->client = $client;
}
/**
* Get a MongoDB client to use.
* @return \MongoDB\Client
* @throws \Xibo\Support\Exception\GeneralException
*/
private function getClient()
{
if ($this->client === null) {
try {
$uri = isset($this->config['uri']) ? $this->config['uri'] : 'mongodb://' . $this->config['host'] . ':' . $this->config['port'];
$this->client = new Client($uri, [
'username' => $this->config['username'],
'password' => $this->config['password']
], (array_key_exists('driverOptions', $this->config) ? $this->config['driverOptions'] : []));
} catch (\MongoDB\Exception\RuntimeException $e) {
$this->log->error('Unable to connect to MongoDB: ' . $e->getMessage());
$this->log->debug($e->getTraceAsString());
throw new GeneralException('Connection to Time Series Database failed, please try again.');
}
}
return $this->client;
}
/** @inheritdoc */
public function addStat($statData)
{
// We need to transform string date to UTC date
$statData['statDate'] = new UTCDateTime($statData['statDate']->format('U') * 1000);
// In mongo collection we save fromDt/toDt as start/end
// and tag as eventName
// so we unset fromDt/toDt/tag from individual stats array
$statData['start'] = new UTCDateTime($statData['fromDt']->format('U') * 1000);
$statData['end'] = new UTCDateTime($statData['toDt']->format('U') * 1000);
$statData['eventName'] = $statData['tag'];
unset($statData['fromDt']);
unset($statData['toDt']);
unset($statData['tag']);
// Make an empty array to collect layout/media/display tags into
$tagFilter = [];
// Media name
$mediaName = null;
if (!empty($statData['mediaId'])) {
if (array_key_exists($statData['mediaId'], $this->mediaItems)) {
$media = $this->mediaItems[$statData['mediaId']];
} else {
try {
// Media exists in not found cache
if (in_array($statData['mediaId'], $this->mediaItemsNotFound)) {
return;
}
$media = $this->mediaFactory->getById($statData['mediaId']);
// Cache media
$this->mediaItems[$statData['mediaId']] = $media;
} catch (NotFoundException $error) {
// Cache Media not found, ignore and log the stat
if (!in_array($statData['mediaId'], $this->mediaItemsNotFound)) {
$this->mediaItemsNotFound[] = $statData['mediaId'];
$this->log->error('Media not found. Media Id: '. $statData['mediaId']);
}
return;
}
}
$mediaName = $media->name; //dont remove used later
$statData['mediaName'] = $mediaName;
$i = 0;
foreach ($media->tags as $tagLink) {
$tagFilter['media'][$i]['tag'] = $tagLink->tag;
if (isset($tagLink->value)) {
$tagFilter['media'][$i]['val'] = $tagLink->value;
}
$i++;
}
}
// Widget name
if (!empty($statData['widgetId'])) {
if (array_key_exists($statData['widgetId'], $this->widgets)) {
$widget = $this->widgets[$statData['widgetId']];
} else {
// We are already doing getWidgetForStat is XMDS,
// checking widgetId not found does not require
// We should always be able to get the widget
try {
$widget = $this->widgetFactory->getById($statData['widgetId']);
// Cache widget
$this->widgets[$statData['widgetId']] = $widget;
} catch (\Exception $error) {
// Widget not found, ignore and log the stat
$this->log->error('Widget not found. Widget Id: '. $statData['widgetId']);
return;
}
}
if ($widget != null) {
$widget->load();
$widgetName = isset($mediaName) ? $mediaName : $widget->getOptionValue('name', $widget->type);
// SET widgetName
$statData['widgetName'] = $widgetName;
}
}
// Layout data
$layoutName = null;
// For a type "event" we have layoutid 0 so is campaignId
// otherwise we should try and resolve the campaignId
$campaignId = 0;
if ($statData['type'] != 'event') {
if (array_key_exists($statData['layoutId'], $this->layouts)) {
$layout = $this->layouts[$statData['layoutId']];
} else {
try {
// Layout exists in not found cache
if (in_array($statData['layoutId'], $this->layoutsNotFound)) {
return;
}
// Get the layout campaignId - this can give us a campaignId of a layoutId which id was replaced when draft to published
$layout = $this->layoutFactory->getByLayoutHistory($statData['layoutId']);
$this->log->debug('Found layout : '. $statData['layoutId']);
// Cache layout
$this->layouts[$statData['layoutId']] = $layout;
} catch (NotFoundException $error) {
// All we can do here is log
// we shouldn't ever get in this situation because the campaignId we used above will have
// already been looked up in the layouthistory table.
// Cache layouts not found
if (!in_array($statData['layoutId'], $this->layoutsNotFound)) {
$this->layoutsNotFound[] = $statData['layoutId'];
$this->log->alert('Error processing statistic into MongoDB. Layout not found. Layout Id: ' . $statData['layoutId']);
}
return;
} catch (GeneralException $error) {
// Cache layouts not found
if (!in_array($statData['layoutId'], $this->layoutsNotFound)) {
$this->layoutsNotFound[] = $statData['layoutId'];
$this->log->error('Layout not found. Layout Id: '. $statData['layoutId']);
}
return;
}
}
$campaignId = (int) $layout->campaignId;
$layoutName = $layout->layout;
$i = 0;
foreach ($layout->tags as $tagLink) {
$tagFilter['layout'][$i]['tag'] = $tagLink->tag;
if (isset($tagLink->value)) {
$tagFilter['layout'][$i]['val'] = $tagLink->value;
}
$i++;
}
}
// Get layout Campaign ID
$statData['campaignId'] = $campaignId;
$statData['layoutName'] = $layoutName;
// Display
$display = $statData['display'];
// Display ID
$statData['displayId'] = $display->displayId;
unset($statData['display']);
// Display name
$statData['displayName'] = $display->display;
$i = 0;
foreach ($display->tags as $tagLink) {
$tagFilter['dg'][$i]['tag'] = $tagLink->tag;
if (isset($tagLink->value)) {
$tagFilter['dg'][$i]['val'] = $tagLink->value;
}
$i++;
}
// Display tags
if (array_key_exists($display->displayGroupId, $this->displayGroups)) {
$displayGroup = $this->displayGroups[$display->displayGroupId];
} else {
try {
$displayGroup = $this->displayGroupFactory->getById($display->displayGroupId);
// Cache displaygroup
$this->displayGroups[$display->displayGroupId] = $displayGroup;
} catch (NotFoundException $notFoundException) {
$this->log->error('Display group not found');
return;
}
}
$i = 0;
foreach ($displayGroup->tags as $tagLink) {
$tagFilter['dg'][$i]['tag'] = $tagLink->tag;
if (isset($tagLink->value)) {
$tagFilter['dg'][$i]['val'] = $tagLink->value;
}
$i++;
}
// TagFilter array
$statData['tagFilter'] = $tagFilter;
// Parent Campaign
if (array_key_exists('parentCampaign', $statData)) {
if ($statData['parentCampaign'] instanceof Campaign) {
$statData['parentCampaign'] = $statData['parentCampaign']->campaign;
} else {
$statData['parentCampaign'] = '';
}
}
$this->stats[] = $statData;
}
/**
* @inheritdoc
* @throws \Xibo\Support\Exception\GeneralException
*/
public function addStatFinalize()
{
// Insert statistics
if (count($this->stats) > 0) {
$collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
try {
$collection->insertMany($this->stats);
// Reset
$this->stats = [];
} catch (\MongoDB\Exception\RuntimeException $e) {
$this->log->error($e->getMessage());
throw new \MongoDB\Exception\RuntimeException($e->getMessage());
}
}
// Create a period collection if it doesnot exist
$collectionPeriod = $this->getClient()->selectCollection($this->config['database'], $this->periodTable);
try {
$cursor = $collectionPeriod->findOne(['name' => 'period']);
if ($cursor === null) {
$this->log->error('Period collection does not exist in Mongo DB.');
// Period collection created
$collectionPeriod->insertOne(['name' => 'period']);
$this->log->debug('Period collection created.');
}
} catch (\MongoDB\Exception\RuntimeException $e) {
$this->log->error($e->getMessage());
}
}
/** @inheritdoc */
public function getEarliestDate()
{
$collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
try {
// _id is the same as statDate for the purposes of sorting (stat date being the date/time of stat insert)
$earliestDate = $collection->find([], [
'limit' => 1,
'sort' => ['start' => 1]
])->toArray();
if (count($earliestDate) > 0) {
return Carbon::instance($earliestDate[0]['start']->toDateTime());
}
} catch (\MongoDB\Exception\RuntimeException $e) {
$this->log->error($e->getMessage());
}
return null;
}
/**
* @inheritdoc
*/
public function getStats($filterBy = [], $isBufferedQuery = false)
{
// do we consider that the fromDt and toDt will always be provided?
$fromDt = $filterBy['fromDt'] ?? null;
$toDt = $filterBy['toDt'] ?? null;
$statDate = $filterBy['statDate'] ?? null;
$statDateLessThan = $filterBy['statDateLessThan'] ?? null;
// In the case of user switches from mysql to mongo - laststatId were saved as integer
if (isset($filterBy['statId'])) {
try {
$statId = new ObjectID($filterBy['statId']);
} catch (\Exception $e) {
throw new InvalidArgumentException(__('Invalid statId provided'), 'statId');
}
} else {
$statId = null;
}
$type = $filterBy['type'] ?? null;
$displayIds = $filterBy['displayIds'] ?? [];
$layoutIds = $filterBy['layoutIds'] ?? [];
$mediaIds = $filterBy['mediaIds'] ?? [];
$campaignId = $filterBy['campaignId'] ?? null;
$parentCampaignId = $filterBy['parentCampaignId'] ?? null;
$mustHaveParentCampaign = $filterBy['mustHaveParentCampaign'] ?? false;
$eventTag = $filterBy['eventTag'] ?? null;
// Limit
$start = $filterBy['start'] ?? null;
$length = $filterBy['length'] ?? null;
// Match query
$match = [];
// fromDt/toDt Filter
if (($fromDt != null) && ($toDt != null)) {
$fromDt = new UTCDateTime($fromDt->format('U')*1000);
$match['$match']['end'] = ['$gt' => $fromDt];
$toDt = new UTCDateTime($toDt->format('U')*1000);
$match['$match']['start'] = ['$lte' => $toDt];
} elseif (($fromDt != null) && ($toDt == null)) {
$fromDt = new UTCDateTime($fromDt->format('U') * 1000);
$match['$match']['start'] = ['$gte' => $fromDt];
}
// statDate and statDateLessThan Filter
// get the next stats from the given date
$statDateQuery = [];
if ($statDate != null) {
$statDate = new UTCDateTime($statDate->format('U')*1000);
$statDateQuery['$gte'] = $statDate;
}
if ($statDateLessThan != null) {
$statDateLessThan = new UTCDateTime($statDateLessThan->format('U')*1000);
$statDateQuery['$lt'] = $statDateLessThan;
}
if (count($statDateQuery) > 0) {
$match['$match']['statDate'] = $statDateQuery;
}
if ($statId !== null) {
$match['$match']['_id'] = ['$gt' => new ObjectId($statId)];
}
// Displays Filter
if (count($displayIds) != 0) {
$match['$match']['displayId'] = ['$in' => $displayIds];
}
// Campaign/Layout Filter
// ---------------
// Use the Layout Factory to get all Layouts linked to the provided CampaignId
if ($campaignId != null) {
$campaignIds = [];
try {
$layouts = $this->layoutFactory->getByCampaignId($campaignId, false);
if (count($layouts) > 0) {
foreach ($layouts as $layout) {
$campaignIds[] = $layout->campaignId;
}
// Add to our match
$match['$match']['campaignId'] = ['$in' => $campaignIds];
}
} catch (NotFoundException $ignored) {
}
}
// Type Filter
if ($type != null) {
$match['$match']['type'] = new Regex($type, 'i');
}
// Event Tag Filter
if ($eventTag != null) {
$match['$match']['eventName'] = $eventTag;
}
// Layout Filter
if (count($layoutIds) != 0) {
// Get campaignIds for selected layoutIds
$campaignIds = [];
foreach ($layoutIds as $layoutId) {
try {
$campaignIds[] = $this->layoutFactory->getCampaignIdFromLayoutHistory($layoutId);
} catch (NotFoundException $notFoundException) {
// Ignore the missing one
$this->log->debug('Filter for Layout without Layout History Record, layoutId is ' . $layoutId);
}
}
$match['$match']['campaignId'] = ['$in' => $campaignIds];
}
// Media Filter
if (count($mediaIds) != 0) {
$match['$match']['mediaId'] = ['$in' => $mediaIds];
}
// Parent Campaign Filter
if ($parentCampaignId != null) {
$match['$match']['parentCampaignId'] = $parentCampaignId;
}
// Has Parent Campaign Filter
if ($mustHaveParentCampaign) {
$match['$match']['parentCampaignId'] = ['$exists' => true, '$ne' => 0];
}
// Select collection
$collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
// Paging
// ------
// Check whether or not we've requested a page, if we have then we need a count of records total for paging
// if we haven't then we don't bother getting a count
$total = 0;
if ($start !== null && $length !== null) {
// We add a group pipeline to get a total count of records
$group = [
'$group' => [
'_id' => null,
'count' => ['$sum' => 1],
]
];
if (count($match) > 0) {
$totalQuery = [
$match,
$group,
];
} else {
$totalQuery = [
$group,
];
}
// Get total
try {
$totalCursor = $collection->aggregate($totalQuery, ['allowDiskUse' => true]);
$totalCount = $totalCursor->toArray();
$total = (count($totalCount) > 0) ? $totalCount[0]['count'] : 0;
} catch (\Exception $e) {
$this->log->error('Error: Total Count. ' . $e->getMessage());
throw new GeneralException(__('Sorry we encountered an error getting Proof of Play data, please consult your administrator'));
}
}
try {
$project = [
'$project' => [
'id'=> '$_id',
'type'=> 1,
'start'=> 1,
'end'=> 1,
'layout'=> '$layoutName',
'display'=> '$displayName',
'media'=> '$mediaName',
'tag'=> '$eventName',
'duration'=> ['$toInt' => '$duration'],
'count'=> ['$toInt' => '$count'],
'displayId'=> 1,
'layoutId'=> 1,
'widgetId'=> 1,
'mediaId'=> 1,
'campaignId'=> 1,
'parentCampaign'=> 1,
'parentCampaignId'=> 1,
'campaignStart'=> 1,
'campaignEnd'=> 1,
'statDate'=> 1,
'engagements'=> 1,
'tagFilter' => 1
]
];
if (count($match) > 0) {
$query = [
$match,
$project,
];
} else {
$query = [
$project,
];
}
// Paging
if ($start !== null && $length !== null) {
// Sort by id (statId) - we must sort before we do pagination as mongo stat has descending order indexing on start/end
$query[]['$sort'] = ['id'=> 1];
$query[]['$skip'] = $start;
$query[]['$limit'] = $length;
}
$cursor = $collection->aggregate($query, ['allowDiskUse' => true]);
$result = new TimeSeriesMongoDbResults($cursor);
// Total (we have worked this out above if we have paging enabled, otherwise its 0)
$result->totalCount = $total;
} catch (\Exception $e) {
$this->log->error('Error: Get total. '. $e->getMessage());
throw new GeneralException(__('Sorry we encountered an error getting Proof of Play data, please consult your administrator'));
}
return $result;
}
/** @inheritdoc */
public function getExportStatsCount($filterBy = [])
{
// do we consider that the fromDt and toDt will always be provided?
$fromDt = $filterBy['fromDt'] ?? null;
$toDt = $filterBy['toDt'] ?? null;
$displayIds = $filterBy['displayIds'] ?? [];
// Match query
$match = [];
// fromDt/toDt Filter
if (($fromDt != null) && ($toDt != null)) {
$fromDt = new UTCDateTime($fromDt->format('U')*1000);
$match['$match']['end'] = ['$gt' => $fromDt];
$toDt = new UTCDateTime($toDt->format('U')*1000);
$match['$match']['start'] = ['$lte' => $toDt];
}
// Displays Filter
if (count($displayIds) != 0) {
$match['$match']['displayId'] = ['$in' => $displayIds];
}
$collection = $this->getClient()->selectCollection($this->config['database'], $this->table);
// Get total
try {
$totalQuery = [
$match,
[
'$group' => [
'_id'=> null,
'count' => ['$sum' => 1],
]
],
];
$totalCursor = $collection->aggregate($totalQuery, ['allowDiskUse' => true]);
$totalCount = $totalCursor->toArray();
$total = (count($totalCount) > 0) ? $totalCount[0]['count'] : 0;
} catch (\Exception $e) {
$this->log->error($e->getMessage());
throw new GeneralException(__('Sorry we encountered an error getting total number of Proof of Play data, please consult your administrator'));
}
return $total;
}
/** @inheritdoc */
public function deleteStats($maxage, $fromDt = null, $options = [])
{
// Filter the records we want to delete.
// we dont use $options['limit'] anymore.
// we delete all the records at once based on filter criteria (no-limit approach)
$filter = [
'start' => ['$lte' => new UTCDateTime($maxage->format('U')*1000)],
];
// Do we also limit the from date?
if ($fromDt !== null) {
$filter['end'] = ['$gt' => new UTCDateTime($fromDt->format('U')*1000)];
}
// Run the delete and return the number of records we deleted.
try {
$deleteResult = $this->getClient()
->selectCollection($this->config['database'], $this->table)
->deleteMany($filter);
return $deleteResult->getDeletedCount();
} catch (\MongoDB\Exception\RuntimeException $e) {
$this->log->error($e->getMessage());
throw new GeneralException('Stats cannot be deleted.');
}
}
/** @inheritdoc */
public function getEngine()
{
return 'mongodb';
}
/** @inheritdoc */
public function executeQuery($options = [])
{
$this->log->debug('Execute MongoDB query.');
$options = array_merge([
'allowDiskUse' => true
], $options);
// Aggregate command options
$aggregateConfig['allowDiskUse'] = $options['allowDiskUse'];
if (!empty($options['maxTimeMS'])) {
$aggregateConfig['maxTimeMS']= $options['maxTimeMS'];
}
$collection = $this->getClient()->selectCollection($this->config['database'], $options['collection']);
try {
$cursor = $collection->aggregate($options['query'], $aggregateConfig);
// log query
$this->log->debug(json_encode($options['query']));
$results = $cursor->toArray();
} catch (\MongoDB\Driver\Exception\RuntimeException $e) {
$this->log->error($e->getMessage());
$this->log->debug($e->getTraceAsString());
throw new GeneralException($e->getMessage());
}
return $results;
}
}

View File

@@ -0,0 +1,578 @@
<?php
/*
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
use Carbon\Carbon;
use Xibo\Factory\CampaignFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Service\LogServiceInterface;
use Xibo\Support\Exception\GeneralException;
use Xibo\Support\Exception\InvalidArgumentException;
/**
* Class MySqlTimeSeriesStore
* @package Xibo\Storage
*/
class MySqlTimeSeriesStore implements TimeSeriesStoreInterface
{
// Keep all stats in this array after processing
private $stats = [];
private $layoutCampaignIds = [];
private $layoutIdsNotFound = [];
/** @var StorageServiceInterface */
private $store;
/** @var LogServiceInterface */
private $log;
/** @var LayoutFactory */
protected $layoutFactory;
/** @var CampaignFactory */
protected $campaignFactory;
/**
* @inheritdoc
*/
public function __construct($config = null)
{
}
/**
* @inheritdoc
*/
public function setDependencies($log, $layoutFactory, $campaignFactory, $mediaFactory, $widgetFactory, $displayFactory, $displayGroupFactory)
{
$this->log = $log;
$this->layoutFactory = $layoutFactory;
$this->campaignFactory = $campaignFactory;
return $this;
}
/** @inheritdoc */
public function addStat($statData)
{
// For a type "event" we have layoutid 0 so is campaignId
// otherwise we should try and resolve the campaignId
$campaignId = 0;
if ($statData['type'] != 'event') {
if (array_key_exists($statData['layoutId'], $this->layoutCampaignIds)) {
$campaignId = $this->layoutCampaignIds[$statData['layoutId']];
} else {
try {
// Get the layout campaignId
$campaignId = $this->layoutFactory->getCampaignIdFromLayoutHistory($statData['layoutId']);
// Put layout campaignId to memory
$this->layoutCampaignIds[$statData['layoutId']] = $campaignId;
} catch (GeneralException $error) {
if (!in_array($statData['layoutId'], $this->layoutIdsNotFound)) {
$this->layoutIdsNotFound[] = $statData['layoutId'];
$this->log->error('Layout not found. Layout Id: '. $statData['layoutId']);
}
return;
}
}
}
// Set to Unix Timestamp
$statData['statDate'] = $statData['statDate']->format('U');
$statData['fromDt'] = $statData['fromDt']->format('U');
$statData['toDt'] = $statData['toDt']->format('U');
$statData['campaignId'] = $campaignId;
$statData['displayId'] = $statData['display']->displayId;
$statData['engagements'] = json_encode($statData['engagements']);
unset($statData['display']);
$this->stats[] = $statData;
}
/** @inheritdoc */
public function addStatFinalize()
{
if (count($this->stats) > 0) {
$sql = '
INSERT INTO `stat` (
`type`,
`statDate`,
`start`,
`end`,
`scheduleID`,
`displayID`,
`campaignID`,
`layoutID`,
`mediaID`,
`tag`,
`widgetId`,
`duration`,
`count`,
`engagements`,
`parentCampaignId`
)
VALUES ';
$placeHolders = '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$sql = $sql . implode(', ', array_fill(1, count($this->stats), $placeHolders));
// Flatten the array
$data = [];
foreach ($this->stats as $stat) {
// Be explicit about the order of the keys
$ordered = [
'type' => $stat['type'],
'statDate' => $stat['statDate'],
'fromDt' => $stat['fromDt'],
'toDt' => $stat['toDt'],
'scheduleId' => $stat['scheduleId'],
'displayId' => $stat['displayId'],
'campaignId' => $stat['campaignId'],
'layoutId' => $stat['layoutId'],
'mediaId' => $stat['mediaId'],
'tag' => $stat['tag'],
'widgetId' => $stat['widgetId'],
'duration' => $stat['duration'],
'count' => $stat['count'],
'engagements' => $stat['engagements'],
'parentCampaignId' => $stat['parentCampaignId'],
];
// Add each value to another array in order
foreach ($ordered as $field) {
$data[] = $field;
}
}
$this->store->update($sql, $data);
}
}
/** @inheritdoc */
public function getEarliestDate()
{
$result = $this->store->select('SELECT MIN(start) AS minDate FROM `stat`', []);
$earliestDate = $result[0]['minDate'];
return ($earliestDate === null)
? null
: Carbon::createFromFormat('U', $result[0]['minDate']);
}
/** @inheritdoc */
public function getStats($filterBy = [], $isBufferedQuery = false)
{
$fromDt = $filterBy['fromDt'] ?? null;
$toDt = $filterBy['toDt'] ?? null;
$statDate = $filterBy['statDate'] ?? null;
$statDateLessThan = $filterBy['statDateLessThan'] ?? null;
// In the case of user switches from mongo to mysql - laststatId were saved as Mongo ObjectId string
if (isset($filterBy['statId'])) {
if (!is_numeric($filterBy['statId'])) {
throw new InvalidArgumentException(__('Invalid statId provided'), 'statId');
} else {
$statId = $filterBy['statId'];
}
} else {
$statId = null;
}
$type = $filterBy['type'] ?? null;
$displayIds = $filterBy['displayIds'] ?? [];
$layoutIds = $filterBy['layoutIds'] ?? [];
$mediaIds = $filterBy['mediaIds'] ?? [];
$campaignId = $filterBy['campaignId'] ?? null;
$parentCampaignId = $filterBy['parentCampaignId'] ?? null;
$mustHaveParentCampaign = $filterBy['mustHaveParentCampaign'] ?? false;
$eventTag = $filterBy['eventTag'] ?? null;
// Tag embedding
$embedDisplayTags = $filterBy['displayTags'] ?? false;
$embedLayoutTags = $filterBy['layoutTags'] ?? false;
$embedMediaTags = $filterBy['mediaTags'] ?? false;
// Limit
$start = $filterBy['start'] ?? null;
$length = $filterBy['length'] ?? null;
$params = [];
$select = 'SELECT stat.statId,
stat.statDate,
stat.type,
stat.displayId,
stat.widgetId,
stat.layoutId,
stat.mediaId,
stat.campaignId,
stat.parentCampaignId,
stat.start as start,
stat.end as end,
stat.tag,
stat.duration,
stat.count,
stat.engagements,
display.Display as display,
layout.Layout as layout,
campaign.campaign as parentCampaign,
media.Name AS media ';
if ($embedDisplayTags) {
$select .= ',
(
SELECT GROUP_CONCAT(DISTINCT CONCAT(tag, \'|\', IFNULL(value, \'null\')))
FROM tag
INNER JOIN lktagdisplaygroup
ON lktagdisplaygroup.tagId = tag.tagId
INNER JOIN `displaygroup`
ON lktagdisplaygroup.displayGroupId = displaygroup.displayGroupId
AND `displaygroup`.isDisplaySpecific = 1
INNER JOIN `lkdisplaydg`
ON lkdisplaydg.displayGroupId = displaygroup.displayGroupId
WHERE lkdisplaydg.displayId = stat.displayId
GROUP BY lktagdisplaygroup.displayGroupId
) AS displayTags
';
}
if ($embedMediaTags) {
$select .= ',
(
SELECT GROUP_CONCAT(DISTINCT CONCAT(tag, \'|\', IFNULL(value, \'null\')))
FROM tag
INNER JOIN lktagmedia
ON lktagmedia.tagId = tag.tagId
WHERE lktagmedia.mediaId = media.mediaId
GROUP BY lktagmedia.mediaId
) AS mediaTags
';
}
if ($embedLayoutTags) {
$select .= ',
(
SELECT GROUP_CONCAT(DISTINCT CONCAT(tag, \'|\', IFNULL(value, \'null\')))
FROM tag
INNER JOIN lktaglayout
ON lktaglayout.tagId = tag.tagId
WHERE lktaglayout.layoutId = layout.layoutId
GROUP BY lktaglayout.layoutId
) AS layoutTags
';
}
$body = '
FROM stat
LEFT OUTER JOIN display
ON stat.DisplayID = display.DisplayID
LEFT OUTER JOIN layout
ON layout.LayoutID = stat.LayoutID
LEFT OUTER JOIN campaign
ON campaign.campaignID = stat.parentCampaignID
LEFT OUTER JOIN media
ON media.mediaID = stat.mediaID
LEFT OUTER JOIN widget
ON widget.widgetId = stat.widgetId
WHERE 1 = 1 ';
// fromDt/toDt Filter
if (($fromDt != null) && ($toDt != null)) {
$body .= ' AND stat.end > '. $fromDt->format('U') . ' AND stat.start <= '. $toDt->format('U');
} else if (($fromDt != null) && empty($toDt)) {
$body .= ' AND stat.start >= '. $fromDt->format('U');
}
// statDate Filter
// get the next stats from the given date
if ($statDate != null) {
$body .= ' AND stat.statDate >= ' . $statDate->format('U');
}
if ($statDateLessThan != null) {
$body .= ' AND stat.statDate < ' . $statDateLessThan->format('U');
}
if ($statId != null) {
$body .= ' AND stat.statId > '. $statId;
}
if (count($displayIds) > 0) {
$body .= ' AND stat.displayID IN (' . implode(',', $displayIds) . ')';
}
// Type filter
if ($type == 'layout') {
$body .= ' AND `stat`.type = \'layout\' ';
} else if ($type == 'media') {
$body .= ' AND `stat`.type = \'media\' AND IFNULL(`media`.mediaId, 0) <> 0 ';
} else if ($type == 'widget') {
$body .= ' AND `stat`.type = \'widget\' AND IFNULL(`widget`.widgetId, 0) <> 0 ';
} else if ($type == 'event') {
$body .= ' AND `stat`.type = \'event\' ';
}
// Event Tag Filter
if ($eventTag) {
$body .= ' AND `stat`.tag = :eventTag';
$params['eventTag'] = $eventTag;
}
// Layout Filter
if (count($layoutIds) != 0) {
$layoutSql = '';
$i = 0;
foreach ($layoutIds as $layoutId) {
$i++;
$layoutSql .= ':layoutId_' . $i . ',';
$params['layoutId_' . $i] = $layoutId;
}
$body .= ' AND `stat`.campaignId IN (SELECT campaignId FROM `layouthistory` WHERE layoutId IN (' . trim($layoutSql, ',') . ')) ';
}
// Media Filter
if (count($mediaIds) != 0) {
$mediaSql = '';
$i = 0;
foreach ($mediaIds as $mediaId) {
$i++;
$mediaSql .= ':mediaId_' . $i . ',';
$params['mediaId_' . $i] = $mediaId;
}
$body .= ' AND `media`.mediaId IN (' . trim($mediaSql, ',') . ')';
}
// Parent Campaign Filter
if ($parentCampaignId != null) {
$body .= ' AND `stat`.parentCampaignId = :parentCampaignId ';
$params['parentCampaignId'] = $parentCampaignId;
}
// Has Parent Campaign Filter
if ($mustHaveParentCampaign) {
$body .= ' AND IFNULL(`stat`.parentCampaignId, 0) != 0 ';
}
// Campaign
// --------
// Filter on Layouts linked to a Campaign
if ($campaignId != null) {
$body .= ' AND stat.campaignId IN (
SELECT lkcampaignlayout.campaignId
FROM `lkcampaignlayout`
INNER JOIN `campaign`
ON `lkcampaignlayout`.campaignId = `campaign`.campaignId
AND `campaign`.isLayoutSpecific = 1
INNER JOIN `lkcampaignlayout` lkcl
ON lkcl.layoutid = lkcampaignlayout.layoutId
WHERE lkcl.campaignId = :campaignId
) ';
$params['campaignId'] = $campaignId;
}
// Sorting
$body .= ' ORDER BY stat.statId ';
$limit = '';
if ($start !== null && $length !== null) {
$limit = ' LIMIT ' . $start . ', ' . $length;
}
// Total count if paging is enabled.
$totalNumberOfRecords = 0;
if ($start !== null && $length !== null) {
$totalNumberOfRecords = $this->store->select('
SELECT COUNT(*) AS total FROM ( ' . $select . $body . ') total
', $params)[0]['total'];
}
// Join our SQL statement together
$sql = $select . $body. $limit;
// Write this to our log
$this->log->sql($sql, $params);
// Run our query using a connection object (to save memory)
$connection = $this->store->getConnection();
$connection->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $isBufferedQuery);
// Prepare the statement
$statement = $connection->prepare($sql);
// Execute
$statement->execute($params);
// Create a results object and set the total number of records on it.
$results = new TimeSeriesMySQLResults($statement);
$results->totalCount = $totalNumberOfRecords;
return $results;
}
/** @inheritdoc */
public function getExportStatsCount($filterBy = [])
{
$fromDt = isset($filterBy['fromDt']) ? $filterBy['fromDt'] : null;
$toDt = isset($filterBy['toDt']) ? $filterBy['toDt'] : null;
$displayIds = isset($filterBy['displayIds']) ? $filterBy['displayIds'] : [];
$params = [];
$sql = ' SELECT COUNT(*) AS total FROM `stat` WHERE 1 = 1 ';
// fromDt/toDt Filter
if (($fromDt != null) && ($toDt != null)) {
$sql .= ' AND stat.end > '. $fromDt->format('U') . ' AND stat.start <= '. $toDt->format('U');
}
if (count($displayIds) > 0) {
$sql .= ' AND stat.displayID IN (' . implode(',', $displayIds) . ')';
}
// Total count
$resTotal = $this->store->select($sql, $params);
// Total
return isset($resTotal[0]['total']) ? $resTotal[0]['total'] : 0;
}
/** @inheritdoc */
public function deleteStats($maxage, $fromDt = null, $options = [])
{
// Set some default options
$options = array_merge([
'maxAttempts' => 10,
'statsDeleteSleep' => 3,
'limit' => 10000,
], $options);
// Convert to a simple type so that we can pass by reference to bindParam.
$maxage = $maxage->format('U');
try {
$i = 0;
$rows = 1;
if ($fromDt !== null) {
// Convert to a simple type so that we can pass by reference to bindParam.
$fromDt = $fromDt->format('U');
// Prepare a delete statement which we will use multiple times
$delete = $this->store->getConnection()
->prepare('DELETE FROM `stat` WHERE stat.start <= :toDt AND stat.end > :fromDt ORDER BY statId LIMIT :limit');
$delete->bindParam(':fromDt', $fromDt, \PDO::PARAM_STR);
$delete->bindParam(':toDt', $maxage, \PDO::PARAM_STR);
$delete->bindParam(':limit', $options['limit'], \PDO::PARAM_INT);
} else {
$delete = $this->store->getConnection()
->prepare('DELETE FROM `stat` WHERE stat.start <= :maxage LIMIT :limit');
$delete->bindParam(':maxage', $maxage, \PDO::PARAM_STR);
$delete->bindParam(':limit', $options['limit'], \PDO::PARAM_INT);
}
$count = 0;
while ($rows > 0) {
$i++;
// Run the delete
$delete->execute();
// Find out how many rows we've deleted
$rows = $delete->rowCount();
$count += $rows;
// We shouldn't be in a transaction, but commit anyway just in case
$this->store->commitIfNecessary();
// Give SQL time to recover
if ($rows > 0) {
$this->log->debug('Stats delete effected ' . $rows . ' rows, sleeping.');
sleep($options['statsDeleteSleep']);
}
// Break if we've exceeded the maximum attempts, assuming that has been provided
if ($options['maxAttempts'] > -1 && $i >= $options['maxAttempts']) {
break;
}
}
$this->log->debug('Deleted Stats back to ' . $maxage . ' in ' . $i . ' attempts');
return $count;
}
catch (\PDOException $e) {
$this->log->error($e->getMessage());
throw new GeneralException('Stats cannot be deleted.');
}
}
/** @inheritdoc */
public function executeQuery($options = [])
{
$this->log->debug('Execute MySQL query.');
$query = $options['query'];
$params = $options['params'];
$dbh = $this->store->getConnection();
$sth = $dbh->prepare($query);
$sth->execute($params);
// Get the results
$results = $sth->fetchAll();
return $results;
}
/**
* @param StorageServiceInterface $store
* @return $this
*/
public function setStore($store)
{
$this->store = $store;
return $this;
}
/** @inheritdoc */
public function getEngine()
{
return 'mysql';
}
}

View File

@@ -0,0 +1,482 @@
<?php
/*
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
use Xibo\Service\ConfigService;
use Xibo\Service\LogService;
use Xibo\Support\Exception\DeadlockException;
/**
* Class PDOConnect
* Manages global connection state and the creation of connections
* @package Xibo\Storage
*/
class PdoStorageService implements StorageServiceInterface
{
/** @var \PDO[] An array of connections */
private static $conn = [];
/** @var array Statistics */
private static $stats = [];
/** @var string */
private static $version;
/**
* Logger
* @var LogService
*/
private $log;
/**
* PDOConnect constructor.
* @param LogService $logger
*/
public function __construct($logger = null)
{
$this->log = $logger;
}
/** @inheritdoc */
public function setConnection($name = 'default')
{
// Create a new connection
self::$conn[$name] = PdoStorageService::newConnection($name);
return $this;
}
/** @inheritdoc */
public function close($name = null)
{
if ($name !== null && isset(self::$conn[$name])) {
self::$conn[$name] = null;
unset(self::$conn[$name]);
} else {
foreach (self::$conn as &$conn) {
$conn = null;
}
self::$conn = [];
}
}
/**
* Create a DSN from the host/db name
* @param string $host
* @param string|null $name
* @return string
*/
private static function createDsn($host, $name = null)
{
if (strstr($host, ':')) {
$hostParts = explode(':', $host);
$dsn = 'mysql:host=' . $hostParts[0] . ';port=' . $hostParts[1] . ';';
} else {
$dsn = 'mysql:host=' . $host . ';';
}
if ($name != null) {
$dsn .= 'dbname=' . $name . ';';
}
return $dsn;
}
/**
* @inheritDoc
*/
public static function newConnection(string $name)
{
// If we already have a connection, return it.
if (isset(self::$conn[$name])) {
return self::$conn[$name];
}
$dsn = PdoStorageService::createDsn(ConfigService::$dbConfig['host'], ConfigService::$dbConfig['name']);
$opts = [];
if (!empty(ConfigService::$dbConfig['ssl']) && ConfigService::$dbConfig['ssl'] !== 'none') {
$opts[\PDO::MYSQL_ATTR_SSL_CA] = ConfigService::$dbConfig['ssl'];
$opts[\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = ConfigService::$dbConfig['sslVerify'];
}
// Open the connection and set the error mode
$conn = new \PDO(
$dsn,
ConfigService::$dbConfig['user'],
ConfigService::$dbConfig['password'],
$opts
);
$conn->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$conn->query("SET NAMES 'utf8mb4'");
return $conn;
}
/** @inheritDoc */
public function connect($host, $user, $pass, $name = null, $ssl = null, $sslVerify = true)
{
if (!isset(self::$conn['default'])) {
$this->close('default');
}
$dsn = PdoStorageService::createDsn($host, $name);
$opts = [];
if (!empty($ssl) && $ssl !== 'none') {
$opts[\PDO::MYSQL_ATTR_SSL_CA] = $ssl;
$opts[\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = $sslVerify;
}
// Open the connection and set the error mode
self::$conn['default'] = new \PDO($dsn, $user, $pass, $opts);
self::$conn['default']->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
self::$conn['default']->query("SET NAMES 'utf8mb4'");
return self::$conn['default'];
}
/** @inheritdoc */
public function getConnection($name = 'default')
{
if (!isset(self::$conn[$name])) {
self::$conn[$name] = PdoStorageService::newConnection($name);
}
return self::$conn[$name];
}
/** @inheritdoc */
public function exists($sql, $params, $connection = 'default', $reconnect = false, $close = false)
{
if ($this->log != null) {
$this->log->sql($sql, $params);
}
try {
$sth = $this->getConnection($connection)->prepare($sql);
$sth->execute($params);
$exists = $sth->fetch();
$this->incrementStat($connection, 'exists');
if ($close) {
$this->close($connection);
}
if ($exists) {
return true;
} else {
return false;
}
} catch (\PDOException $PDOException) {
// Throw if we're not expected to reconnect.
if (!$reconnect) {
throw $PDOException;
}
$errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
if ($errorCode != 2006) {
throw $PDOException;
} else {
$this->close($connection);
return $this->exists($sql, $params, $connection, false, $close);
}
} catch (\ErrorException $exception) {
// Super odd we'd get one of these
// we're trying to catch "Error while sending QUERY packet."
if (!$reconnect) {
throw $exception;
}
// Try again
$this->close($connection);
return $this->exists($sql, $params, $connection, false, $close);
}
}
/** @inheritdoc */
public function insert($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false)
{
if ($this->log != null) {
$this->log->sql($sql, $params);
}
try {
if ($transaction && !$this->getConnection($connection)->inTransaction()) {
$this->getConnection($connection)->beginTransaction();
}
$sth = $this->getConnection($connection)->prepare($sql);
$sth->execute($params);
$id = intval($this->getConnection($connection)->lastInsertId());
$this->incrementStat($connection, 'insert');
if ($close) {
$this->close($connection);
}
return $id;
} catch (\PDOException $PDOException) {
// Throw if we're not expected to reconnect.
if (!$reconnect) {
throw $PDOException;
}
$errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
if ($errorCode != 2006) {
throw $PDOException;
} else {
$this->close($connection);
return $this->insert($sql, $params, $connection, false, $transaction, $close);
}
} catch (\ErrorException $exception) {
// Super odd we'd get one of these
// we're trying to catch "Error while sending QUERY packet."
if (!$reconnect) {
throw $exception;
}
// Try again
$this->close($connection);
return $this->insert($sql, $params, $connection, false, $transaction, $close);
}
}
/** @inheritdoc */
public function update($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false)
{
if ($this->log != null) {
$this->log->sql($sql, $params);
}
try {
if ($transaction && !$this->getConnection($connection)->inTransaction()) {
$this->getConnection($connection)->beginTransaction();
}
$sth = $this->getConnection($connection)->prepare($sql);
$sth->execute($params);
$rows = $sth->rowCount();
$this->incrementStat($connection, 'update');
if ($close) {
$this->close($connection);
}
return $rows;
} catch (\PDOException $PDOException) {
// Throw if we're not expected to reconnect.
if (!$reconnect) {
throw $PDOException;
}
$errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
if ($errorCode != 2006) {
throw $PDOException;
} else {
$this->close($connection);
return $this->update($sql, $params, $connection, false, $transaction, $close);
}
} catch (\ErrorException $exception) {
// Super odd we'd get one of these
// we're trying to catch "Error while sending QUERY packet."
if (!$reconnect) {
throw $exception;
}
// Try again
$this->close($connection);
return $this->update($sql, $params, $connection, false, $transaction, $close);
}
}
/** @inheritdoc */
public function select($sql, $params, $connection = 'default', $reconnect = false, $close = false)
{
if ($this->log != null) {
$this->log->sql($sql, $params);
}
try {
$sth = $this->getConnection($connection)->prepare($sql);
$sth->execute($params);
$records = $sth->fetchAll(\PDO::FETCH_ASSOC);
$this->incrementStat($connection, 'select');
if ($close) {
$this->close($connection);
}
return $records;
} catch (\PDOException $PDOException) {
$errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
// syntax error, log the sql and params in error level.
if ($errorCode == 1064 && $this->log != null) {
$this->log->sql($sql, $params, true);
}
// Throw if we're not expected to reconnect.
if (!$reconnect) {
throw $PDOException;
}
if ($errorCode != 2006) {
throw $PDOException;
} else {
$this->close($connection);
return $this->select($sql, $params, $connection, false, $close);
}
} catch (\ErrorException $exception) {
// Super odd we'd get one of these
// we're trying to catch "Error while sending QUERY packet."
if (!$reconnect) {
throw $exception;
}
// Try again
$this->close($connection);
return $this->select($sql, $params, $connection, false, $close);
}
}
/** @inheritdoc */
public function updateWithDeadlockLoop($sql, $params, $connection = 'default', $transaction = true, $close = false)
{
$maxRetries = 2;
// Should we log?
if ($this->log != null) {
$this->log->sql($sql, $params);
}
// Start a transaction?
if ($transaction && !$this->getConnection($connection)->inTransaction()) {
$this->getConnection($connection)->beginTransaction();
}
// Prepare the statement
$statement = $this->getConnection($connection)->prepare($sql);
// Deadlock protect this statement
$success = false;
$retries = $maxRetries;
do {
try {
$this->incrementStat($connection, 'update');
$statement->execute($params);
// Successful
$success = true;
} catch (\PDOException $PDOException) {
$errorCode = $PDOException->errorInfo[1] ?? $PDOException->getCode();
if ($errorCode != 1213 && $errorCode != 1205) {
throw $PDOException;
}
}
if ($success) {
break;
}
// Sleep a bit, give the DB time to breathe
$queryHash = substr($sql, 0, 15) . '... [' . md5($sql . json_encode($params)) . ']';
$this->log->debug('Retrying query after a short nap, try: ' . (3 - $retries)
. '. Query Hash: ' . $queryHash);
usleep(10000);
} while ($retries--);
if (!$success) {
throw new DeadlockException(sprintf(
__('Failed to write to database after %d retries. Please try again later.'),
$maxRetries
));
}
if ($close) {
$this->close($connection);
}
}
/** @inheritdoc */
public function commitIfNecessary($name = 'default', $close = false)
{
if ($this->getConnection($name)->inTransaction()) {
$this->incrementStat($name, 'commit');
$this->getConnection($name)->commit();
}
}
/**
* Set the TimeZone for this connection
* @param string $timeZone e.g. -8:00
* @param string $connection
*/
public function setTimeZone($timeZone, $connection = 'default')
{
$this->getConnection($connection)->query('SET time_zone = \'' . $timeZone . '\';');
$this->incrementStat($connection, 'utility');
}
/**
* PDO stats
* @return array
*/
public function stats()
{
self::$stats['connections'] = count(self::$conn);
return self::$stats;
}
/** @inheritdoc */
public static function incrementStat($connection, $key)
{
$currentCount = (isset(self::$stats[$connection][$key])) ? self::$stats[$connection][$key] : 0;
self::$stats[$connection][$key] = $currentCount + 1;
}
/**
* @inheritdoc
*/
public function getVersion()
{
if (self::$version === null) {
$results = $this->select('SELECT version() AS v', []);
if (count($results) <= 0) {
return null;
}
self::$version = explode('-', $results[0]['v'])[0];
}
return self::$version;
}
}

View File

@@ -0,0 +1,172 @@
<?php
/*
* Copyright (c) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
use Xibo\Service\LogService;
use Xibo\Support\Exception\DeadlockException;
/**
* Interface StorageInterface
* @package Xibo\Storage
*/
interface StorageServiceInterface
{
/**
* PDOConnect constructor.
* @param LogService $logger
*/
public function __construct($logger);
/**
* Set a connection
* @param string $name
* @return $this
*/
public function setConnection($name = 'default');
/**
* Closes the stored connection
* @param string|null $name The name of the connection, or null for all connections
*/
public function close($name = null);
/**
* Open a new connection using the stored details
* @param $name string The name of the connection, e.g. "default"
* @return \PDO
*/
public static function newConnection(string $name);
/**
* Open a connection with the specified details
* @param string $host
* @param string $user
* @param string $pass
* @param string|null $name
* @param string|null $ssl
* @param boolean $sslVerify
* @return \PDO
*/
public function connect($host, $user, $pass, $name = null, $ssl = null, $sslVerify = true);
/**
* Get the Raw Connection
* @param string $name The connection name
* @return \PDO
*/
public function getConnection($name = 'default');
/**
* Check to see if the query returns records
* @param string $sql
* @param array $params
* @param string|null $connection Note: the transaction for non-default connections is not automatically committed
* @param bool $reconnect
* @param bool $close
* @return bool
*/
public function exists($sql, $params, $connection = 'default', $reconnect = false, $close = false);
/**
* Run Insert SQL
* @param string $sql
* @param array $params
* @param string|null $connection Note: the transaction for non-default connections is not automatically committed
* @param bool $reconnect
* @param bool $close
* @return int
* @throws \PDOException
*/
public function insert($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false);
/**
* Run Update SQL
* @param string $sql
* @param array $params
* @param string|null $connection Note: the transaction for non-default connections is not automatically committed
* @param bool $reconnect
* @param bool $transaction If we are already in a transaction, then do nothing. Otherwise, start one.
* @param bool $close
* @return int affected rows
* @throws \PDOException
*/
public function update($sql, $params, $connection = 'default', $reconnect = false, $transaction = true, $close = false);
/**
* Run Select SQL
* @param $sql
* @param $params
* @param string|null $connection Note: the transaction for non-default connections is not automatically committed
* @param bool $reconnect
* @param bool $close
* @return array
* @throws \PDOException
*/
public function select($sql, $params, $connection = 'default', $reconnect = false, $close = false);
/**
* Run the SQL statement with a deadlock loop
* @param $sql
* @param $params
* @param string|null $connection Note: the transaction for non-default connections is not automatically committed
* @param bool $close
* @param bool $transaction If we are already in a transaction, then do nothing. Otherwise, start one.
* @return mixed
* @throws DeadlockException
*/
public function updateWithDeadlockLoop($sql, $params, $connection = 'default', $transaction = true, $close = false);
/**
* Commit if necessary
* @param $name
* @param bool $close
*/
public function commitIfNecessary($name = 'default', $close = false);
/**
* Set the TimeZone for this connection
* @param string|null $connection
* @param string $timeZone e.g. -8:00
*/
public function setTimeZone($timeZone, $connection = 'default');
/**
* PDO stats
* @return array
*/
public function stats();
/**
* @param $connection
* @param $key
* @return mixed
*/
public static function incrementStat($connection, $key);
/**
* Get the Storage engine version
* @return string
*/
public function getVersion();
}

View File

@@ -0,0 +1,134 @@
<?php
/**
* Copyright (C) 2019 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
use Carbon\Carbon;
/**
* Class TimeSeriesMongoDbResults
* @package Xibo\Storage
*/
class TimeSeriesMongoDbResults implements TimeSeriesResultsInterface
{
/**
* Statement
* @var \MongoDB\Driver\Cursor
*/
private $object;
/**
* Total number of stats
*/
public $totalCount;
/**
* Iterator
* @var \IteratorIterator
*/
private $iterator;
/**
* @inheritdoc
*/
public function __construct($cursor = null)
{
$this->object = $cursor;
}
/** @inheritdoc */
public function getArray()
{
$this->object->setTypeMap(['root' => 'array']);
return $this->object->toArray();
}
/** @inheritDoc */
public function getIdFromRow($row)
{
return (string)$row['id'];
}
/** @inheritDoc */
public function getDateFromValue($value)
{
return Carbon::instance($value->toDateTime());
}
/** @inheritDoc */
public function getEngagementsFromRow($row, $decoded = true)
{
if ($decoded) {
return $row['engagements'] ?? [];
} else {
return isset($row['engagements']) ? json_encode($row['engagements']) : '[]';
}
}
/** @inheritDoc */
public function getTagFilterFromRow($row)
{
return $row['tagFilter'] ?? [
'dg' => [],
'layout' => [],
'media' => []
];
}
/**
* Gets an iterator for this result set
* @return \IteratorIterator
*/
private function getIterator()
{
if ($this->iterator == null) {
$this->iterator = new \IteratorIterator($this->object);
$this->iterator->rewind();
}
return $this->iterator;
}
/** @inheritdoc */
public function getNextRow()
{
$this->getIterator();
if ($this->iterator->valid()) {
$document = $this->iterator->current();
$this->iterator->next();
return (array) $document;
}
return false;
}
/** @inheritdoc */
public function getTotalCount()
{
return $this->totalCount;
}
}

View File

@@ -0,0 +1,149 @@
<?php
/**
* Copyright (C) 2019 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
use Carbon\Carbon;
/**
* Class TimeSeriesMySQLResults
* @package Xibo\Storage
*/
class TimeSeriesMySQLResults implements TimeSeriesResultsInterface
{
/**
* Statement
* @var \PDOStatement
*/
private $object;
/**
* Total number of stats
*/
public $totalCount;
/**
* @inheritdoc
*/
public function __construct($stmtObject = null)
{
$this->object = $stmtObject;
}
/**
* @inheritdoc
*/
public function getArray()
{
return $this->object->fetchAll(\PDO::FETCH_ASSOC);
}
/** @inheritDoc */
public function getIdFromRow($row)
{
return $row['statId'];
}
/** @inheritDoc */
public function getDateFromValue($value)
{
return Carbon::createFromTimestamp($value);
}
/** @inheritDoc */
public function getEngagementsFromRow($row, $decoded = true)
{
if ($decoded) {
return isset($row['engagements']) ? json_decode($row['engagements']) : [];
} else {
return $row['engagements'] ?? '[]';
}
}
/** @inheritDoc */
public function getTagFilterFromRow($row)
{
// Tags
// Mimic the structure we have in Mongo.
$entry['tagFilter'] = [
'dg' => [],
'layout' => [],
'media' => []
];
// Display Tags
if (array_key_exists('displayTags', $row) && !empty($row['displayTags'])) {
$tags = explode(',', $row['displayTags']);
foreach ($tags as $tag) {
$tag = explode('|', $tag);
$value = $tag[1] ?? null;
$entry['tagFilter']['dg'][] = [
'tag' => $tag[0],
'value' => ($value === 'null') ? null : $value
];
}
}
// Layout Tags
if (array_key_exists('layoutTags', $row) && !empty($row['layoutTags'])) {
$tags = explode(',', $row['layoutTags']);
foreach ($tags as $tag) {
$tag = explode('|', $tag);
$value = $tag[1] ?? null;
$entry['tagFilter']['layout'][] = [
'tag' => $tag[0],
'value' => ($value === 'null') ? null : $value
];
}
}
// Media Tags
if (array_key_exists('mediaTags', $row) && !empty($row['mediaTags'])) {
$tags = explode(',', $row['mediaTags']);
foreach ($tags as $tag) {
$tag = explode('|', $tag);
$value = $tag[1] ?? null;
$entry['tagFilter']['media'][] = [
'tag' => $tag[0],
'value' => ($value === 'null') ? null : $value
];
}
}
}
/**
* @inheritdoc
*/
public function getNextRow()
{
return $this->object->fetch(\PDO::FETCH_ASSOC);
}
/**
* @inheritdoc
*/
public function getTotalCount()
{
return $this->totalCount;
}
}

View File

@@ -0,0 +1,80 @@
<?php
/**
* Copyright (C) 2019 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
/**
* Interface TimeSeriesResultsInterface
* @package Xibo\Service
*/
interface TimeSeriesResultsInterface
{
/**
* Time series results constructor
* @param null $object
*/
public function __construct($object = null);
/**
* Get statistics array
* @return array
*/
public function getArray();
/**
* Get next row
* @return array|false
*/
public function getNextRow();
/**
* Get total number of stats
* @return integer
*/
public function getTotalCount();
/**
* @param $row
* @return string|int
*/
public function getIdFromRow($row);
/**
* @param $row
* @param bool $decoded Should the engagements be decoded or strings?
* @return array
*/
public function getEngagementsFromRow($row, $decoded = true);
/**
* @param $row
* @return array
*/
public function getTagFilterFromRow($row);
/**
* @param string $value
* @return \Carbon\Carbon
*/
public function getDateFromValue($value);
}

View File

@@ -0,0 +1,131 @@
<?php
/**
* Copyright (C) 2019 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Xibo\Storage;
use Carbon\Carbon;
use Xibo\Factory\CampaignFactory;
use Xibo\Factory\DisplayFactory;
use Xibo\Factory\LayoutFactory;
use Xibo\Factory\MediaFactory;
use Xibo\Factory\WidgetFactory;
use Xibo\Service\LogServiceInterface;
use Xibo\Support\Exception\GeneralException;
/**
* Interface TimeSeriesStoreInterface
* @package Xibo\Service
*/
interface TimeSeriesStoreInterface
{
/**
* Time series constructor.
* @param array $config
*/
public function __construct($config = null);
/**
* Set Time series Dependencies
* @param LogServiceInterface $logger
* @param LayoutFactory $layoutFactory
* @param CampaignFactory $campaignFactory
* @param MediaFactory $mediaFactory
* @param WidgetFactory $widgetFactory
* @param DisplayFactory $displayFactory
* @param \Xibo\Entity\DisplayGroup $displayGroupFactory
*/
public function setDependencies(
$logger,
$layoutFactory,
$campaignFactory,
$mediaFactory,
$widgetFactory,
$displayFactory,
$displayGroupFactory
);
/**
* @param \Xibo\Storage\StorageServiceInterface $store
* @return $this
*/
public function setStore($store);
/**
* Process and add a single statdata to array
* @param $statData array
*/
public function addStat($statData);
/**
* Write statistics to DB
*/
public function addStatFinalize();
/**
* Get the earliest date
* @return \Carbon\Carbon|null
*/
public function getEarliestDate();
/**
* Get statistics
* @param $filterBy array[mixed]|null
* @param $isBufferedQuery bool Option to set buffered queries in MySQL
* @throws GeneralException
* @return TimeSeriesResultsInterface
*/
public function getStats($filterBy = [], $isBufferedQuery = false);
/**
* Get total count of export statistics
* @param $filterBy array[mixed]|null
* @throws GeneralException
* @return TimeSeriesResultsInterface
*/
public function getExportStatsCount($filterBy = []);
/**
* Delete statistics
* @param $toDt Carbon
* @param $fromDt Carbon|null
* @param $options array
* @throws GeneralException
* @return int number of deleted stat records
* @throws \Exception
*/
public function deleteStats($toDt, $fromDt = null, $options = []);
/**
* Execute query
* @param $options array|[]
* @throws GeneralException
* @return array
*/
public function executeQuery($options = []);
/**
* Get the statistic store
* @return string
*/
public function getEngine();
}