diff options
author | Thomas Steur <tsteur@users.noreply.github.com> | 2017-10-03 23:22:01 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-10-03 23:22:01 +0300 |
commit | 9af4e95aa976f3a6533e95b776b5298f73e5f916 (patch) | |
tree | d612cd4d32019e9e52ce1398b8bf214ec06a8e0f /core | |
parent | 359c3ec875b554c7b71a933b26d18cdde0bb8f4e (diff) |
Better segment editor and fixes (#12040)
* column tweak
* fix install
* more tweaks
* rename column to dimension
* various fixes
* added new control expandable select
* starting to refactor segment selector
* make segment editor work again
* use translation keys
* defined some metrics
* set types
* simplify
* simplify
* fix join generator
* add possibility to use custom join table names when using query builder and it uses an inner query
* fix bug in query selector when selecting same field name from different tables twice
* more metadata
* more tweaks
* improve selector
* add possibility to use custom entity names
* also processed archived metrics
* generate sql filter, suggested values callback, and accept values automatically for columns with enums
* several tweaks
* focus search field when opening it
* various tweaks
* added missing method
* format and fix more metadata
* more fixes
* better definition
* define custom filter
* fix definition
* fix various tests
* fix more tests
* fix bug in logquery builder
* fix referrerurl segment was missing
* fix some tests
* fix more tests
* add group
* refactor for better definition
* fix a bug in log query builder when similar columns are used in archiver
* add goal metrics
* various fixes
* make datatable row more flexible
* various fixes and visualization enhancements
* simply segment editor and make it smaller
* remove trailing comma
* various fixes and added new dimension
* fix formatting of returning customer
* added missing primary key
* fixes
* various fixes and improvements
* make sure to update segment definition when selecting a value from auto complete list
* various fixes and more metrics
* more metrics
* more dimensions and fixes
* fix some tests
* fix some integration tests
* update submodule
* fix some system tests
* fix ui tests
* trigger new test run
* fix more ui tests
* fix system tests
* update submodule
* fix categories
* sort segments by category for more consistency
* add custom variables
* some translations and fixes
* add minute segment
* more segments
* added plurals
* added some docs
* fix test
* fix tests
* fix tests
* added suggested values
* fix some tests
* various fixes
* fix more tests
* allow to select segments on any site
* make sure to include file
* added doc block
* fix some system tests
* fix most system tests
* fix ui test
* fix system test
* adjust examples
* added more tests and docs
* no metrics for these dimensions
* added developer changelog and made some classes public api
* some fixes for entity names
* add possibility to set format metrics in test
* more consistency in defining the name
* get idsites only if provided
* fix integration tests
* added another segment for visit start hour and visit start minute
* more clear name for segment
* use old segment name to not break bc
* various fixes
* more test fixes
* fix no suggested values for new segment
* add event value
* for boolean dimensions only sum metric
* update available widgets when updating reporting menu
* Add new segments in developer changelog + typo
* fix system tests
* fix screenshot test
Diffstat (limited to 'core')
34 files changed, 2042 insertions, 365 deletions
diff --git a/core/API/DataTableManipulator.php b/core/API/DataTableManipulator.php index 7dad3b7a0d..e9140fecf6 100644 --- a/core/API/DataTableManipulator.php +++ b/core/API/DataTableManipulator.php @@ -10,6 +10,7 @@ namespace Piwik\API; use Exception; use Piwik\Archive\DataTableFactory; +use Piwik\Container\StaticContainer; use Piwik\DataTable\Row; use Piwik\DataTable; use Piwik\Period\Range; @@ -153,11 +154,11 @@ abstract class DataTableManipulator } $apiParameters = array(); - if (!empty($request['idDimension'])) { - $apiParameters['idDimension'] = $request['idDimension']; - } - if (!empty($request['idGoal'])) { - $apiParameters['idGoal'] = $request['idGoal']; + $entityNames = StaticContainer::get('entities.idNames'); + foreach ($entityNames as $idName) { + if (!empty($request[$idName])) { + $apiParameters[$idName] = $request[$idName]; + } } $meta = API::getInstance()->getMetadata($idSite, $this->apiModule, $this->apiMethod, $apiParameters); diff --git a/core/API/DocumentationGenerator.php b/core/API/DocumentationGenerator.php index 990d232163..e0116b7dfa 100644 --- a/core/API/DocumentationGenerator.php +++ b/core/API/DocumentationGenerator.php @@ -10,6 +10,7 @@ namespace Piwik\API; use Exception; use Piwik\Common; +use Piwik\Container\StaticContainer; use Piwik\Piwik; use Piwik\Url; use ReflectionClass; @@ -283,6 +284,15 @@ class DocumentationGenerator $aParameters['disable_generic_filters'] = false; $aParameters['expanded'] = false; $aParameters['idDimenson'] = false; + $aParameters['format_metrics'] = false; + + $entityNames = StaticContainer::get('entities.idNames'); + foreach ($entityNames as $entityName) { + if (isset($aParameters[$entityName])) { + continue; + } + $aParameters[$entityName] = false; + } $moduleName = Proxy::getInstance()->getModuleNameFromClassName($class); $aParameters = array_merge(array('module' => 'API', 'method' => $moduleName . '.' . $methodName), $aParameters); diff --git a/core/ArchiveProcessor/PluginsArchiver.php b/core/ArchiveProcessor/PluginsArchiver.php index 4c963e17bc..2b46ea29f6 100644 --- a/core/ArchiveProcessor/PluginsArchiver.php +++ b/core/ArchiveProcessor/PluginsArchiver.php @@ -147,7 +147,12 @@ class PluginsArchiver ); } catch (Exception $e) { $className = get_class($e); - $exception = new $className($e->getMessage() . " - caused by plugin $pluginName", $e->getCode(), $e); + + if ($className === 'PHPUnit_Framework_Exception' || (class_exists('PHPUnit_Framework_Exception', false) && is_subclass_of($className, 'PHPUnit_Framework_Exception'))) { + $exception = new $className($e->getMessage() . " - caused by plugin $pluginName", $e->getCode(), $e->getFile(), $e->getLine(), $e); + } else { + $exception = new $className($e->getMessage() . " - caused by plugin $pluginName", $e->getCode(), $e); + } throw $exception; } diff --git a/core/Columns/ComputedMetricFactory.php b/core/Columns/ComputedMetricFactory.php new file mode 100644 index 0000000000..0b2b2f5426 --- /dev/null +++ b/core/Columns/ComputedMetricFactory.php @@ -0,0 +1,57 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns; + +use Piwik\Piwik; +use Piwik\Plugin\ArchivedMetric; +use Piwik\Plugin\ComputedMetric; +use Piwik\Plugin\Report; + +/** + * A factory to create computed metrics. + * + * @api since Piwik 3.2.0 + */ +class ComputedMetricFactory +{ + /** + * @var MetricsList + */ + private $metricsList = null; + + /** + * Generates a new report metric factory. + * @param MetricsList $list A report list instance + * @ignore + */ + public function __construct(MetricsList $list) + { + $this->metricsList = $list; + } + + /** + * @return \Piwik\Plugin\ComputedMetric + */ + public function createComputedMetric($metricName1, $metricName2, $aggregation) + { + $metric1 = $this->metricsList->getMetric($metricName1); + + if (!$metric1 instanceof ArchivedMetric || !$metric1->getDimension()) { + throw new \Exception('Only possible to create computed metric for an archived metric with a dimension'); + } + + $dimension1 = $metric1->getDimension(); + + $metric = new ComputedMetric($metricName1, $metricName2, $aggregation); + $metric->setCategory($dimension1->getCategoryId()); + + return $metric; + } + +}
\ No newline at end of file diff --git a/core/Columns/Dimension.php b/core/Columns/Dimension.php index 3f31073bea..74266af05c 100644 --- a/core/Columns/Dimension.php +++ b/core/Columns/Dimension.php @@ -7,29 +7,48 @@ * */ namespace Piwik\Columns; - -use Exception; -use Piwik\CacheId; +use Piwik\Common; +use Piwik\Db; use Piwik\Piwik; use Piwik\Plugin; +use Piwik\Plugin\ArchivedMetric; use Piwik\Plugin\ComponentFactory; -use Piwik\Plugin\Dimension\ActionDimension; -use Piwik\Plugin\Dimension\ConversionDimension; -use Piwik\Plugin\Dimension\VisitDimension; use Piwik\Plugin\Segment; +use Exception; +use Piwik\CacheId; use Piwik\Cache as PiwikCache; use Piwik\Plugin\Manager as PluginManager; +use Piwik\Metrics\Formatter; /** * @api - * @since 2.5.0 + * @since 3.1.0 */ abstract class Dimension { const COMPONENT_SUBNAMESPACE = 'Columns'; - // TODO that we have quite a few @ignore in public methods might show we should maybe split some code into two - // classes. + /** + * Segment type 'dimension'. Can be used along with {@link setType()}. + * @api + */ + const TYPE_DIMENSION = 'dimension'; + const TYPE_BINARY = 'binary'; + const TYPE_TEXT = 'text'; + const TYPE_ENUM = 'enum'; + const TYPE_MONEY = 'money'; + const TYPE_BYTE = 'byte'; + const TYPE_DURATION_MS = 'duration_ms'; + const TYPE_DURATION_S = 'duration_s'; + const TYPE_NUMBER = 'number'; + const TYPE_FLOAT = 'float'; + const TYPE_URL = 'url'; + const TYPE_DATE = 'date'; + const TYPE_TIME = 'time'; + const TYPE_DATETIME = 'datetime'; + const TYPE_TIMESTAMP = 'timestamp'; + const TYPE_BOOL = 'bool'; + const TYPE_PERCENT = 'percent'; /** * This will be the name of the column in the database table if a $columnType is specified. @@ -54,6 +73,392 @@ abstract class Dimension protected $segments = array(); /** + * Defines what kind of data type this dimension holds. By default the type is auto-detected based on + * `$columnType` but sometimes it may be needed to correct this value. Depending on this type, a dimension will be + * formatted differently for example. + * @var string + * @api since Piwik 3.2.0 + */ + protected $type = ''; + + /** + * Translation key for name singular + * @var string + */ + protected $nameSingular = ''; + + /** + * Translation key for name plural + * @var string + * @api since Piwik 3.2.0 + */ + protected $namePlural = ''; + + /** + * Translation key for category + * @var string + */ + protected $category = ''; + + /** + * By defining a segment name a user will be able to filter their visitors by this column. If you do not want to + * define a segment for this dimension, simply leave the name empty. + * @api since Piwik 3.2.0 + */ + protected $segmentName = ''; + + /** + * Sets a callback which will be executed when user will call for suggested values for segment. + * + * @var callable + * @api since Piwik 3.2.0 + */ + protected $suggestedValuesCallback; + + /** + * Here you should explain which values are accepted/useful for your segment, for example: + * "1, 2, 3, etc." or "comcast.net, proxad.net, etc.". If the value needs any special encoding you should mention + * this as well. For example "Any URL including protocol. The URL must be URL encoded." + * + * @var string + * @api since Piwik 3.2.0 + */ + protected $acceptValues; + + /** + * Defines to which column in the MySQL database the segment belongs (if one is conifugred). Defaults to + * `$this.dbTableName . '.'. $this.columnName` but you can customize it eg like `HOUR(log_visit.visit_last_action_time)`. + * + * @param string $sqlSegment + * @api since Piwik 3.2.0 + */ + protected $sqlSegment; + + /** + * Interesting when specifying a segment. Sometimes you want users to set segment values that differ from the way + * they are actually stored. For instance if you want to allow to filter by any URL than you might have to resolve + * this URL to an action id. Or a country name maybe has to be mapped to a 2 letter country code. You can do this by + * specifing either a callable such as `array('Classname', 'methodName')` or by passing a closure. + * There will be four values passed to the given closure or callable: `string $valueToMatch`, `string $segment` + * (see {@link setSegment()}), `string $matchType` (eg SegmentExpression::MATCH_EQUAL or any other match constant + * of this class) and `$segmentName`. + * + * If the closure returns NULL, then Piwik assumes the segment sub-string will not match any visitor. + * + * @var string|\Closure + * @api since Piwik 3.2.0 + */ + protected $sqlFilter; + + /** + * Similar to {@link $sqlFilter} you can map a given segment value to another value. For instance you could map + * "new" to 0, 'returning' to 1 and any other value to '2'. You can either define a callable or a closure. There + * will be only one value passed to the closure or callable which contains the value a user has set for this + * segment. + * @var string|array + * @api since Piwik 3.2.0 + */ + protected $sqlFilterValue; + + /** + * Defines whether this dimension (and segment based on this dimension) is available to anonymous users. + * @var bool + * @api since Piwik 3.2.0 + */ + protected $allowAnonymous = true; + + /** + * The name of the database table this dimension refers to + * @var string + * @api + */ + protected $dbTableName = ''; + + /** + * By default the metricId is automatically generated based on the dimensionId. This might sometimes not be as + * readable and quite long. If you want more expressive metric names like `nb_visits` compared to + * `nb_corehomevisitid`, you can eg set a metricId `visit`. + * + * @var string + * @api since Piwik 3.2.0 + */ + protected $metricId = ''; + + /** + * To be implemented when a column references another column + * @return Join|null + * @api since Piwik 3.2.0 + */ + public function getDbColumnJoin() + { + return null; + } + + /** + * @return Discriminator|null + * @api since Piwik 3.2.0 + */ + public function getDbDiscriminator() + { + return null; + } + + /** + * To be implemented when a column represents an enum. + * @return array + * @api since Piwik 3.2.0 + */ + public function getEnumColumnValues() + { + return array(); + } + + /** + * Get the metricId which is used to generate metric names based on this dimension. + * @return string + */ + public function getMetricId() + { + if (!empty($this->metricId)) { + return $this->metricId; + } + + $id = $this->getId(); + + return str_replace(array('.', ' ', '-'), '_', strtolower($id)); + } + + /** + * Installs the action dimension in case it is not installed yet. The installation is already implemented based on + * the {@link $columnName} and {@link $columnType}. If you want to perform additional actions beside adding the + * column to the database - for instance adding an index - you can overwrite this method. We recommend to call + * this parent method to get the minimum required actions and then add further custom actions since this makes sure + * the column will be installed correctly. We also recommend to change the default install behavior only if really + * needed. FYI: We do not directly execute those alter table statements here as we group them together with several + * other alter table statements do execute those changes in one step which results in a faster installation. The + * column will be added to the `log_link_visit_action` MySQL table. + * + * Example: + * ``` + public function install() + { + $changes = parent::install(); + $changes['log_link_visit_action'][] = "ADD INDEX index_idsite_servertime ( idsite, server_time )"; + + return $changes; + } + ``` + * + * @return array An array containing the table name as key and an array of MySQL alter table statements that should + * be executed on the given table. Example: + * ``` + array( + 'log_link_visit_action' => array("ADD COLUMN `$this->columnName` $this->columnType", "ADD INDEX ...") + ); + ``` + * @api + */ + public function install() + { + if (empty($this->columnName) || empty($this->columnType) || empty($this->dbTableName)) { + return array(); + } + + // TODO if table does not exist, create it with a primary key, but at this point we cannot really create it + // cause we need to show the query in the UI first and user needs to be able to create table manually. + // we cannot return something like "create table " here as it would be returned for each table etc. + // we need to do this in column updater etc! + + return array( + $this->dbTableName => array("ADD COLUMN `$this->columnName` $this->columnType") + ); + } + + /** + * Updates the action dimension in case the {@link $columnType} has changed. The update is already implemented based + * on the {@link $columnName} and {@link $columnType}. This method is intended not to overwritten by plugin + * developers as it is only supposed to make sure the column has the correct type. Adding additional custom "alter + * table" actions would not really work since they would be executed with every {@link $columnType} change. So + * adding an index here would be executed whenever the columnType changes resulting in an error if the index already + * exists. If an index needs to be added after the first version is released a plugin update class should be + * created since this makes sure it is only executed once. + * + * @return array An array containing the table name as key and an array of MySQL alter table statements that should + * be executed on the given table. Example: + * ``` + array( + 'log_link_visit_action' => array("MODIFY COLUMN `$this->columnName` $this->columnType", "DROP COLUMN ...") + ); + ``` + * @ignore + */ + public function update() + { + if (empty($this->columnName) || empty($this->columnType) || empty($this->dbTableName)) { + return array(); + } + + return array( + $this->dbTableName => array("MODIFY COLUMN `$this->columnName` $this->columnType") + ); + } + + /** + * Uninstalls the dimension if a {@link $columnName} and {@link columnType} is set. In case you perform any custom + * actions during {@link install()} - for instance adding an index - you should make sure to undo those actions by + * overwriting this method. Make sure to call this parent method to make sure the uninstallation of the column + * will be done. + * @throws Exception + * @api + */ + public function uninstall() + { + if (empty($this->columnName) || empty($this->columnType) || empty($this->dbTableName)) { + return; + } + + try { + $sql = "ALTER TABLE `" . Common::prefixTable($this->dbTableName) . "` DROP COLUMN `$this->columnName`"; + Db::exec($sql); + } catch (Exception $e) { + if (!Db::get()->isErrNo($e, '1091')) { + throw $e; + } + } + } + + /** + * Returns the ID of the category (typically a translation key). + * @return string + */ + public function getCategoryId() + { + return $this->category; + } + + /** + * Returns the translated name of this dimension which is typically in singular. + * + * @return string + */ + public function getName() + { + if (!empty($this->nameSingular)) { + return Piwik::translate($this->nameSingular); + } + + return $this->nameSingular; + } + + /** + * Returns a translated name in plural for this dimension. + * @return string + * @api since Piwik 3.2.0 + */ + public function getNamePlural() + { + if (!empty($this->namePlural)) { + return Piwik::translate($this->namePlural); + } + + return $this->getName(); + } + + /** + * Defines whether an anonymous user is allowed to view this dimension + * @return bool + * @api since Piwik 3.2.0 + */ + public function isAnonymousAllowed() + { + return $this->allowAnonymous; + } + + /** + * Sets (overwrites) the SQL segment + * @param $segment + * @api since Piwik 3.2.0 + */ + public function setSqlSegment($segment) + { + $this->sqlSegment = $segment; + } + + /** + * Sets (overwrites the dimension type) + * @param $type + * @api since Piwik 3.2.0 + */ + public function setType($type) + { + $this->type = $type; + } + + /** + * A dimension should group values by using this method. Otherwise the same row may appear several times. + * + * @param mixed $value + * @param int $idSite + * @return mixed + * @api since Piwik 3.2.0 + */ + public function groupValue($value, $idSite) + { + switch ($this->type) { + case Dimension::TYPE_URL: + return str_replace(array('http://', 'https://'), '', $value); + case Dimension::TYPE_BOOL: + return !empty($value) ? '1' : '0'; + case Dimension::TYPE_DURATION_MS: + return number_format($value / 1000, 2); // because we divide we need to group them and cannot do this in formatting step + } + return $value; + } + + /** + * Formats the dimension value. By default, the dimension is formatted based on the set dimension type. + * + * @param mixed $value + * @param int $idSite + * @param Formatter $formatter + * @return mixed + * @api since Piwik 3.2.0 + */ + public function formatValue($value, $idSite, Formatter $formatter) + { + switch ($this->type) { + case Dimension::TYPE_BOOL: + if (empty($value)) { + return Piwik::translate('General_No'); + } + + return Piwik::translate('General_Yes'); + case Dimension::TYPE_ENUM: + $values = $this->getEnumColumnValues(); + if (isset($values[$value])) { + return $values[$value]; + } + break; + case Dimension::TYPE_MONEY: + return $formatter->getPrettyMoney($value, $idSite); + case Dimension::TYPE_FLOAT: + return $formatter->getPrettyNumber((float) $value, $precision = 2); + case Dimension::TYPE_NUMBER: + return $formatter->getPrettyNumber($value); + case Dimension::TYPE_DURATION_S: + return $formatter->getPrettyTimeFromSeconds($value, $displayAsSentence = false); + case Dimension::TYPE_DURATION_MS: + return $formatter->getPrettyTimeFromSeconds($value, $displayAsSentence = true); + case Dimension::TYPE_PERCENT: + return $formatter->getPrettyPercentFromQuotient($value); + case Dimension::TYPE_BYTE: + return $formatter->getPrettySizeFromBytes($value); + } + + return $value; + } + + /** * Overwrite this method to configure segments. To do so just create an instance of a {@link \Piwik\Plugin\Segment} * class, configure it and call the {@link addSegment()} method. You can add one or more segments for this * dimension. Example: @@ -68,6 +473,42 @@ abstract class Dimension */ protected function configureSegments() { + if ($this->segmentName && $this->category + && ($this->sqlSegment || ($this->columnName && $this->dbTableName)) + && $this->nameSingular) { + $segment = new Segment(); + $this->addSegment($segment); + } + } + + /** + * Configures metrics for this dimension. + * + * For certain dimension types, some metrics will be added automatically. + * + * @param MetricsList $metricsList + * @param DimensionMetricFactory $dimensionMetricFactory + */ + public function configureMetrics(MetricsList $metricsList, DimensionMetricFactory $dimensionMetricFactory) + { + if ($this->getMetricId() && $this->dbTableName && $this->columnName && $this->getNamePlural()) { + if (in_array($this->getType(), array(self::TYPE_DATETIME, self::TYPE_DATE, self::TYPE_TIME, self::TYPE_TIMESTAMP))) { + // we do not generate any metrics from these types + return; + } elseif (in_array($this->getType(), array(self::TYPE_URL, self::TYPE_TEXT, self::TYPE_BINARY, self::TYPE_ENUM))) { + $metric = $dimensionMetricFactory->createMetric(ArchivedMetric::AGGREGATION_UNIQUE); + $metricsList->addMetric($metric); + } elseif (in_array($this->getType(), array(self::TYPE_BOOL))) { + $metric = $dimensionMetricFactory->createMetric(ArchivedMetric::AGGREGATION_SUM); + $metricsList->addMetric($metric); + } else { + $metric = $dimensionMetricFactory->createMetric(ArchivedMetric::AGGREGATION_SUM); + $metricsList->addMetric($metric); + + $metric = $dimensionMetricFactory->createMetric(ArchivedMetric::AGGREGATION_MAX); + $metricsList->addMetric($metric); + } + } } /** @@ -85,16 +526,110 @@ abstract class Dimension } /** - * Adds a new segment. The segment type will be set to 'dimension' automatically if not already set. + * Adds a new segment. It automatically sets the SQL segment depending on the column name in case none is set + * already. + * @see \Piwik\Columns\Dimension::addSegment() * @param Segment $segment * @api */ protected function addSegment(Segment $segment) { - $type = $segment->getType(); + if (!$segment->getSegment() && $this->segmentName) { + $segment->setSegment($this->segmentName); + } + + if (!$segment->getType()) { + $metricTypes = array(self::TYPE_NUMBER, self::TYPE_FLOAT, self::TYPE_MONEY, self::TYPE_DURATION_S, self::TYPE_DURATION_MS); + if (in_array($this->getType(), $metricTypes, $strict = true)) { + $segment->setType(Segment::TYPE_METRIC); + } else { + $segment->setType(Segment::TYPE_DIMENSION); + } + } + + if (!$segment->getCategoryId() && $this->category) { + $segment->setCategory($this->category); + } + + if (!$segment->getName() && $this->nameSingular) { + $segment->setName($this->nameSingular); + } + + $sqlSegment = $segment->getSqlSegment(); + + if (empty($sqlSegment) && !$segment->getUnionOfSegments()) { + if (!empty($this->sqlSegment)) { + $segment->setSqlSegment($this->sqlSegment); + } elseif ($this->dbTableName && $this->columnName) { + $segment->setSqlSegment($this->dbTableName . '.' . $this->columnName); + } else { + throw new Exception('Segment cannot be added because no sql segment is set'); + } + } + + if (!$this->suggestedValuesCallback) { + // we can generate effecient value callback for enums automatically + $enum = $this->getEnumColumnValues(); + if (!empty($enum)) { + $this->suggestedValuesCallback = function ($idSite, $maxValuesToReturn) use ($enum) { + $values = array_values($enum); + return array_slice($values, 0, $maxValuesToReturn); + }; + } + } + + if (!$this->acceptValues) { + // we can generate accept values for enums automatically + $enum = $this->getEnumColumnValues(); + if (!empty($enum)) { + $enumValues = array_values($enum); + $enumValues = array_slice($enumValues, 0, 20); + $this->acceptValues = 'Eg. ' . implode(', ', $enumValues); + }; + } + + if ($this->acceptValues && !$segment->getAcceptValues()) { + $segment->setAcceptedValues($this->acceptValues); + } + + if (!$this->sqlFilterValue && !$segment->getSqlFilter() && !$segment->getSqlFilterValue()) { + // no sql filter configured, we try to configure automatically for enums + $enum = $this->getEnumColumnValues(); + if (!empty($enum)) { + $this->sqlFilterValue = function ($value, $sqlSegmentName) use ($enum) { + if (isset($enum[$value])) { + return $value; + } + + $id = array_search($value, $enum); + + if ($id === false) { + $id = array_search(strtolower(trim(urldecode($value))), $enum); + + if ($id === false) { + throw new \Exception("Invalid '$sqlSegmentName' segment value $value"); + } + } + + return $id; + }; + }; + } + + if ($this->suggestedValuesCallback && !$segment->getSuggestedValuesCallback()) { + $segment->setSuggestedValuesCallback($this->suggestedValuesCallback); + } + + if ($this->sqlFilterValue && !$segment->getSqlFilterValue()) { + $segment->setSqlFilterValue($this->sqlFilterValue); + } + + if ($this->sqlFilter && !$segment->getSqlFilter()) { + $segment->setSqlFilter($this->sqlFilter); + } - if (empty($type)) { - $segment->setType(Segment::TYPE_DIMENSION); + if (!$this->allowAnonymous) { + $segment->setRequiresAtLeastViewAccess(true); } $this->segments[] = $segment; @@ -115,6 +650,16 @@ abstract class Dimension } /** + * Returns the name of the segment that this dimension defines + * @return string + * @api since Piwik 3.2.0 + */ + public function getSegmentName() + { + return $this->segmentName; + } + + /** * Get the name of the dimension column. * @return string * @ignore @@ -125,6 +670,22 @@ abstract class Dimension } /** + * Returns a sql segment expression for this dimension. + * @return string + * @api since Piwik 3.2.0 + */ + public function getSqlSegment() + { + if (!empty($this->sqlSegment)) { + return $this->sqlSegment; + } + + if ($this->dbTableName && $this->columnName) { + return $this->dbTableName . '.' . $this->columnName; + } + } + + /** * Check whether the dimension has a column type configured * @return bool * @ignore @@ -135,13 +696,13 @@ abstract class Dimension } /** - * Get the translated name of the dimension. Defaults to an empty string. + * Returns the name of the database table this dimension belongs to. * @return string - * @api + * @api since Piwik 3.2.0 */ - public function getName() + public function getDbTableName() { - return ''; + return $this->dbTableName; } /** @@ -157,6 +718,17 @@ abstract class Dimension { $className = get_class($this); + return $this->generateIdFromClass($className); + } + + /** + * @param string $className + * @return string + * @throws Exception + * @ignore + */ + protected function generateIdFromClass($className) + { // parse plugin name & dimension name $regex = "/Piwik\\\\Plugins\\\\([^\\\\]+)\\\\" . self::COMPONENT_SUBNAMESPACE . "\\\\([^\\\\]+)/"; if (!preg_match($regex, $className, $matches)) { @@ -232,11 +804,11 @@ abstract class Dimension public static function getDimensions(Plugin $plugin) { - $dimensions = $plugin->findMultipleComponents('Columns', '\\Piwik\\Columns\\Dimension'); + $columns = $plugin->findMultipleComponents('Columns', '\\Piwik\\Columns\\Dimension'); $instances = array(); - foreach ($dimensions as $dimension) { - $instances[] = new $dimension(); + foreach ($columns as $colum) { + $instances[] = new $colum(); } return $instances; @@ -250,6 +822,7 @@ abstract class Dimension * $dimensionId or if the plugin that contains the Dimension is * not loaded. * @api + * @deprecated Please use DimensionProvider::factory instead */ public static function factory($dimensionId) { @@ -274,4 +847,61 @@ abstract class Dimension $parts = explode('.', $id); return reset($parts); } + + /** + * Returns the type of the dimension which defines what kind of value this dimension stores. + * @return string + * @api since Piwik 3.2.0 + */ + public function getType() + { + if (!empty($this->type)) { + return $this->type; + } + + if ($this->getDbColumnJoin()) { + // best guess + return self::TYPE_TEXT; + } + + if ($this->getEnumColumnValues()) { + // best guess + return self::TYPE_ENUM; + } + + if (!empty($this->columnType)) { + // best guess + $type = strtolower($this->columnType); + if (strpos($type, 'datetime') !== false) { + return self::TYPE_DATETIME; + } elseif (strpos($type, 'timestamp') !== false) { + return self::TYPE_TIMESTAMP; + } elseif (strpos($type, 'date') !== false) { + return self::TYPE_DATE; + } elseif (strpos($type, 'time') !== false) { + return self::TYPE_TIME; + } elseif (strpos($type, 'float') !== false) { + return self::TYPE_FLOAT; + } elseif (strpos($type, 'decimal') !== false) { + return self::TYPE_FLOAT; + } elseif (strpos($type, 'int') !== false) { + return self::TYPE_NUMBER; + } elseif (strpos($type, 'binary') !== false) { + return self::TYPE_BINARY; + } + } + + return self::TYPE_TEXT; + } + + /** + * Get the version of the dimension which is used for update checks. + * @return string + * @ignore + */ + public function getVersion() + { + return $this->columnType; + } + } diff --git a/core/Columns/DimensionMetricFactory.php b/core/Columns/DimensionMetricFactory.php new file mode 100644 index 0000000000..58178951ec --- /dev/null +++ b/core/Columns/DimensionMetricFactory.php @@ -0,0 +1,122 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns; + +use Piwik\Piwik; +use Piwik\Plugin\ArchivedMetric; +use Piwik\Plugin\ComputedMetric; +use Piwik\Plugin\Report; + + +/** + * A factory to create metrics from a dimension. + * + * @api since Piwik 3.2.0 + */ +class DimensionMetricFactory +{ + /** + * @var Dimension + */ + private $dimension = null; + + /** + * Generates a new dimension metric factory. + * @param Dimension $dimension A dimension instance the created metrics should be based on. + */ + public function __construct(Dimension $dimension) + { + $this->dimension = $dimension; + } + + /** + * @return ArchivedMetric + */ + public function createCustomMetric($metricName, $readableName, $aggregation, $documentation = '') + { + if (!$this->dimension->getDbTableName() || !$this->dimension->getColumnName()) { + throw new \Exception(sprintf('Cannot make metric from dimension %s because DB table or column missing', $this->dimension->getId())); + } + + $metric = new ArchivedMetric($this->dimension, $aggregation); + $metric->setType($this->dimension->getType()); + $metric->setName($metricName); + $metric->setTranslatedName($readableName); + $metric->setDocumentation($documentation); + $metric->setCategory($this->dimension->getCategoryId()); + + return $metric; + } + + /** + * @return \Piwik\Plugin\ComputedMetric + */ + public function createComputedMetric($metricName1, $metricName2, $aggregation) + { + // We cannot use reuse ComputedMetricFactory here as it would result in an endless loop since ComputedMetricFactory + // requires a MetricsList which is just being built here... + $metric = new ComputedMetric($metricName1, $metricName2, $aggregation); + $metric->setCategory($this->dimension->getCategoryId()); + return $metric; + } + + /** + * @return ArchivedMetric + */ + public function createMetric($aggregation) + { + $dimension = $this->dimension; + + if (!$dimension->getNamePlural()) { + throw new \Exception(sprintf('No metric can be created for this dimension %s automatically because no $namePlural is set.', $dimension->getId())); + } + + $prefix = ''; + $translatedName = $dimension->getNamePlural(); + + $documentation = ''; + + switch ($aggregation) { + case ArchivedMetric::AGGREGATION_COUNT; + $prefix = ArchivedMetric::AGGREGATION_COUNT_PREFIX; + $translatedName = $dimension->getNamePlural(); + $documentation = Piwik::translate('General_ComputedMetricCountDocumentation', $dimension->getNamePlural()); + break; + case ArchivedMetric::AGGREGATION_SUM; + $prefix = ArchivedMetric::AGGREGATION_SUM_PREFIX; + $translatedName = Piwik::translate('General_ComputedMetricSum', $dimension->getNamePlural()); + $documentation = Piwik::translate('General_ComputedMetricSumDocumentation', $dimension->getNamePlural()); + break; + case ArchivedMetric::AGGREGATION_MAX; + $prefix = ArchivedMetric::AGGREGATION_MAX_PREFIX; + $translatedName = Piwik::translate('General_ComputedMetricMax', $dimension->getNamePlural()); + $documentation = Piwik::translate('General_ComputedMetricMaxDocumentation', $dimension->getNamePlural()); + break; + case ArchivedMetric::AGGREGATION_MIN; + $prefix = ArchivedMetric::AGGREGATION_MIN_PREFIX; + $translatedName = Piwik::translate('General_ComputedMetricMin', $dimension->getNamePlural()); + $documentation = Piwik::translate('General_ComputedMetricMinDocumentation', $dimension->getNamePlural()); + break; + case ArchivedMetric::AGGREGATION_UNIQUE; + $prefix = ArchivedMetric::AGGREGATION_UNIQUE_PREFIX; + $translatedName = Piwik::translate('General_ComputedMetricUniqueCount', $dimension->getNamePlural()); + $documentation = Piwik::translate('General_ComputedMetricUniqueCountDocumentation', $dimension->getNamePlural()); + break; + case ArchivedMetric::AGGREGATION_COUNT_WITH_NUMERIC_VALUE; + $prefix = ArchivedMetric::AGGREGATION_COUNT_WITH_NUMERIC_VALUE_PREFIX; + $translatedName = Piwik::translate('General_ComputedMetricCountWithValue', $dimension->getName()); + $documentation = Piwik::translate('General_ComputedMetricCountWithValueDocumentation', $dimension->getName()); + break; + } + + $metricId = strtolower($dimension->getMetricId()); + + return $this->createCustomMetric($prefix . $metricId, $translatedName, $aggregation, $documentation); + } +}
\ No newline at end of file diff --git a/core/Columns/DimensionsProvider.php b/core/Columns/DimensionsProvider.php new file mode 100644 index 0000000000..cd9da962b6 --- /dev/null +++ b/core/Columns/DimensionsProvider.php @@ -0,0 +1,62 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns; + +use Piwik\CacheId; +use Piwik\Cache as PiwikCache; + +class DimensionsProvider +{ + /** + * @param $dimensionId + * @return Dimension + */ + public function factory($dimensionId) + { + $listDimensions = self::getMapOfNameToDimension(); + + if (empty($listDimensions) || !is_array($listDimensions) || !$dimensionId || !array_key_exists($dimensionId, $listDimensions)) { + return null; + } + + return $listDimensions[$dimensionId]; + } + + private static function getMapOfNameToDimension() + { + $cacheId = CacheId::pluginAware('DimensionFactoryMap'); + + $cache = PiwikCache::getTransientCache(); + if ($cache->contains($cacheId)) { + $mapIdToDimension = $cache->fetch($cacheId); + } else { + $dimensions = new static(); + $dimensions = $dimensions->getAllDimensions(); + + $mapIdToDimension = array(); + foreach ($dimensions as $dimension) { + $mapIdToDimension[$dimension->getId()] = $dimension; + } + + $cache->save($cacheId, $mapIdToDimension); + } + + return $mapIdToDimension; + } + + /** + * Returns a list of all available dimensions. + * @return Dimension[] + */ + public function getAllDimensions() + { + return Dimension::getAllDimensions(); + } + +}
\ No newline at end of file diff --git a/core/Columns/Discriminator.php b/core/Columns/Discriminator.php new file mode 100644 index 0000000000..91ad6d2e38 --- /dev/null +++ b/core/Columns/Discriminator.php @@ -0,0 +1,75 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns; + +use Exception; +use Piwik\Plugins\Actions\Actions\ActionSiteSearch; + +/** + * @api + * @since 3.1.0 + */ +class Discriminator +{ + private $table; + private $discriminatorColumn; + private $discriminatorValue; + + /** + * Join constructor. + * @param string $table unprefixed table name + * @param null|string $discriminatorColumn + * @param null|int $discriminatorValue should be only hard coded, safe values. + * @throws Exception + */ + public function __construct($table, $discriminatorColumn = null, $discriminatorValue = null) + { + if (empty($discriminatorColumn) || !isset($discriminatorValue)) { + throw new Exception('Both discriminatorColumn and discriminatorValue need to be defined'); + } + $this->table = $table; + $this->discriminatorColumn = $discriminatorColumn; + $this->discriminatorValue = $discriminatorValue; + + if (!$this->isValid()) { + // if adding another string value please post an event instead to get a list of allowed values + throw new Exception('$discriminatorValue needs to be null or numeric'); + } + } + + public function isValid() + { + return isset($this->discriminatorColumn) + && (is_numeric($this->discriminatorValue) || $this->discriminatorValue == ActionSiteSearch::CVAR_KEY_SEARCH_CATEGORY); + } + + /** + * @return string + */ + public function getTable() + { + return $this->table; + } + + /** + * @return string + */ + public function getColumn() + { + return $this->discriminatorColumn; + } + + /** + * @return int|null + */ + public function getValue() + { + return $this->discriminatorValue; + } +} diff --git a/core/Columns/Join.php b/core/Columns/Join.php new file mode 100644 index 0000000000..75afec98a7 --- /dev/null +++ b/core/Columns/Join.php @@ -0,0 +1,61 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns; + +use Exception; + +/** + * @api + * @since 3.1.0 + */ +class Join +{ + private $table; + private $column; + private $targetColumn; + + /** + * Join constructor. + * @param $table + * @param $column + * @param $targetColumn + * @throws Exception + */ + public function __construct($table, $column, $targetColumn) + { + $this->table = $table; + $this->column = $column; + $this->targetColumn = $targetColumn; + } + + /** + * @return string + */ + public function getTable() + { + return $this->table; + } + + /** + * @return string + */ + public function getColumn() + { + return $this->column; + } + + /** + * @return string + */ + public function getTargetColumn() + { + return $this->targetColumn; + } + +} diff --git a/core/Columns/Join/ActionNameJoin.php b/core/Columns/Join/ActionNameJoin.php new file mode 100644 index 0000000000..64fdd25c8c --- /dev/null +++ b/core/Columns/Join/ActionNameJoin.php @@ -0,0 +1,24 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns\Join; + +use Piwik\Columns; + +/** + * @api + * @since 3.1.0 + */ +class ActionNameJoin extends Columns\Join +{ + public function __construct() + { + return parent::__construct('log_action', 'idaction', 'name'); + } + +} diff --git a/core/Columns/Join/GoalNameJoin.php b/core/Columns/Join/GoalNameJoin.php new file mode 100644 index 0000000000..ed6f875983 --- /dev/null +++ b/core/Columns/Join/GoalNameJoin.php @@ -0,0 +1,24 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns\Join; + +use Piwik\Columns; + +/** + * @api + * @since 3.1.0 + */ +class GoalNameJoin extends Columns\Join +{ + public function __construct() + { + return parent::__construct('goal', 'idgoal', 'name'); + } + +} diff --git a/core/Columns/Join/SiteNameJoin.php b/core/Columns/Join/SiteNameJoin.php new file mode 100644 index 0000000000..206c585b75 --- /dev/null +++ b/core/Columns/Join/SiteNameJoin.php @@ -0,0 +1,24 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns\Join; + +use Piwik\Columns; + +/** + * @api + * @since 3.1.0 + */ +class SiteNameJoin extends Columns\Join +{ + public function __construct() + { + return parent::__construct('site', 'idsite', 'name'); + } + +} diff --git a/core/Columns/MetricsList.php b/core/Columns/MetricsList.php new file mode 100644 index 0000000000..e65ec67ec7 --- /dev/null +++ b/core/Columns/MetricsList.php @@ -0,0 +1,190 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + * + */ +namespace Piwik\Columns; + +use Piwik\Cache; +use Piwik\Piwik; +use Piwik\Plugin\ArchivedMetric; +use Piwik\Plugin\Metric; +use Piwik\Plugin\ProcessedMetric; + +/** + * Manages the global list of metrics that can be used in reports. + * + * Metrics are added automatically by dimensions as well as through the {@hook Metric.addMetrics} and + * {@hook Metric.addComputedMetrics} and filtered through the {@hook Metric.filterMetrics} event. + * Observers for this event should call the {@link addMetric()} method to add metrics or use any of the other + * methods to remove metrics. + * + * @api since Piwik 3.2.0 + */ +class MetricsList +{ + /** + * List of metrics + * + * @var Metric[] + */ + private $metrics = array(); + + /** + * @param Metric $metric + */ + public function addMetric(Metric $metric) + { + $this->metrics[] = $metric; + } + + /** + * Get all available metrics. + * + * @return Metric[] + */ + public function getMetrics() + { + return $this->metrics; + } + + /** + * Removes one or more metrics from the metrics list. + * + * @param string $metricCategory The metric category id. Can be a translation token eg 'General_Visits' + * see {@link Metric::getCategory()}. + * @param string|false $metricName The name of the metric to remove eg 'nb_visits'. + * If not supplied, all metrics within that category will be removed. + */ + public function remove($metricCategory, $metricName = false) + { + foreach ($this->metrics as $index => $metric) { + if ($metric->getCategoryId() === $metricCategory) { + if (!$metricName || $metric->getName() === $metricName) { + unset($this->metrics[$index]); + } + } + } + } + + /** + * @param string $metricName + * @return Metric|ArchivedMetric|null + */ + public function getMetric($metricName) + { + foreach ($this->metrics as $index => $metric) { + if ($metric->getName() === $metricName) { + return $metric; + } + } + return null; + } + + /** + * Get all metrics defined in the Piwik platform. + * @ignore + * @return static + */ + public static function get() + { + $cache = Cache::getTransientCache(); + $cacheKey = 'MetricsList'; + + foreach (array('idsite', 'idSite') as $param) { + if (!empty($_GET[$param]) && is_numeric($_GET[$param])) { + $cacheKey .= $cacheKey . '_' . $_GET[$param]; + } + + if (!empty($_POST[$param]) && is_numeric($_POST[$param])) { + $cacheKey .= $cacheKey . '_' . $_POST[$param]; + } + } + + if ($cache->contains($cacheKey)) { + return $cache->fetch($cacheKey); + } + + $list = new static; + + /** + * Triggered to add new metrics that cannot be picked up automatically by the platform. + * This is useful if the plugin allows a user to create metrics dynamically. For example + * CustomDimensions or CustomVariables. + * + * **Example** + * + * public function addMetric(&$list) + * { + * $list->addMetric(new MyCustomMetric()); + * } + * + * @param MetricsList $list An instance of the MetricsList. You can add metrics to the list this way. + */ + Piwik::postEvent('Metric.addMetrics', array($list)); + + $dimensions = Dimension::getAllDimensions(); + foreach ($dimensions as $dimension) { + $factory = new DimensionMetricFactory($dimension); + $dimension->configureMetrics($list, $factory); + } + + $computedFactory = new ComputedMetricFactory($list); + + /** + * Triggered to add new metrics that cannot be picked up automatically by the platform. + * This is useful if the plugin allows a user to create metrics dynamically. For example + * CustomDimensions or CustomVariables. + * + * **Example** + * + * public function addMetric(&$list) + * { + * $list->addMetric(new MyCustomMetric()); + * } + * + * @param MetricsList $list An instance of the MetricsList. You can add metrics to the list this way. + */ + Piwik::postEvent('Metric.addComputedMetrics', array($list, $computedFactory)); + + /** + * Triggered to filter metrics. + * + * **Example** + * + * public function removeMetrics(Piwik\Columns\MetricsList $list) + * { + * $list->remove($category='General_Visits'); // remove all metrics having this category + * } + * + * @param MetricsList $list An instance of the MetricsList. You can change the list of metrics this way. + */ + Piwik::postEvent('Metric.filterMetrics', array($list)); + + $availableMetrics = array(); + foreach ($list->getMetrics() as $metric) { + $availableMetrics[] = $metric->getName(); + } + + foreach ($list->metrics as $index => $metric) { + if ($metric instanceof ProcessedMetric) { + $depMetrics = $metric->getDependentMetrics(); + if (is_array($depMetrics)) { + foreach ($depMetrics as $depMetric) { + if (!in_array($depMetric, $availableMetrics, $strict = true)) { + unset($list->metrics[$index]); // not resolvable metric + } + } + } + } + } + + $cache->save($cacheKey, $list); + + return $list; + } + +} diff --git a/core/Columns/Updater.php b/core/Columns/Updater.php index fe63d4c146..73da658fe8 100644 --- a/core/Columns/Updater.php +++ b/core/Columns/Updater.php @@ -123,7 +123,7 @@ class Updater extends \Piwik\Updates $allUpdatesToRun = array(); foreach ($this->getVisitDimensions() as $dimension) { - $updates = $this->getUpdatesForDimension($updater, $dimension, 'log_visit.', $visitColumns, $conversionColumns); + $updates = $this->getUpdatesForDimension($updater, $dimension, 'log_visit.', $visitColumns); $allUpdatesToRun = $this->mixinUpdates($allUpdatesToRun, $updates); } @@ -143,11 +143,9 @@ class Updater extends \Piwik\Updates /** * @param ActionDimension|ConversionDimension|VisitDimension $dimension * @param string $componentPrefix - * @param array $existingColumnsInDb - * @param array $conversionColumns * @return array */ - private function getUpdatesForDimension(PiwikUpdater $updater, $dimension, $componentPrefix, $existingColumnsInDb, $conversionColumns = array()) + private function getUpdatesForDimension(PiwikUpdater $updater, $dimension, $componentPrefix, $existingColumnsInDb) { $column = $dimension->getColumnName(); $componentName = $componentPrefix . $column; @@ -157,11 +155,7 @@ class Updater extends \Piwik\Updates } if (array_key_exists($column, $existingColumnsInDb)) { - if ($dimension instanceof VisitDimension) { - $sqlUpdates = $dimension->update($conversionColumns); - } else { - $sqlUpdates = $dimension->update(); - } + $sqlUpdates = $dimension->update(); } else { $sqlUpdates = $dimension->install(); } @@ -218,7 +212,8 @@ class Updater extends \Piwik\Updates } /** - * @param ActionDimension|ConversionDimension|VisitDimension $dimension + * @param PiwikUpdater $updater + * @param Dimension $dimension * @param string $componentPrefix * @param array $columns * @param array $versions diff --git a/core/DataAccess/LogAggregator.php b/core/DataAccess/LogAggregator.php index c30132f41e..ede9f81526 100644 --- a/core/DataAccess/LogAggregator.php +++ b/core/DataAccess/LogAggregator.php @@ -164,6 +164,11 @@ class LogAggregator $this->logger = $logger ?: StaticContainer::get('Psr\Log\LoggerInterface'); } + public function getSegment() + { + return $this->segment; + } + public function setQueryOriginHint($nameOfOrigiin) { $this->queryOriginHint = $nameOfOrigiin; @@ -515,7 +520,7 @@ class LogAggregator * * @return array */ - protected function getGeneralQueryBindParams() + public function getGeneralQueryBindParams() { $bind = array($this->dateStart->toString(Date::DATE_TIME_FORMAT), $this->dateEnd->toString(Date::DATE_TIME_FORMAT)); $bind = array_merge($bind, $this->sites); diff --git a/core/DataAccess/LogQueryBuilder.php b/core/DataAccess/LogQueryBuilder.php index 186ed0d96e..7989b72e80 100644 --- a/core/DataAccess/LogQueryBuilder.php +++ b/core/DataAccess/LogQueryBuilder.php @@ -22,11 +22,26 @@ class LogQueryBuilder */ private $logTableProvider; + /** + * Forces to use a subselect when generating the query. Set value to `false` to force not using a subselect. + * @var string + */ + private $forcedInnerGroupBy = ''; + public function __construct(LogTablesProvider $logTablesProvider) { $this->logTableProvider = $logTablesProvider; } + /** + * Forces to use a subselect when generating the query. + * @var string + */ + public function forceInnerGroupBySubselect($innerGroupBy) + { + $this->forcedInnerGroupBy = $innerGroupBy; + } + public function getSelectQueryString(SegmentExpression $segmentExpression, $select, $from, $where, $bind, $groupBy, $orderBy, $limitAndOffset) { @@ -55,11 +70,13 @@ class LogQueryBuilder && $fromInitially == array('log_conversion') && strpos($from, 'log_link_visit_action') !== false); - if ($useSpecialConversionGroupBy) { + if (!empty($this->forcedInnerGroupBy)) { + $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $tables, $this->forcedInnerGroupBy); + } elseif ($useSpecialConversionGroupBy) { $innerGroupBy = "CONCAT(log_conversion.idvisit, '_' , log_conversion.idgoal, '_', log_conversion.buster)"; - $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $innerGroupBy); + $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $tables, $innerGroupBy); } elseif ($joinWithSubSelect) { - $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset); + $sql = $this->buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $tables); } else { $sql = $this->buildSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset); } @@ -91,9 +108,20 @@ class LogQueryBuilder * @throws Exception * @return string */ - private function buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, $innerGroupBy = null) + private function buildWrappedSelectQuery($select, $from, $where, $groupBy, $orderBy, $limitAndOffset, JoinTables $tables, $innerGroupBy = null) { - $matchTables = '(' . implode('|', $this->getKnownTables()) . ')'; + $matchTables = $this->getKnownTables(); + foreach ($tables as $table) { + if (is_array($table) && isset($table['tableAlias']) && !in_array($table['tableAlias'], $matchTables, $strict = true)) { + $matchTables[] = $table['tableAlias']; + } elseif (is_array($table) && isset($table['table']) && !in_array($table['table'], $matchTables, $strict = true)) { + $matchTables[] = $table['table']; + } elseif (is_string($table) && !in_array($table, $matchTables, $strict = true)) { + $matchTables[] = $table; + } + } + + $matchTables = '(' . implode('|', $matchTables) . ')'; preg_match_all("/". $matchTables ."\.[a-z0-9_\*]+/", $select, $matches); $neededFields = array_unique($matches[0]); @@ -102,6 +130,30 @@ class LogQueryBuilder . "Please use a table prefix."); } + $fieldNames = array(); + $toBeReplaced = array(); + $epregReplace = array(); + foreach ($neededFields as &$neededField) { + $parts = explode('.', $neededField); + if (count($parts) === 2 && !empty($parts[1])) { + if (in_array($parts[1], $fieldNames, $strict = true)) { + // eg when selecting 2 dimensions log_action_X.name + $columnAs = $parts[1] . md5($neededField); + $fieldNames[] = $columnAs; + // we make sure to not replace a idvisitor column when duplicate column is idvisit + $toBeReplaced[$neededField . ' '] = $parts[0] . '.' . $columnAs . ' '; + $toBeReplaced[$neededField . ')'] = $parts[0] . '.' . $columnAs . ')'; + $toBeReplaced[$neededField . '`'] = $parts[0] . '.' . $columnAs . '`'; + $toBeReplaced[$neededField . ','] = $parts[0] . '.' . $columnAs . ','; + // replace when string ends this, we need to use regex to check for this + $epregReplace["/(" . $neededField . ")$/"] = $parts[0] . '.' . $columnAs; + $neededField .= ' as ' . $columnAs; + } else { + $fieldNames[] = $parts[1]; + } + } + } + preg_match_all("/". $matchTables . "/", $from, $matchesFrom); $innerSelect = implode(", \n", $neededFields); @@ -110,12 +162,6 @@ class LogQueryBuilder $innerLimitAndOffset = $limitAndOffset; - if (!isset($innerGroupBy) && in_array('log_visit', $matchesFrom[1])) { - $innerGroupBy = "log_visit.idvisit"; - } elseif (!isset($innerGroupBy)) { - throw new Exception('Cannot use subselect for join as no group by rule is specified'); - } - $innerOrderBy = "NULL"; if ($innerLimitAndOffset && $orderBy) { // only When LIMITing we can apply to the inner query the same ORDER BY as the parent query @@ -126,9 +172,29 @@ class LogQueryBuilder $innerGroupBy = false; } + if (!isset($innerGroupBy) && in_array('log_visit', $matchesFrom[1])) { + $innerGroupBy = "log_visit.idvisit"; + } elseif (!isset($innerGroupBy)) { + throw new Exception('Cannot use subselect for join as no group by rule is specified'); + } + + if (!empty($toBeReplaced)) { + $select = preg_replace(array_keys($epregReplace), array_values($epregReplace), $select); + $select = str_replace(array_keys($toBeReplaced), array_values($toBeReplaced), $select); + if (!empty($groupBy)) { + $groupBy = preg_replace(array_keys($epregReplace), array_values($epregReplace), $groupBy); + $groupBy = str_replace(array_keys($toBeReplaced), array_values($toBeReplaced), $groupBy); + } + if (!empty($orderBy)) { + $orderBy = preg_replace(array_keys($epregReplace), array_values($epregReplace), $orderBy); + $orderBy = str_replace(array_keys($toBeReplaced), array_values($toBeReplaced), $orderBy); + } + } + $innerQuery = $this->buildSelectQuery($innerSelect, $innerFrom, $innerWhere, $innerGroupBy, $innerOrderBy, $innerLimitAndOffset); $select = preg_replace('/'.$matchTables.'\./', 'log_inner.', $select); + $from = " ( $innerQuery diff --git a/core/DataAccess/LogQueryBuilder/JoinGenerator.php b/core/DataAccess/LogQueryBuilder/JoinGenerator.php index a7f8b5499f..82cffc6b65 100644 --- a/core/DataAccess/LogQueryBuilder/JoinGenerator.php +++ b/core/DataAccess/LogQueryBuilder/JoinGenerator.php @@ -120,6 +120,11 @@ class JoinGenerator $this->joinString .= ' LEFT JOIN'; } + if (!isset($table['joinOn']) && $this->tables->getLogTable($table['table']) && !empty($availableLogTables)) { + $logTable = $this->tables->getLogTable($table['table']); + $table['joinOn'] = $this->findJoinCriteriasForTables($logTable, $availableLogTables); + } + $this->joinString .= ' ' . Common::prefixTable($table['table']) . " AS " . $alias . " ON " . $table['joinOn']; continue; @@ -166,7 +171,7 @@ class JoinGenerator * to be joined * @throws Exception if table cannot be joined for segmentation */ - protected function findJoinCriteriasForTables(LogTable $logTable, $availableLogTables) + public function findJoinCriteriasForTables(LogTable $logTable, $availableLogTables) { $join = null; $alternativeJoin = null; @@ -278,10 +283,16 @@ class JoinGenerator } if (is_array($tA)) { + if (isset($tA['joinOn']) && is_string($tA['joinOn']) && strpos($tA['joinOn'] . '.', $tB) === 0) { + return 1; // tA requires tB so needs to be listed before + } return -1; } if (is_array($tB)) { + if (isset($tB['joinOn']) && is_string($tB['joinOn']) && strpos($tB['joinOn'] . '.', $tA) === 0) { + return -1; // tB requires tA so needs to be listed before + } return 1; } diff --git a/core/DataAccess/LogQueryBuilder/JoinTables.php b/core/DataAccess/LogQueryBuilder/JoinTables.php index c773750155..c05a333d1d 100644 --- a/core/DataAccess/LogQueryBuilder/JoinTables.php +++ b/core/DataAccess/LogQueryBuilder/JoinTables.php @@ -48,7 +48,22 @@ class JoinTables extends \ArrayObject public function hasJoinedTable($tableName) { - return in_array($tableName, $this->getTables()); + $tables = in_array($tableName, $this->getTables()); + if ($tables) { + return true; + } + + foreach ($this as $table) { + if (is_array($table)) { + if (!isset($table['tableAlias']) && $table['table'] === $table) { + return true; + } elseif (isset($table['tableAlias']) && $table['tableAlias'] === $table) { + return true; + } + } + } + + return false; } public function hasJoinedTableManually($tableToFind, $joinToFind) diff --git a/core/Metrics/Formatter.php b/core/Metrics/Formatter.php index 9ce179d769..6608b802e3 100644 --- a/core/Metrics/Formatter.php +++ b/core/Metrics/Formatter.php @@ -11,6 +11,7 @@ use Piwik\Common; use Piwik\DataTable; use Piwik\NumberFormatter; use Piwik\Piwik; +use Piwik\Plugin\ArchivedMetric; use Piwik\Plugin\Metric; use Piwik\Plugin\ProcessedMetric; use Piwik\Plugin\Report; @@ -214,7 +215,8 @@ class Formatter if ($metricsToFormat !== null) { $metricMatchRegex = $this->makeRegexToMatchMetrics($metricsToFormat); - $metrics = array_filter($metrics, function (ProcessedMetric $metric) use ($metricMatchRegex) { + $metrics = array_filter($metrics, function ($metric) use ($metricMatchRegex) { + /** @var ProcessedMetric|ArchivedMetric $metric */ return preg_match($metricMatchRegex, $metric->getName()); }); } diff --git a/core/Plugin/ArchivedMetric.php b/core/Plugin/ArchivedMetric.php new file mode 100644 index 0000000000..05fa3d8665 --- /dev/null +++ b/core/Plugin/ArchivedMetric.php @@ -0,0 +1,207 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +namespace Piwik\Plugin; + +use Piwik\Archive\DataTableFactory; +use Piwik\Columns\Dimension; +use Piwik\Common; +use Piwik\DataTable; +use Piwik\DataTable\Row; +use Piwik\Metrics\Formatter; +use Piwik\Piwik; + +class ArchivedMetric extends Metric +{ + const AGGREGATION_COUNT = 'count(%s)'; + const AGGREGATION_COUNT_PREFIX = 'nb_'; + const AGGREGATION_SUM = 'sum(%s)'; + const AGGREGATION_SUM_PREFIX = 'sum_'; + const AGGREGATION_MAX = 'max(%s)'; + const AGGREGATION_MAX_PREFIX = 'max_'; + const AGGREGATION_MIN = 'min(%s)'; + const AGGREGATION_MIN_PREFIX = 'min_'; + const AGGREGATION_UNIQUE = 'count(distinct %s)'; + const AGGREGATION_UNIQUE_PREFIX = 'nb_uniq_'; + const AGGREGATION_COUNT_WITH_NUMERIC_VALUE = 'sum(if(%s > 0, 1, 0))'; + const AGGREGATION_COUNT_WITH_NUMERIC_VALUE_PREFIX = 'nb_with_'; + + /** + * @var string + */ + private $aggregation; + + /** + * @var int + */ + protected $idSite; + + private $name = ''; + private $type = ''; + private $translatedName = ''; + private $documentation = ''; + private $dbTable = ''; + private $category = ''; + private $query = ''; + + /** + * @var Dimension + */ + private $dimension; + + public function __construct(Dimension $dimension, $aggregation = false) + { + if (!empty($aggregation) && strpos($aggregation, '%s') === false) { + throw new \Exception(sprintf('The given aggregation for %s.%s needs to include a %%s for the column name', $dimension->getDbTableName(), $dimension->getColumnName())); + } + + $this->setDimension($dimension); + $this->setDbTable($dimension->getDbTableName()); + $this->aggregation = $aggregation; + } + + public function setDimension($dimension) + { + $this->dimension = $dimension; + return $this; + } + + public function getDimension() + { + return $this->dimension; + } + + public function setCategory($category) + { + $this->category = $category; + return $this; + } + + public function getCategoryId() + { + return $this->category; + } + + public function setDbTable($dbTable) + { + $this->dbTable = $dbTable; + return $this; + } + + public function setDocumentation($documentation) + { + $this->documentation = $documentation; + return $this; + } + + public function setTranslatedName($name) + { + $this->translatedName = $name; + return $this; + } + + public function setType($type) + { + $this->type = $type; + return $this; + } + + public function setName($name) + { + $this->name = $name; + return $this; + } + + public function setQuery($query) + { + $this->query = $query; + return $this; + } + + public function getName() + { + return $this->name; + } + + public function format($value, Formatter $formatter) + { + switch ($this->type) { + case Dimension::TYPE_BOOL: + return $formatter->getPrettyNumber($value); + case Dimension::TYPE_ENUM: + return $formatter->getPrettyNumber($value); + case Dimension::TYPE_MONEY: + return $formatter->getPrettyMoney($value, $this->idSite); + case Dimension::TYPE_FLOAT: + return $formatter->getPrettyNumber((float) $value, $precision = 2); + case Dimension::TYPE_NUMBER: + return $formatter->getPrettyNumber($value); + case Dimension::TYPE_DURATION_S: + return $formatter->getPrettyTimeFromSeconds($value, $displayAsSentence = true); + case Dimension::TYPE_DURATION_MS: + $val = number_format($value / 1000, 2); + if ($val > 60) { + $val = round($val); + } + return $formatter->getPrettyTimeFromSeconds($val, $displayAsSentence = true); + case Dimension::TYPE_PERCENT: + return $formatter->getPrettyPercentFromQuotient($value); + case Dimension::TYPE_BYTE: + return $formatter->getPrettySizeFromBytes($value); + } + + return $value; + } + + public function getTranslatedName() + { + if (!empty($this->translatedName)) { + return Piwik::translate($this->translatedName); + } + + return $this->translatedName; + } + + public function getDependentMetrics() + { + return array($this->getName()); + } + + public function getDocumentation() + { + return $this->documentation; + } + + public function getDbTableName() + { + return $this->dbTable; + } + + public function getQuery() + { + if ($this->query) { + return $this->query; + } + + $column = $this->dbTable . '.' . $this->dimension->getColumnName(); + + if (!empty($this->aggregation)) { + return sprintf($this->aggregation, $column); + } + + return $column; + } + + public function beforeFormat($report, DataTable $table) + { + $this->idSite = DataTableFactory::getSiteIdFromMetadata($table); + if (empty($this->idSite)) { + $this->idSite = Common::getRequestVar('idSite', 0, 'int'); + } + return !empty($this->idSite); // skip formatting if there is no site to get currency info from + } +} diff --git a/core/Plugin/ComputedMetric.php b/core/Plugin/ComputedMetric.php new file mode 100644 index 0000000000..7825fedde3 --- /dev/null +++ b/core/Plugin/ComputedMetric.php @@ -0,0 +1,260 @@ +<?php +/** + * Piwik - free/libre analytics platform + * + * @link http://piwik.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ +namespace Piwik\Plugin; + +use Piwik\Archive\DataTableFactory; +use Piwik\Columns\Dimension; +use Piwik\Columns\MetricsList; +use Piwik\Common; +use Piwik\DataTable; +use Piwik\DataTable\Row; +use Piwik\Metrics\Formatter; +use Piwik\Piwik; + +class ComputedMetric extends ProcessedMetric +{ + const AGGREGATION_AVG = 'avg'; + const AGGREGATION_RATE = 'rate'; + + /** + * @var string + */ + private $aggregation; + + /** + * @var int + */ + protected $idSite; + + private $name = ''; + private $type = ''; + private $translatedName = ''; + private $documentation = ''; + private $metric1 = ''; + private $metric2 = ''; + private $category = ''; + + private $metricsList; + + public function __construct($metric1, $metric2, $aggregation = 'avg') + { + $nameShort1 = str_replace(array('nb_'), '', $metric1); + $nameShort2 = str_replace(array('nb_'), '', $metric2); + + if ($aggregation === ComputedMetric::AGGREGATION_AVG) { + $this->name = 'avg_' . $nameShort1 . '_per_' . $nameShort2; + } elseif ($aggregation === ComputedMetric::AGGREGATION_RATE) { + $this->name = $nameShort1 . '_' . $nameShort2 . '_rate'; + } else { + throw new \Exception('Not supported aggregation type'); + } + + $this->setMetric1($metric1); + $this->setMetric2($metric2); + $this->aggregation = $aggregation; + } + + public function getDependentMetrics() + { + return array($this->metric1, $this->metric2); + } + + public function setCategory($category) + { + $this->category = $category; + return $this; + } + + public function getCategoryId() + { + return $this->category; + } + + public function setMetric1($metric1) + { + $this->metric1 = $metric1; + return $this; + } + + public function setMetric2($metric2) + { + $this->metric2 = $metric2; + return $this; + } + + public function setDocumentation($documentation) + { + $this->documentation = $documentation; + return $this; + } + + public function setTranslatedName($name) + { + $this->translatedName = $name; + return $this; + } + + public function setType($type) + { + $this->type = $type; + return $this; + } + + public function setName($name) + { + $this->name = $name; + return $this; + } + + public function getName() + { + return $this->name; + } + + public function compute(Row $row) + { + $metric1 = $this->getMetric($row, $this->metric1); + $metric2 = $this->getMetric($row, $this->metric2); + + return Piwik::getQuotientSafe($metric1, $metric2, $precision = 2); + } + + private function getDetectedType() + { + if (!$this->type) { + if ($this->aggregation === self::AGGREGATION_RATE) { + $this->type = Dimension::TYPE_PERCENT; + } else { + $this->type = Dimension::TYPE_NUMBER; // default to number + $metric1 = $this->getMetricsList()->getMetric($this->metric1); + if ($metric1) { + $dimension = $metric1->getDimension(); + if ($dimension) { + $this->type = $dimension->getType(); + } + } + } + } + + return $this->type; + } + + public function format($value, Formatter $formatter) + { + if ($this->aggregation === self::AGGREGATION_RATE) { + return $formatter->getPrettyPercentFromQuotient($value); + } + + $type = $this->getDetectedType(); + + switch ($type) { + case Dimension::TYPE_MONEY: + return $formatter->getPrettyMoney($value, $this->idSite); + case Dimension::TYPE_FLOAT: + return $formatter->getPrettyNumber($value, $precision = 2); + case Dimension::TYPE_NUMBER: + return $formatter->getPrettyNumber($value, 1); // we still need to round to have somewhat more accurate result + case Dimension::TYPE_DURATION_S: + return $formatter->getPrettyTimeFromSeconds(round($value), $displayAsSentence = true); + case Dimension::TYPE_DURATION_MS: + $val = number_format($value / 1000, 2); + if ($val > 60) { + $val = round($val); + } + return $formatter->getPrettyTimeFromSeconds($val, $displayAsSentence = true); + case Dimension::TYPE_PERCENT: + return $formatter->getPrettyPercentFromQuotient($value); + case Dimension::TYPE_BYTE: + return $formatter->getPrettySizeFromBytes($value); + } + + return $value; + } + + public function getTranslatedName() + { + if (!$this->translatedName) { + $metric = $this->getMetricsList(); + $metric1 = $metric->getMetric($this->metric1); + $metric2 = $metric->getMetric($this->metric2); + + if ($this->aggregation === self::AGGREGATION_AVG) { + if ($metric1 && $metric1 instanceof ArchivedMetric && $metric2 && $metric2 instanceof ArchivedMetric) { + + $metric1Name = $metric1->getDimension()->getName(); + $metric2Name = $metric2->getDimension()->getName(); + return Piwik::translate('General_ComputedMetricAverage', array($metric1Name, $metric2Name)); + } + + if ($metric1 && $metric1 instanceof ArchivedMetric) { + $metric1Name = $metric1->getDimension()->getName(); + return Piwik::translate('General_AverageX', array($metric1Name)); + } + + if ($metric1 && $metric2) { + return $metric1->getTranslatedName() . ' per ' . $metric2->getTranslatedName(); + } + + return $this->metric1 . ' per ' . $this->metric2; + } else if ($this->aggregation === self::AGGREGATION_RATE) { + if ($metric1 && $metric1 instanceof ArchivedMetric) { + return Piwik::translate('General_ComputedMetricRate', array($metric1->getTranslatedName())); + } else { + return Piwik::translate('General_ComputedMetricRate', array($this->metric1)); + } + } + } + return $this->translatedName; + } + + public function getDocumentation() + { + if (!$this->documentation) { + $metric = $this->getMetricsList(); + $metric1 = $metric->getMetric($this->metric1); + $metric2 = $metric->getMetric($this->metric2); + + if ($this->aggregation === self::AGGREGATION_AVG) { + if ($metric1 && $metric1 instanceof ArchivedMetric && $metric2 && $metric2 instanceof ArchivedMetric) { + return Piwik::translate('General_ComputedMetricAverageDocumentation', array($metric1->getDimension()->getName(), $metric2->getTranslatedName())); + } + + if ($metric1 && $metric1 instanceof ArchivedMetric) { + return Piwik::translate('General_ComputedMetricAverageShortDocumentation', array($metric1->getDimension()->getName())); + } + + return Piwik::translate('General_ComputedMetricAverageDocumentation', array($this->metric1, $this->metric2)); + + } else if ($this->aggregation === self::AGGREGATION_RATE) { + if ($metric1 && $metric1 instanceof ArchivedMetric) { + return Piwik::translate('General_ComputedMetricRateDocumentation', array($metric1->getDimension()->getNamePlural(), $metric2->getDimension()->getNamePlural())); + } else { + return Piwik::translate('General_ComputedMetricRateShortDocumentation', array($this->metric1)); + } + } + } + return $this->documentation; + } + + private function getMetricsList() + { + if (!$this->metricsList) { + $this->metricsList = MetricsList::get(); + } + return $this->metricsList; + } + + public function beforeFormat($report, DataTable $table) + { + $this->idSite = DataTableFactory::getSiteIdFromMetadata($table); + if (empty($this->idSite)) { + $this->idSite = Common::getRequestVar('idSite', 0, 'int'); + } + return !empty($this->idSite); // skip formatting if there is no site to get currency info from + } +} diff --git a/core/Plugin/Dimension/ActionDimension.php b/core/Plugin/Dimension/ActionDimension.php index ce8a1eeda2..4ed4867448 100644 --- a/core/Plugin/Dimension/ActionDimension.php +++ b/core/Plugin/Dimension/ActionDimension.php @@ -12,10 +12,7 @@ use Piwik\CacheId; use Piwik\Cache as PiwikCache; use Piwik\Columns\Dimension; use Piwik\Plugin\Manager as PluginManager; -use Piwik\Plugin\Segment; -use Piwik\Common; use Piwik\Plugin; -use Piwik\Db; use Piwik\Tracker\Action; use Piwik\Tracker\Request; use Piwik\Tracker\Visitor; @@ -38,111 +35,8 @@ abstract class ActionDimension extends Dimension { const INSTALLER_PREFIX = 'log_link_visit_action.'; - private $tableName = 'log_link_visit_action'; - - /** - * Installs the action dimension in case it is not installed yet. The installation is already implemented based on - * the {@link $columnName} and {@link $columnType}. If you want to perform additional actions beside adding the - * column to the database - for instance adding an index - you can overwrite this method. We recommend to call - * this parent method to get the minimum required actions and then add further custom actions since this makes sure - * the column will be installed correctly. We also recommend to change the default install behavior only if really - * needed. FYI: We do not directly execute those alter table statements here as we group them together with several - * other alter table statements do execute those changes in one step which results in a faster installation. The - * column will be added to the `log_link_visit_action` MySQL table. - * - * Example: - * ``` - public function install() - { - $changes = parent::install(); - $changes['log_link_visit_action'][] = "ADD INDEX index_idsite_servertime ( idsite, server_time )"; - - return $changes; - } - ``` - * - * @return array An array containing the table name as key and an array of MySQL alter table statements that should - * be executed on the given table. Example: - * ``` - array( - 'log_link_visit_action' => array("ADD COLUMN `$this->columnName` $this->columnType", "ADD INDEX ...") - ); - ``` - * @api - */ - public function install() - { - if (empty($this->columnName) || empty($this->columnType)) { - return array(); - } - - return array( - $this->tableName => array("ADD COLUMN `$this->columnName` $this->columnType") - ); - } - - /** - * Updates the action dimension in case the {@link $columnType} has changed. The update is already implemented based - * on the {@link $columnName} and {@link $columnType}. This method is intended not to overwritten by plugin - * developers as it is only supposed to make sure the column has the correct type. Adding additional custom "alter - * table" actions would not really work since they would be executed with every {@link $columnType} change. So - * adding an index here would be executed whenever the columnType changes resulting in an error if the index already - * exists. If an index needs to be added after the first version is released a plugin update class should be - * created since this makes sure it is only executed once. - * - * @return array An array containing the table name as key and an array of MySQL alter table statements that should - * be executed on the given table. Example: - * ``` - array( - 'log_link_visit_action' => array("MODIFY COLUMN `$this->columnName` $this->columnType", "DROP COLUMN ...") - ); - ``` - * @ignore - */ - public function update() - { - if (empty($this->columnName) || empty($this->columnType)) { - return array(); - } - - return array( - $this->tableName => array("MODIFY COLUMN `$this->columnName` $this->columnType") - ); - } - - /** - * Uninstalls the dimension if a {@link $columnName} and {@link columnType} is set. In case you perform any custom - * actions during {@link install()} - for instance adding an index - you should make sure to undo those actions by - * overwriting this method. Make sure to call this parent method to make sure the uninstallation of the column - * will be done. - * @throws Exception - * @api - */ - public function uninstall() - { - if (empty($this->columnName) || empty($this->columnType)) { - return; - } - - try { - $sql = "ALTER TABLE `" . Common::prefixTable($this->tableName) . "` DROP COLUMN `$this->columnName`"; - Db::exec($sql); - } catch (Exception $e) { - if (!Db::get()->isErrNo($e, '1091')) { - throw $e; - } - } - } - - /** - * Get the version of the dimension which is used for update checks. - * @return string - * @ignore - */ - public function getVersion() - { - return $this->columnType; - } + protected $dbTableName = 'log_link_visit_action'; + protected $category = 'General_Actions'; /** * If the value you want to save for your dimension is something like a page title or page url, you usually do not @@ -192,23 +86,6 @@ abstract class ActionDimension extends Dimension } /** - * Adds a new segment. It automatically sets the SQL segment depending on the column name in case none is set - * already. - * @see \Piwik\Columns\Dimension::addSegment() - * @param Segment $segment - * @api - */ - protected function addSegment(Segment $segment) - { - $sqlSegment = $segment->getSqlSegment(); - if (!empty($this->columnName) && empty($sqlSegment)) { - $segment->setSqlSegment($this->tableName . '.' . $this->columnName); - } - - parent::addSegment($segment); - } - - /** * Get all action dimensions that are defined by all activated plugins. * @return ActionDimension[] * @ignore diff --git a/core/Plugin/Dimension/ConversionDimension.php b/core/Plugin/Dimension/ConversionDimension.php index ff1bff55ae..14a242d24f 100644 --- a/core/Plugin/Dimension/ConversionDimension.php +++ b/core/Plugin/Dimension/ConversionDimension.php @@ -12,15 +12,11 @@ use Piwik\CacheId; use Piwik\Cache as PiwikCache; use Piwik\Columns\Dimension; use Piwik\Plugin\Manager as PluginManager; -use Piwik\Common; -use Piwik\Db; use Piwik\Tracker\Action; use Piwik\Tracker\GoalManager; use Piwik\Tracker\Request; use Piwik\Tracker\Visitor; -use Piwik\Plugin\Segment; use Piwik\Plugin; -use Exception; /** * Defines a new conversion dimension that records any visit related information during tracking. @@ -40,116 +36,8 @@ abstract class ConversionDimension extends Dimension { const INSTALLER_PREFIX = 'log_conversion.'; - private $tableName = 'log_conversion'; - - /** - * Installs the conversion dimension in case it is not installed yet. The installation is already implemented based - * on the {@link $columnName} and {@link $columnType}. If you want to perform additional actions beside adding the - * column to the database - for instance adding an index - you can overwrite this method. We recommend to call - * this parent method to get the minimum required actions and then add further custom actions since this makes sure - * the column will be installed correctly. We also recommend to change the default install behavior only if really - * needed. FYI: We do not directly execute those alter table statements here as we group them together with several - * other alter table statements do execute those changes in one step which results in a faster installation. The - * column will be added to the `log_conversion` MySQL table. - * - * Example: - * ``` - public function install() - { - $changes = parent::install(); - $changes['log_conversion'][] = "ADD INDEX index_idsite_servertime ( idsite, server_time )"; - - return $changes; - } - ``` - * - * @return array An array containing the table name as key and an array of MySQL alter table statements that should - * be executed on the given table. Example: - * ``` - array( - 'log_conversion' => array("ADD COLUMN `$this->columnName` $this->columnType", "ADD INDEX ...") - ); - ``` - * @api - */ - public function install() - { - if (empty($this->columnName) || empty($this->columnType)) { - return array(); - } - - return array( - $this->tableName => array("ADD COLUMN `$this->columnName` $this->columnType") - ); - } - - /** - * @see ActionDimension::update() - * @return array - * @ignore - */ - public function update() - { - if (empty($this->columnName) || empty($this->columnType)) { - return array(); - } - - return array( - $this->tableName => array("MODIFY COLUMN `$this->columnName` $this->columnType") - ); - } - - /** - * Uninstalls the dimension if a {@link $columnName} and {@link columnType} is set. In case you perform any custom - * actions during {@link install()} - for instance adding an index - you should make sure to undo those actions by - * overwriting this method. Make sure to call this parent method to make sure the uninstallation of the column - * will be done. - * @throws Exception - * @api - */ - public function uninstall() - { - if (empty($this->columnName) || empty($this->columnType)) { - return; - } - - try { - $sql = "ALTER TABLE `" . Common::prefixTable($this->tableName) . "` DROP COLUMN `$this->columnName`"; - Db::exec($sql); - } catch (Exception $e) { - if (!Db::get()->isErrNo($e, '1091')) { - throw $e; - } - } - } - - /** - * @see ActionDimension::getVersion() - * @return string - * @ignore - */ - public function getVersion() - { - return $this->columnType; - } - - /** - * Adds a new segment. It automatically sets the SQL segment depending on the column name in case none is set - * already. - * - * @see \Piwik\Columns\Dimension::addSegment() - * @param Segment $segment - * @api - */ - protected function addSegment(Segment $segment) - { - $sqlSegment = $segment->getSqlSegment(); - if (!empty($this->columnName) && empty($sqlSegment)) { - $segment->setSqlSegment($this->tableName . '.' . $this->columnName); - } - - parent::addSegment($segment); - } + protected $dbTableName = 'log_conversion'; + protected $category = 'Goals_Conversion'; /** * Get all conversion dimensions that are defined by all activated plugins. diff --git a/core/Plugin/Dimension/VisitDimension.php b/core/Plugin/Dimension/VisitDimension.php index c59495d2aa..f6e3f19205 100644 --- a/core/Plugin/Dimension/VisitDimension.php +++ b/core/Plugin/Dimension/VisitDimension.php @@ -13,12 +13,11 @@ use Piwik\Cache as PiwikCache; use Piwik\Columns\Dimension; use Piwik\Common; use Piwik\Db; +use Piwik\DbHelper; use Piwik\Plugin\Manager as PluginManager; -use Piwik\Plugin\Segment; use Piwik\Tracker\Request; use Piwik\Tracker\Visitor; use Piwik\Tracker\Action; -use Piwik\Tracker; use Piwik\Plugin; use Exception; @@ -39,38 +38,9 @@ abstract class VisitDimension extends Dimension { const INSTALLER_PREFIX = 'log_visit.'; - private $tableName = 'log_visit'; + protected $dbTableName = 'log_visit'; + protected $category = 'General_Visitors'; - /** - * Installs the visit dimension in case it is not installed yet. The installation is already implemented based on - * the {@link $columnName} and {@link $columnType}. If you want to perform additional actions beside adding the - * column to the database - for instance adding an index - you can overwrite this method. We recommend to call - * this parent method to get the minimum required actions and then add further custom actions since this makes sure - * the column will be installed correctly. We also recommend to change the default install behavior only if really - * needed. FYI: We do not directly execute those alter table statements here as we group them together with several - * other alter table statements do execute those changes in one step which results in a faster installation. The - * column will be added to the `log_visit` MySQL table. - * - * Example: - * ``` - public function install() - { - $changes = parent::install(); - $changes['log_visit'][] = "ADD INDEX index_idsite_servertime ( idsite, server_time )"; - - return $changes; - } - ``` - * - * @return array An array containing the table name as key and an array of MySQL alter table statements that should - * be executed on the given table. Example: - * ``` - array( - 'log_visit' => array("ADD COLUMN `$this->columnName` $this->columnType", "ADD INDEX ...") - ); - ``` - * @api - */ public function install() { if (empty($this->columnType) || empty($this->columnName)) { @@ -78,7 +48,7 @@ abstract class VisitDimension extends Dimension } $changes = array( - $this->tableName => array("ADD COLUMN `$this->columnName` $this->columnType") + $this->dbTableName => array("ADD COLUMN `$this->columnName` $this->columnType") ); if ($this->isHandlingLogConversion()) { @@ -90,19 +60,20 @@ abstract class VisitDimension extends Dimension /** * @see ActionDimension::update() - * @param array $conversionColumns An array of currently installed columns in the conversion table. * @return array * @ignore */ - public function update($conversionColumns) + public function update() { if (!$this->columnType) { return array(); } + $conversionColumns = DbHelper::getTableColumns(Common::prefixTable('log_conversion')); + $changes = array(); - $changes[$this->tableName] = array("MODIFY COLUMN `$this->columnName` $this->columnType"); + $changes[$this->dbTableName] = array("MODIFY COLUMN `$this->columnName` $this->columnType"); $handlingConversion = $this->isHandlingLogConversion(); $hasConversionColumn = array_key_exists($this->columnName, $conversionColumns); @@ -119,7 +90,6 @@ abstract class VisitDimension extends Dimension } /** - * @see ActionDimension::getVersion() * @return string * @ignore */ @@ -152,7 +122,7 @@ abstract class VisitDimension extends Dimension } try { - $sql = "ALTER TABLE `" . Common::prefixTable($this->tableName) . "` DROP COLUMN `$this->columnName`"; + $sql = "ALTER TABLE `" . Common::prefixTable($this->dbTableName) . "` DROP COLUMN `$this->columnName`"; Db::exec($sql); } catch (Exception $e) { if (!Db::get()->isErrNo($e, '1091')) { @@ -175,23 +145,6 @@ abstract class VisitDimension extends Dimension } /** - * Adds a new segment. It automatically sets the SQL segment depending on the column name in case none is set - * already. - * @see \Piwik\Columns\Dimension::addSegment() - * @param Segment $segment - * @api - */ - protected function addSegment(Segment $segment) - { - $sqlSegment = $segment->getSqlSegment(); - if (!empty($this->columnName) && empty($sqlSegment)) { - $segment->setSqlSegment('log_visit.' . $this->columnName); - } - - parent::addSegment($segment); - } - - /** * Sometimes you may want to make sure another dimension is executed before your dimension so you can persist * this dimensions' value depending on the value of other dimensions. You can do this by defining an array of * dimension names. If you access any value of any other column within your events, you should require them here. diff --git a/core/Plugin/Metric.php b/core/Plugin/Metric.php index e1bdb78cad..464575b6c0 100644 --- a/core/Plugin/Metric.php +++ b/core/Plugin/Metric.php @@ -54,6 +54,16 @@ abstract class Metric abstract public function getTranslatedName(); /** + * Returns the category that this metric belongs to. + * @return string + * @api since Piwik 3.2.0 + */ + public function getCategoryId() + { + return ''; + } + + /** * Returns a string describing what the metric represents. The result will be included in report metadata * API output, including processed reports. * diff --git a/core/Plugin/Report.php b/core/Plugin/Report.php index 24887fb4c0..c300ad11a3 100644 --- a/core/Plugin/Report.php +++ b/core/Plugin/Report.php @@ -410,6 +410,7 @@ class Report if (empty($restrictToColumns)) { $restrictToColumns = array_merge($allMetrics, array_keys($this->getProcessedMetrics())); } + $restrictToColumns = array_unique($restrictToColumns); $processedMetricsById = $this->getProcessedMetricsById(); $metricsSet = array_flip($allMetrics); @@ -860,7 +861,7 @@ class Report $result = array(); foreach ($processedMetrics as $processedMetric) { - if ($processedMetric instanceof ProcessedMetric) { // instanceof check for backwards compatibility + if ($processedMetric instanceof ProcessedMetric || $processedMetric instanceof ArchivedMetric) { // instanceof check for backwards compatibility $result[$processedMetric->getName()] = $processedMetric; } } diff --git a/core/Plugin/Segment.php b/core/Plugin/Segment.php index 2a20208ea2..494e562d86 100644 --- a/core/Plugin/Segment.php +++ b/core/Plugin/Segment.php @@ -203,6 +203,33 @@ class Segment } /** + * @return string + * @ignore + */ + public function getSqlFilterValue() + { + return $this->sqlFilterValue; + } + + /** + * @return string + * @ignore + */ + public function getAcceptValues() + { + return $this->acceptValues; + } + + /** + * @return string + * @ignore + */ + public function getSqlFilter() + { + return $this->sqlFilter; + } + + /** * Set (overwrite) the type of this segment which is usually either a 'dimension' or a 'metric'. * @param string $type See constansts TYPE_* * @api @@ -231,6 +258,15 @@ class Segment } /** + * @return string + * @ignore + */ + public function getCategoryId() + { + return $this->category; + } + + /** * Returns the name of this segment as it should appear in segment expressions. * * @return string @@ -241,6 +277,15 @@ class Segment } /** + * @return string + * @ignore + */ + public function getSuggestedValuesCallback() + { + return $this->suggestedValuesCallback; + } + + /** * Set callback which will be executed when user will call for suggested values for segment. * * @param callable $suggestedValuesCallback diff --git a/core/Plugin/Visualization.php b/core/Plugin/Visualization.php index 6ac18ea67e..6bb2c3a4f8 100644 --- a/core/Plugin/Visualization.php +++ b/core/Plugin/Visualization.php @@ -13,6 +13,7 @@ use Piwik\API\DataTablePostProcessor; use Piwik\API\Proxy; use Piwik\API\ResponseBuilder; use Piwik\Common; +use Piwik\Container\StaticContainer; use Piwik\DataTable; use Piwik\Date; use Piwik\Log; @@ -279,13 +280,12 @@ class Visualization extends ViewDataTable $action = $this->requestConfig->getApiMethodToRequest(); $apiParameters = array(); - $idDimension = Common::getRequestVar('idDimension', 0, 'int'); - $idGoal = Common::getRequestVar('idGoal', 0, 'int'); - if ($idDimension > 0) { - $apiParameters['idDimension'] = $idDimension; - } - if ($idGoal > 0) { - $apiParameters['idGoal'] = $idGoal; + $entityNames = StaticContainer::get('entities.idNames'); + foreach ($entityNames as $entityName) { + $idEntity = Common::getRequestVar($entityName, 0, 'int'); + if ($idEntity > 0) { + $apiParameters[$entityName] = $idEntity; + } } $metadata = ApiApi::getInstance()->getMetadata($idSite, $module, $action, $apiParameters); diff --git a/core/RankingQuery.php b/core/RankingQuery.php index cd4f830669..2338cc9ba4 100644 --- a/core/RankingQuery.php +++ b/core/RankingQuery.php @@ -136,6 +136,14 @@ class RankingQuery } /** + * @return array + */ + public function getLabelColumns() + { + return $this->labelColumns; + } + + /** * Add a column that has be added to the outer queries. * * @param $column diff --git a/core/Segment.php b/core/Segment.php index aa39083cab..8e3e23cd9a 100644 --- a/core/Segment.php +++ b/core/Segment.php @@ -107,6 +107,16 @@ class Segment } } + /** + * Returns the segment expression. + * @return SegmentExpression + * @api since Piwik 3.2.0 + */ + public function getSegmentExpression() + { + return $this->segmentExpression; + } + private function getAvailableSegments() { // segment metadata @@ -253,7 +263,7 @@ class Segment && $matchType != SegmentExpression::MATCH_IS_NULL_OR_EMPTY) { if (isset($segment['sqlFilterValue'])) { - $value = call_user_func($segment['sqlFilterValue'], $value); + $value = call_user_func($segment['sqlFilterValue'], $value, $segment['sqlSegment']); } // apply presentation filter diff --git a/core/Segment/SegmentExpression.php b/core/Segment/SegmentExpression.php index 605cd13682..0428a5ca5f 100644 --- a/core/Segment/SegmentExpression.php +++ b/core/Segment/SegmentExpression.php @@ -325,9 +325,23 @@ class SegmentExpression $table = preg_replace('/^[A-Z_]+\(/', '', $table); $tableExists = !$table || in_array($table, $availableTables); - if (!$tableExists) { - $availableTables[] = $table; + if ($tableExists) { + return; } + + if (is_array($availableTables)) { + foreach ($availableTables as $availableTable) { + if (is_array($availableTable)) { + if (!isset($availableTable['tableAlias']) && $availableTable['table'] === $table) { + return; + } elseif (isset($availableTable['tableAlias']) && $availableTable['tableAlias'] === $table) { + return; + } + } + } + } + + $availableTables[] = $table; } /** diff --git a/core/Settings/FieldConfig.php b/core/Settings/FieldConfig.php index 7b2ce1df03..8074db3efb 100644 --- a/core/Settings/FieldConfig.php +++ b/core/Settings/FieldConfig.php @@ -63,6 +63,12 @@ class FieldConfig const UI_CONTROL_SINGLE_SELECT = 'select'; /** + * Shows an expandable select field which is useful when each selectable value belongs to a group. + * To use this field assign it to the `$uiControl` property. + */ + const UI_CONTROL_SINGLE_EXPANDABLE_SELECT = 'expandable-select'; + + /** * Generates a hidden form field. To use this field assign it to the `$uiControl` property. */ const UI_CONTROL_HIDDEN = 'hidden'; diff --git a/core/Tracker/LogTable.php b/core/Tracker/LogTable.php index 3fedba6945..091258e1e0 100644 --- a/core/Tracker/LogTable.php +++ b/core/Tracker/LogTable.php @@ -82,4 +82,15 @@ abstract class LogTable { return; } + /** + * Get the names of the columns that represents the primary key. For example "idvisit" or "idlink_va". If the table + * defines the primary key based on multiple columns, you must specify them all + * (eg array('idvisit', 'idgoal', 'buster')). + * + * @return array + */ + public function getPrimaryKey() + { + return array(); + } } diff --git a/core/ViewDataTable/Config.php b/core/ViewDataTable/Config.php index 150c3f42d7..2c7a59beff 100644 --- a/core/ViewDataTable/Config.php +++ b/core/ViewDataTable/Config.php @@ -11,6 +11,7 @@ namespace Piwik\ViewDataTable; use Piwik\API\Request as ApiRequest; use Piwik\Common; +use Piwik\Container\StaticContainer; use Piwik\DataTable; use Piwik\DataTable\Filter\PivotByDimension; use Piwik\Metrics; @@ -82,7 +83,7 @@ use Piwik\Plugin\ReportsProvider; * * @api */ -class Config +class Config { /** * The list of ViewDataTable properties that are 'Client Side Properties'. @@ -110,6 +111,7 @@ class Config 'show_related_reports', 'show_limit_control', 'show_search', + 'show_export', 'enable_sort', 'show_bar_chart', 'show_pie_chart', @@ -320,6 +322,13 @@ class Config public $show_search = true; /** + * Controls whether the export feature under the datatable is shown. + * + * @api since Piwik 3.2.0 + */ + public $show_export = true; + + /** * Controls whether the user can sort DataTables by clicking on table column headings. */ public $enable_sort = true; @@ -518,13 +527,12 @@ class Config } $apiParameters = array(); - $idDimension = Common::getRequestVar('idDimension', 0, 'int'); - $idGoal = Common::getRequestVar('idGoal', 0, 'int'); - if ($idDimension > 0) { - $apiParameters['idDimension'] = $idDimension; - } - if ($idGoal > 0) { - $apiParameters['idGoal'] = $idGoal; + $entityNames = StaticContainer::get('entities.idNames'); + foreach ($entityNames as $entityName) { + $idEntity = Common::getRequestVar($entityName, 0, 'int'); + if ($idEntity > 0) { + $apiParameters[$entityName] = $idEntity; + } } $report = API::getInstance()->getMetadata($idSite, $this->controllerName, $this->controllerAction, $apiParameters); |