Files
Cloud-CMS/lib/Entity/DataSetColumn.php

522 lines
17 KiB
PHP
Raw Permalink Normal View History

2025-12-02 10:32:59 -05:00
<?php
/*
* Copyright (C) 2025 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\Entity;
use Illuminate\Support\Str;
use Respect\Validation\Validator as v;
use Xibo\Factory\DataSetColumnFactory;
use Xibo\Factory\DataSetColumnTypeFactory;
use Xibo\Factory\DataTypeFactory;
use Xibo\Service\LogServiceInterface;
use Xibo\Storage\StorageServiceInterface;
use Xibo\Support\Exception\InvalidArgumentException;
use Xibo\Support\Exception\NotFoundException;
use Xibo\Widget\Definition\Sql;
/**
* Class DataSetColumn
* @package Xibo\Entity
*
* @SWG\Definition()
*/
class DataSetColumn implements \JsonSerializable
{
use EntityTrait;
/**
* @SWG\Property(description="The ID of this DataSetColumn")
* @var int
*/
public $dataSetColumnId;
/**
* @SWG\Property(description="The ID of the DataSet that this Column belongs to")
* @var int
*/
public $dataSetId;
/**
* @SWG\Property(description="The Column Heading")
* @var string
*/
public $heading;
/**
* @SWG\Property(description="The ID of the DataType for this Column")
* @var int
*/
public $dataTypeId;
/**
* @SWG\Property(description="The ID of the ColumnType for this Column")
* @var int
*/
public $dataSetColumnTypeId;
/**
* @SWG\Property(description="Comma separated list of valid content for drop down columns")
* @var string
*/
public $listContent;
/**
* @SWG\Property(description="The order this column should be displayed")
* @var int
*/
public $columnOrder;
/**
* @SWG\Property(description="A MySQL formula for this column")
* @var string
*/
public $formula;
/**
* @SWG\Property(description="The data type for this Column")
* @var string
*/
public $dataType;
/**
* @SWG\Property(description="The data field of the remote DataSet as a JSON-String")
* @var string
*/
public $remoteField;
/**
* @SWG\Property(description="Does this column show a filter on the data entry page?")
* @var string
*/
public $showFilter = 0;
/**
* @SWG\Property(description="Does this column allow a sorting on the data entry page?")
* @var string
*/
public $showSort = 0;
/**
* @SWG\Property(description="The column type for this Column")
* @var string
*/
public $dataSetColumnType;
/**
* @SWG\Property(description="Help text that should be displayed when entering data for this Column.")
* @var string
*/
public $tooltip;
/**
* @SWG\Property(description="Flag indicating whether value must be provided for this Column.")
* @var int
*/
public $isRequired = 0;
/**
* @SWG\Property(description="Date format of dates in the source for remote DataSet.")
* @var string
*/
public $dateFormat;
/** @var DataSetColumnFactory */
private $dataSetColumnFactory;
/** @var DataTypeFactory */
private $dataTypeFactory;
/** @var DataSetColumnTypeFactory */
private $dataSetColumnTypeFactory;
/**
* The prior dataset column id, when cloning
* @var int
*/
public $priorDatasetColumnId;
/**
* Entity constructor.
* @param StorageServiceInterface $store
* @param LogServiceInterface $log
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
* @param DataSetColumnFactory $dataSetColumnFactory
* @param DataTypeFactory $dataTypeFactory
* @param DataSetColumnTypeFactory $dataSetColumnTypeFactory
*/
public function __construct($store, $log, $dispatcher, $dataSetColumnFactory, $dataTypeFactory, $dataSetColumnTypeFactory)
{
$this->excludeProperty('priorDatasetColumnId');
$this->setCommonDependencies($store, $log, $dispatcher);
$this->dataSetColumnFactory = $dataSetColumnFactory;
$this->dataTypeFactory = $dataTypeFactory;
$this->dataSetColumnTypeFactory = $dataSetColumnTypeFactory;
}
/**
* Clone
*/
public function __clone()
{
$this->priorDatasetColumnId = $this->dataSetColumnId;
$this->dataSetColumnId = null;
$this->dataSetId = null;
}
/**
* List Content Array
* @return array
*/
public function listContentArray()
{
return explode(',', $this->listContent);
}
/**
* Validate
* @throws InvalidArgumentException
*/
public function validate($options = []): void
{
$options = array_merge([
'testFormulas' => true,
'allowSpacesInHeading' => false,
], $options);
if ($this->dataSetId == 0 || $this->dataSetId == '') {
throw new InvalidArgumentException(__('Missing dataSetId'), 'dataSetId');
}
if ($this->dataTypeId == 0 || $this->dataTypeId == '') {
throw new InvalidArgumentException(__('Missing dataTypeId'), 'dataTypeId');
}
if ($this->dataSetColumnTypeId == 0 || $this->dataSetColumnTypeId == '') {
throw new InvalidArgumentException(__('Missing dataSetColumnTypeId'), 'dataSetColumnTypeId');
}
if ($this->heading == '') {
throw new InvalidArgumentException(__('Please provide a column heading.'), 'heading');
}
// Column heading should not allow reserved/disallowed SQL words
if (Str::contains($this->heading, Sql::DISALLOWED_KEYWORDS, true)) {
throw new InvalidArgumentException(
sprintf(
__('Headings cannot contain reserved words, such as %s'),
implode(', ', Sql::DISALLOWED_KEYWORDS),
),
'heading',
);
}
// We allow spaces here for backwards compatibility, but only on import and edit.
$additionalCharacters = $options['allowSpacesInHeading'] ? ' ' : '';
if (!v::stringType()->alnum($additionalCharacters)->validate($this->heading)
|| strtolower($this->heading) == 'id'
) {
throw new InvalidArgumentException(sprintf(
__('Please provide an alternative column heading %s can not be used.'),
$this->heading
), 'heading');
}
if ($this->dataSetColumnTypeId == 2 && $this->formula == '') {
throw new InvalidArgumentException(__('Please enter a valid formula'), 'formula');
}
// Make sure this column name is unique
$columns = $this->dataSetColumnFactory->getByDataSetId($this->dataSetId);
foreach ($columns as $column) {
if ($column->heading == $this->heading
&& ($this->dataSetColumnId == null || $column->dataSetColumnId != $this->dataSetColumnId)
) {
throw new InvalidArgumentException(
__('A column already exists with this name, please choose another'),
'heading',
);
}
}
// Check the actual values
try {
$this->dataTypeFactory->getById($this->dataTypeId);
} catch (NotFoundException $e) {
throw new InvalidArgumentException(__('Provided Data Type doesn\'t exist'), 'datatype');
}
try {
$dataSetColumnType = $this->dataSetColumnTypeFactory->getById($this->dataSetColumnTypeId);
// If we are a remote column, validate we have a field
if (strtolower($dataSetColumnType->dataSetColumnType) === 'remote'
&& ($this->remoteField === '' || $this->remoteField === null)) {
throw new InvalidArgumentException(
__('Remote field is required when the column type is set to Remote'),
'remoteField',
);
}
} catch (NotFoundException) {
throw new InvalidArgumentException(
__('Provided DataSet Column Type doesn\'t exist'),
'dataSetColumnTypeId',
);
}
// Should we validate the list content?
if ($this->dataSetColumnId != 0 && $this->listContent != '') {
// Look up all DataSet data in this table to make sure that the existing data is covered by the list content
$list = $this->listContentArray();
// Add an empty field
$list[] = '';
// We can check this is valid by building up a NOT IN sql statement, if we get results we know it's not good
$select = '';
$dbh = $this->getStore()->getConnection('isolated');
for ($i=0; $i < count($list); $i++) {
if (!empty($list[$i])) {
$list_val = $dbh->quote($list[$i]);
$select .= $list_val . ',';
}
}
$select = rtrim($select, ',');
// $select has been quoted in the for loop
// always test the original value of the column (we won't have changed the actualised table yet)
$SQL = 'SELECT id FROM `dataset_' . $this->dataSetId . '` WHERE `' . $this->getOriginalValue('heading') . '` NOT IN (' . $select . ')';//phpcs:ignore
$sth = $dbh->prepare($SQL);
$sth->execute();
if ($sth->fetch()) {
throw new InvalidArgumentException(
__('New list content value is invalid as it does not include values for existing data'),
'listcontent'
);
}
}
// if formula dataSetType is set and formula is not empty, try to execute the SQL to validate it - we're
// ignoring client side formulas here.
if ($options['testFormulas']
&& $this->dataSetColumnTypeId == 2
&& $this->formula != ''
&& !str_starts_with($this->formula, '$')
) {
try {
$count = 0;
$formula = str_ireplace(
Sql::DISALLOWED_KEYWORDS,
'',
htmlspecialchars_decode($this->formula, ENT_QUOTES),
$count
);
if ($count > 0) {
throw new InvalidArgumentException(__('Formula contains disallowed keywords.'));
}
$formula = str_replace('[DisplayId]', 0, $formula);
$formula = str_replace('[DisplayGeoLocation]', "ST_GEOMFROMTEXT('POINT(51.504 -0.104)')", $formula);
$this->getStore()->select('
SELECT *
FROM (
SELECT `id`, ' . $formula . ' AS `' . $this->heading . '`
FROM `dataset_' . $this->dataSetId . '`
) dataset
', [], 'isolated');
} catch (\Exception $e) {
$this->getLog()->debug('Formula validation failed with following message ' . $e->getMessage());
throw new InvalidArgumentException(__('Provided formula is invalid'), 'formula');
}
}
}
/**
* Save
* @param array $options
* @throws InvalidArgumentException
*/
public function save($options = [])
{
$options = array_merge([
'validate' => true,
'rebuilding' => false,
], $options);
if ($options['validate'] && !$options['rebuilding']) {
$this->validate($options);
}
if ($this->dataSetColumnId == 0) {
$this->add();
} else {
$this->edit($options);
}
}
/**
* Delete
*/
public function delete(bool $isDeletingDataset = false): void
{
$this->getStore()->update('DELETE FROM `datasetcolumn` WHERE DataSetColumnID = :dataSetColumnId', ['dataSetColumnId' => $this->dataSetColumnId]);
// Delete column (unless remote, or dropping the whole dataset)
if (!$isDeletingDataset && $this->dataSetColumnTypeId !== 2) {
$this->getStore()->update('ALTER TABLE `dataset_' . $this->dataSetId . '` DROP `' . $this->heading . '`', []);
}
}
/**
* Add
*/
private function add()
{
$this->dataSetColumnId = $this->getStore()->insert('
INSERT INTO `datasetcolumn` (DataSetID, Heading, DataTypeID, ListContent, ColumnOrder, DataSetColumnTypeID, Formula, RemoteField, `showFilter`, `showSort`, `tooltip`, `isRequired`, `dateFormat`)
VALUES (:dataSetId, :heading, :dataTypeId, :listContent, :columnOrder, :dataSetColumnTypeId, :formula, :remoteField, :showFilter, :showSort, :tooltip, :isRequired, :dateFormat)
', [
'dataSetId' => $this->dataSetId,
'heading' => $this->heading,
'dataTypeId' => $this->dataTypeId,
'listContent' => $this->listContent,
'columnOrder' => $this->columnOrder,
'dataSetColumnTypeId' => $this->dataSetColumnTypeId,
'formula' => $this->formula,
'remoteField' => $this->remoteField,
'showFilter' => $this->showFilter,
'showSort' => $this->showSort,
'tooltip' => $this->tooltip,
'isRequired' => $this->isRequired,
'dateFormat' => $this->dateFormat
]);
// Add Column to Underlying Table
if (($this->dataSetColumnTypeId == 1) || ($this->dataSetColumnTypeId == 3)) {
// Use a separate connection for DDL (it operates outside transactions)
$this->getStore()->update('ALTER TABLE `dataset_' . $this->dataSetId . '` ADD `' . $this->heading . '` ' . $this->sqlDataType() . ' NULL', [], 'isolated', false, false);
}
}
/**
* Edit
* @param array $options
* @throws InvalidArgumentException
*/
private function edit($options)
{
$params = [
'dataSetId' => $this->dataSetId,
'heading' => $this->heading,
'dataTypeId' => $this->dataTypeId,
'listContent' => $this->listContent,
'columnOrder' => $this->columnOrder,
'dataSetColumnTypeId' => $this->dataSetColumnTypeId,
'formula' => $this->formula,
'dataSetColumnId' => $this->dataSetColumnId,
'remoteField' => $this->remoteField,
'showFilter' => $this->showFilter,
'showSort' => $this->showSort,
'tooltip' => $this->tooltip,
'isRequired' => $this->isRequired,
'dateFormat' => $this->dateFormat
];
$sql = '
UPDATE `datasetcolumn` SET
dataSetId = :dataSetId,
Heading = :heading,
ListContent = :listContent,
ColumnOrder = :columnOrder,
DataTypeID = :dataTypeId,
DataSetColumnTypeID = :dataSetColumnTypeId,
Formula = :formula,
RemoteField = :remoteField,
`showFilter` = :showFilter,
`showSort` = :showSort,
`tooltip` = :tooltip,
`isRequired` = :isRequired,
`dateFormat` = :dateFormat
WHERE dataSetColumnId = :dataSetColumnId
';
$this->getStore()->update($sql, $params);
try {
if ($options['rebuilding'] && ($this->dataSetColumnTypeId == 1 || $this->dataSetColumnTypeId == 3)) {
$this->getStore()->update('ALTER TABLE `dataset_' . $this->dataSetId . '` ADD `' . $this->heading . '` ' . $this->sqlDataType() . ' NULL', [], 'isolated', false, false);
} else if (($this->dataSetColumnTypeId == 1 || $this->dataSetColumnTypeId == 3)
&& ($this->hasPropertyChanged('heading') || $this->hasPropertyChanged('dataTypeId'))) {
$sql = 'ALTER TABLE `dataset_' . $this->dataSetId . '` CHANGE `' . $this->getOriginalValue('heading') . '` `' . $this->heading . '` ' . $this->sqlDataType() . ' NULL DEFAULT NULL';
$this->getStore()->update($sql, [], 'isolated', false, false);
}
} catch (\PDOException $PDOException) {
$this->getLog()->error('Unable to change DataSetColumn because ' . $PDOException->getMessage());
throw new InvalidArgumentException(__('Existing data is incompatible with your new configuration'), 'dataSetData');
}
}
/**
* Get the SQL Data Type for this Column Definition
* @return string
*/
private function sqlDataType()
{
$dataType = null;
switch ($this->dataTypeId) {
case 2:
$dataType = 'DOUBLE';
break;
case 3:
$dataType = 'DATETIME';
break;
case 5:
$dataType = 'INT';
break;
case 1:
case 6:
$dataType = 'TEXT';
break;
case 4:
default:
$dataType = 'VARCHAR(1000)';
}
return $dataType;
}
}