diff options
author | diosmosis <benaka@piwik.pro> | 2014-09-18 07:40:38 +0400 |
---|---|---|
committer | diosmosis <benaka@piwik.pro> | 2014-09-18 07:40:38 +0400 |
commit | 41fabcb3488f00f5840dc2c231519488322b1837 (patch) | |
tree | 32021d167f45a4047e0195841fbb4fba8eed167f /core | |
parent | aafb07c75b86b7905da1d7525e2800c8dbad2cde (diff) |
Adding new PivotByDimension DataTable filter that can pivot a report by (almost) any dimension. The filter can pivot reports by their subtable dimension and can also pivot by other dimensions (by using segments).
Notes:
- in the UI, only pivoting by subtable is supported
- change to CSV DataTable renderer so column names w/ commas & quotes can appear in text
- change to XML DataTable renderer so column names w/ invalid XML characters can be rendered (bit of an iffy change, XML format needs an overhaul I think)
- includes new config option 'pivot_by_filter_enable_fetch_by_segment'
- includes additions to component metadata classes (ie, Report/Dimension)
Diffstat (limited to 'core')
-rw-r--r-- | core/API/DocumentationGenerator.php | 3 | ||||
-rw-r--r-- | core/API/ResponseBuilder.php | 14 | ||||
-rw-r--r-- | core/Columns/Dimension.php | 21 | ||||
-rw-r--r-- | core/DataTable.php | 8 | ||||
-rw-r--r-- | core/DataTable/Filter/PivotByDimension.php | 453 | ||||
-rw-r--r-- | core/DataTable/Renderer/Csv.php | 15 | ||||
-rw-r--r-- | core/DataTable/Renderer/Xml.php | 65 | ||||
-rw-r--r-- | core/EventDispatcher.php | 6 | ||||
-rw-r--r-- | core/Metrics.php | 1 | ||||
-rw-r--r-- | core/Plugin/ComponentFactory.php | 85 | ||||
-rw-r--r-- | core/Plugin/Report.php | 79 | ||||
-rw-r--r-- | core/Plugin/Segment.php | 10 | ||||
-rw-r--r-- | core/ViewDataTable/Config.php | 34 |
13 files changed, 771 insertions, 23 deletions
diff --git a/core/API/DocumentationGenerator.php b/core/API/DocumentationGenerator.php index 5e8b0d1e41..0662ff153e 100644 --- a/core/API/DocumentationGenerator.php +++ b/core/API/DocumentationGenerator.php @@ -190,6 +190,9 @@ class DocumentationGenerator $aParameters['hideColumns'] = false; $aParameters['showColumns'] = false; $aParameters['filter_pattern_recursive'] = false; + $aParameters['pivotBy'] = false; + $aParameters['pivotByColumn'] = false; + $aParameters['pivotByColumnLimit'] = false; $moduleName = Proxy::getInstance()->getModuleNameFromClassName($class); $aParameters = array_merge(array('module' => 'API', 'method' => $moduleName . '.' . $methodName), $aParameters); diff --git a/core/API/ResponseBuilder.php b/core/API/ResponseBuilder.php index f5af551917..17d5d488f7 100644 --- a/core/API/ResponseBuilder.php +++ b/core/API/ResponseBuilder.php @@ -14,6 +14,7 @@ use Piwik\API\DataTableManipulator\LabelFilter; use Piwik\API\DataTableManipulator\ReportTotalsCalculator; use Piwik\Common; use Piwik\DataTable; +use Piwik\DataTable\Filter\PivotByDimension; use Piwik\DataTable\Renderer; use Piwik\DataTable\DataTableInterface; use Piwik\DataTable\Filter\ColumnDelete; @@ -157,10 +158,21 @@ class ResponseBuilder return Renderer::formatValueXml($message); } - protected function handleDataTable($datatable) + protected function handleDataTable(DataTableInterface $datatable) { $label = $this->getLabelFromRequest($this->request); + // handle pivot by dimension filter + $pivotBy = Common::getRequestVar('pivotBy', false, 'string', $this->request); + if (!empty($pivotBy)) { + $reportId = $this->apiModule . '.' . $this->apiMethod; + $pivotByColumn = Common::getRequestVar('pivotByColumn', false, 'string', $this->request); + $pivotByColumnLimit = Common::getRequestVar('pivotByColumnLimit', false, 'int', $this->request); + + $datatable->filter('PivotByDimension', array($reportId, $pivotBy, $pivotByColumn, $pivotByColumnLimit, + PivotByDimension::isSegmentFetchingEnabledInConfig())); + } + // if requested, flatten nested tables if (Common::getRequestVar('flat', '0', 'string', $this->request) == '1') { $flattener = new Flattener($this->apiModule, $this->apiMethod, $this->request); diff --git a/core/Columns/Dimension.php b/core/Columns/Dimension.php index c2b39093e4..a6f8831a71 100644 --- a/core/Columns/Dimension.php +++ b/core/Columns/Dimension.php @@ -195,10 +195,29 @@ abstract class Dimension * @return Dimension|null The created instance or null if there is no Dimension for * $dimensionId or if the plugin that contains the Dimension is * not loaded. + * @api */ public static function factory($dimensionId) { list($module, $dimension) = explode('.', $dimensionId); return ComponentFactory::factory($module, $dimension, __CLASS__); } -} + + /** + * Returns the name of the plugin that contains this Dimension. + * + * @return string + * @throws Exception if the Dimension is not located within a Plugin module. + * @api + */ + public function getModule() + { + $id = $this->getId(); + if (empty($id)) { + throw new Exception("Invalid dimension ID: '$id'."); + } + + $parts = explode('.', $id); + return reset($parts); + } +}
\ No newline at end of file diff --git a/core/DataTable.php b/core/DataTable.php index 3049dbf966..5895e51f2a 100644 --- a/core/DataTable.php +++ b/core/DataTable.php @@ -1635,6 +1635,14 @@ class DataTable implements DataTableInterface, \IteratorAggregate, \ArrayAccess } /** + * Unsets all queued filters. + */ + public function clearQueuedFilters() + { + $this->queuedFilters = array(); + } + + /** * @return \ArrayIterator|Row[] */ public function getIterator() { diff --git a/core/DataTable/Filter/PivotByDimension.php b/core/DataTable/Filter/PivotByDimension.php new file mode 100644 index 0000000000..1d0a7267f8 --- /dev/null +++ b/core/DataTable/Filter/PivotByDimension.php @@ -0,0 +1,453 @@ +<?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\DataTable\Filter; + +use Exception; +use Piwik\Columns\Dimension; +use Piwik\Common; +use Piwik\Config; +use Piwik\DataTable; +use Piwik\DataTable\BaseFilter; +use Piwik\DataTable\Row; +use Piwik\Log; +use Piwik\Metrics; +use Piwik\Plugin\Report; +use Piwik\Plugin\Segment; + +/** + * DataTable filter that creates a pivot table from a report. + * + * A pivot table is a table that displays one metric value for two dimensions. The rows of + * the table represent one dimension and the columns another. + * + * This filter can pivot any report by any dimension as long as either: + * + * - the pivot-by dimension is the dimension of the report's subtable + * - or, the pivot-by dimension has an associated report, and the report to pivot has a dimension with + * a segment + * + * Reports are pivoted by iterating over the rows of the report, fetching the pivot-by report + * for the current row, and setting the columns of row to the rows of the pivot-by report. For example: + * + * to pivot Referrers.getKeywords by UserCountry.City, we first loop through the Referrers.getKeywords + * report's rows. For each row, we take the label (which is the referrer keyword), and get the + * UserCountry.getCity report using the referrerKeyword=... segment. If the row's label were 'abcdefg', + * we would use the 'referrerKeyword==abcdefg' segment. + * + * The UserCountry.getCity report we find is the report on visits by country, but only for the visits + * for the specific row. We take this report's row labels and add them as columns for the Referrers.getKeywords + * table. + * + * Implementation details: + * + * Fetching intersected table can be done by segment or subtable. If the requested pivot by + * dimension is the report's subtable dimension, then the subtable is used regardless, since it + * is much faster than fetching by segment. + * + * Also, by default, fetching by segment is disabled in the config (see the + * '[General] pivot_by_filter_enable_fetch_by_segment' option). + */ +class PivotByDimension extends BaseFilter +{ + const DEFAULT_COLUMN_LIMIT = 7; + + /** + * The pivot-by Dimension. The metadata in this class is used to determine if we can + * pivot the report and used to fetch intersected tables. + * + * @var Dimension + */ + private $pivotByDimension; + + /** + * The report that reports on visits by the pivot dimension. The metadata in this class + * is used to determine if we can pivot the report and used to fetch intersected tables + * by segment. + * + * @var Report + */ + private $pivotDimensionReport; + + /** + * The column that should be displayed in the pivot table. This should be a metric, eg, + * `'nb_visits'`, `'nb_actions'`, etc. + * + * @var string + */ + private $pivotColumn; + + /** + * The number of columns to limit the pivot table to. Applying a pivot can result in + * tables with many, many columns. This can cause problems when displayed in web page. + * + * A default limit of 7 is imposed if no column limit is specified in construction. + * If a negative value is supplied, no limiting is performed. + * + * Columns are summed and sorted before being limited so the columns w/ the most + * visits will be displayed and the columns w/ the least will be cut off. + * + * @var int + */ + private $pivotByColumnLimit; + + /** + * Metadata for the report being pivoted. The metadata in this class is used to + * determine if we can pivot the report and used to fetch intersected tables. + * + * @var Report + */ + private $thisReport; + + /** + * Metadata for the segment of the dimension of the report being pivoted. When + * fetching intersected tables by segment, this is the segment used. + * + * @var Segment + */ + private $thisReportDimensionSegment; + + /** + * Whether fetching by segment is enabled or not. + * + * @var bool + */ + private $isFetchingBySegmentEnabled; + + /** + * The subtable dimension of the report being pivoted. Used to determine if and + * how intersected tables are fetched. + * + * @var Dimension|null + */ + private $subtableDimension; + + /** + * The index value (if any) for the metric that should be displayed in the pivot + * table. + * + * @var int|null + */ + private $metricIndexValue; + + /** + * Constructor. + * + * @param DataTable $table The table to pivot. + * @param string $report The ID of the report being pivoted, eg, `'Referrers.getKeywords'`. + * @param string $pivotByDimension The ID of the dimension to pivot by, eg, `'Referrers.Keyword'`. + * @param string|false $pivotColumn The metric that should be displayed in the pivot table, eg, `'nb_visits'`. + * If `false`, the first non-label column is used. + * @param false|int $pivotByColumnLimit The number of columns to limit the pivot table to. + * @param bool $isFetchingBySegmentEnabled Whether to allow fetching by segment. + * @throws Exception if pivoting the report by a dimension is unsupported. + */ + public function __construct($table, $report, $pivotByDimension, $pivotColumn, $pivotByColumnLimit = false, + $isFetchingBySegmentEnabled = true) + { + parent::__construct($table); + + Log::debug("PivotByDimension::%s: creating with [report = %s, pivotByDimension = %s, pivotColumn = %s, " + . "pivotByColumnLimit = %s, isFetchingBySegmentEnabled = %s]", __FUNCTION__, $report, $pivotByDimension, + $pivotColumn, $pivotByColumnLimit, $isFetchingBySegmentEnabled); + + $this->pivotColumn = $pivotColumn; + $this->pivotByColumnLimit = $pivotByColumnLimit ?: self::DEFAULT_COLUMN_LIMIT; + $this->isFetchingBySegmentEnabled = $isFetchingBySegmentEnabled; + + $namesToId = Metrics::getMappingFromIdToName(); + $this->metricIndexValue = isset($namesToId[$this->pivotColumn]) ? $namesToId[$this->pivotColumn] : null; + + $this->setPivotByDimension($pivotByDimension); + $this->setThisReportMetadata($report); + + $this->checkSupportedPivot(); + } + + /** + * Pivots to table. + * + * @param DataTable $table The table to manipulate. + */ + public function filter($table) + { + // set of all column names in the pivoted table mapped with the sum of all column + // values. used later in truncating and ordering the pivoted table's columns. + $columnSet = array(); + + // if no pivot column was set, use the first one found in the row + if (empty($this->pivotColumn)) { + $this->pivotColumn = $this->getNameOfFirstNonLabelColumnInTable($table); + } + + Log::debug("PivotByDimension::%s: pivoting table with pivot column = %s", __FUNCTION__, $this->pivotColumn); + + foreach ($table->getRows() as $row) { + $row->setColumns(array('label' => $row->getColumn('label'))); + + $associatedTable = $this->getIntersectedTable($table, $row); + if (!empty($associatedTable)) { + foreach ($associatedTable->getRows() as $columnRow) { + $pivotTableColumn = $columnRow->getColumn('label'); + + $columnValue = $this->getColumnValue($columnRow, $this->pivotColumn); + + if (isset($columnSet[$pivotTableColumn])) { + $columnSet[$pivotTableColumn] += $columnValue; + } else { + $columnSet[$pivotTableColumn] = $columnValue; + } + + $row->setColumn($pivotTableColumn, $columnValue); + } + + Common::destroy($associatedTable); + unset($associatedTable); + } + } + + Log::debug("PivotByDimension::%s: pivoted columns set: %s", __FUNCTION__, $columnSet); + + // limit columns + if ($this->pivotByColumnLimit > 0) { + arsort($columnSet); + $columnSet = array_slice($columnSet, 0, $this->pivotByColumnLimit, $preserveKeys = true); + } + + // sort columns by name (to ensure deterministic ordering) + ksort($columnSet); + + // remove column sums from array so it can be used as a default row + $columnSet = array_map(function () { return false; }, $columnSet); + + // make sure label column is first + $columnSet = array_merge(array('label' => false), $columnSet); + + Log::debug("PivotByDimension::%s: processed pivoted columns: %s", __FUNCTION__, $columnSet); + + // post process pivoted datatable + foreach ($table->getRows() as $row) { + // remove subtables from rows + $row->removeSubtable(); + $row->deleteMetadata('idsubdatatable_in_db'); + + // use default row to ensure column ordering and add missing columns + $orderedColumns = $columnSet; + foreach ($row->getColumns() as $name => $value) { + if (isset($orderedColumns[$name])) { + $orderedColumns[$name] = $value; + } + } + $row->setColumns($orderedColumns); + } + + $table->clearQueuedFilters(); // TODO: shouldn't clear queued filters, but we can't wait for them to be run + // since generic filters are run before them. remove after refactoring + // processed metrics. + } + + /** + * An intersected table is a table that describes visits by a certain dimension for the visits + * represented by a row in another table. This method fetches intersected tables either via + * subtable or by using a segment. Read the class docs for more info. + */ + private function getIntersectedTable(DataTable $table, Row $row) + { + if ($this->isPivotDimensionSubtable()) { + return $this->loadSubtable($table, $row); + } + + if ($this->isFetchingBySegmentEnabled) { + $segmentValue = $row->getColumn('label'); + return $this->fetchIntersectedWithThisBySegment($table, $segmentValue); + } + + // should never occur, unless checkSupportedPivot() fails to catch an unsupported pivot + throw new Exception("Unexpected error, cannot fetch intersected table."); + } + + private function isPivotDimensionSubtable() + { + return !empty($this->subtableDimension) && $this->subtableDimension->getId() == $this->pivotByDimension->getId(); + } + + private function loadSubtable(DataTable $table, Row $row) + { + $idSubtable = $row->getIdSubDataTable(); + if ($idSubtable === null) { + return null; + } + + if ($row->isSubtableLoaded()) { + $subtable = $row->getSubtable(); + } else { + $subtable = $this->thisReport->fetchSubtable($idSubtable, $this->getRequestParamOverride($table)); + } + + if ($subtable === null) { // sanity check + throw new Exception("Unexpected error: could not load subtable '$idSubtable'."); + } + + return $subtable; + } + + private function fetchIntersectedWithThisBySegment(DataTable $table, $segmentValue) + { + $segmentStr = $this->thisReportDimensionSegment->getSegment() . "==" . urlencode($segmentValue); + + Log::debug("PivotByDimension: Fetching intersected with segment '%s'", $segmentStr); + + $params = array('segment' => $segmentStr) + $this->getRequestParamOverride($table); + return $this->pivotDimensionReport->fetch($params); + } + + private function setPivotByDimension($pivotByDimension) + { + $this->pivotByDimension = Dimension::factory($pivotByDimension); + if (empty($this->pivotByDimension)) { + throw new Exception("Invalid dimension '$pivotByDimension'."); + } + + $this->pivotDimensionReport = Report::getForDimension($this->pivotByDimension); + } + + private function setThisReportMetadata($report) + { + list($module, $method) = explode('.', $report); + + $this->thisReport = Report::factory($module, $method); + if (empty($this->thisReport)) { + throw new Exception("Unable to find report '$report'."); + } + + $this->subtableDimension = $this->thisReport->getSubtableDimension(); + + $thisReportDimension = $this->thisReport->getDimension(); + if ($thisReportDimension !== null) { + $segments = $thisReportDimension->getSegments(); + $this->thisReportDimensionSegment = reset($segments); + } + } + + private function checkSupportedPivot() + { + $reportId = $this->thisReport->getModule() . '.' . $this->thisReport->getName(); + + if (!$this->isFetchingBySegmentEnabled) { + // if fetching by segment is disabled, then there must be a subtable for the current report and + // subtable's dimension must be the pivot dimension + + if (empty($this->subtableDimension)) { + throw new Exception("Unsupported pivot: report '$reportId' has no subtable dimension."); + } + + if ($this->subtableDimension->getId() !== $this->pivotByDimension->getId()) { + throw new Exception("Unsupported pivot: the subtable dimension for '$reportId' does not match the " + . "requested pivotBy dimension. [subtable dimension = {$this->subtableDimension->getId()}, " + . "pivot by dimension = {$this->pivotByDimension->getId()}]"); + } + } else { + $canFetchBySubtable = !empty($this->subtableDimension) + && $this->subtableDimension->getId() === $this->pivotByDimension->getId(); + if ($canFetchBySubtable) { + return; + } + + // if fetching by segment is enabled, and we cannot fetch by subtable, then there has to be a report + // for the pivot dimension (so we can fetch the report), and there has to be a segment for this report's + // dimension (so we can use it when fetching) + + if (empty($this->pivotDimensionReport)) { + throw new Exception("Unsupported pivot: No report for pivot dimension '{$this->pivotByDimension->getId()}'" + . " (report required for fetching intersected tables by segment)."); + } + + if (empty($this->thisReportDimensionSegment)) { + throw new Exception("Unsupported pivot: No segment for dimension of report '$reportId'." + . " (segment required for fetching intersected tables by segment)."); + } + } + } + + /** + * @param $columnRow + * @param $pivotColumn + * @return false|mixed + */ + private function getColumnValue(Row $columnRow, $pivotColumn) + { + $value = $columnRow->getColumn($pivotColumn); + if (empty($value) + && !empty($this->metricIndexValue) + ) { + $value = $columnRow->getColumn($this->metricIndexValue); + } + return $value; + } + + private function getNameOfFirstNonLabelColumnInTable(DataTable $table) + { + foreach ($table->getRows() as $row) { + foreach ($row->getColumns() as $columnName => $ignore) { + if ($columnName != 'label') { + return $columnName; + } + } + } + } + + private function getRequestParamOverride(DataTable $table) + { + $params = array( + 'pivotBy' => '', + 'column' => '', + 'flat' => 0, + 'totals' => 0, + 'disable_queued_filters' => 1, + 'disable_generic_filters' => 1, + 'showColumns' => '', + 'hideColumns' => '' + ); + + $site = $table->getMetadata('site'); + if (!empty($site)) { + $params['idSite'] = $site->getId(); + } + + $period = $table->getMetadata('period'); + if (!empty($period)) { + $params['date'] = $period->getDateStart()->toString(); + $params['period'] = $period->getLabel(); + } + + return $params; + } + + /** + * Returns true if pivoting by subtable is supported for a report. Will return true if the report + * has a subtable dimension and if the subtable dimension is different than the report's dimension. + * + * @param Report $report + * @return bool + */ + public static function isPivotingReportBySubtableSupported(Report $report) + { + $subtableDimension = $report->getSubtableDimension(); + return !empty($subtableDimension) && $subtableDimension->getId() !== $report->getDimension()->getId(); + } + + /** + * Returns true if fetching intersected tables by segment is enabled in the INI config, false if otherwise. + * + * @return bool + */ + public static function isSegmentFetchingEnabledInConfig() + { + return Config::getInstance()->General['pivot_by_filter_enable_fetch_by_segment']; + } +}
\ No newline at end of file diff --git a/core/DataTable/Renderer/Csv.php b/core/DataTable/Renderer/Csv.php index 064dbd6db1..25961ce8a9 100644 --- a/core/DataTable/Renderer/Csv.php +++ b/core/DataTable/Renderer/Csv.php @@ -278,6 +278,11 @@ class Csv extends Renderer if ($this->translateColumnNames) { $columnMetrics = $this->translateColumnNames($columnMetrics); } + + foreach ($columnMetrics as &$value) { + $value = $this->formatValue($value); + } + return implode($this->separator, $columnMetrics); } @@ -387,4 +392,14 @@ class Csv extends Renderer return $name; } } + + private function escapeCsvValue($value) + { + if (strpos($value, ',') !== false + || strpos($value, '"') !== false + ) { + return '"' . addslashes($value) . '"'; + } + return $value; + } } diff --git a/core/DataTable/Renderer/Xml.php b/core/DataTable/Renderer/Xml.php index 9eb7db914b..93c800fafa 100644 --- a/core/DataTable/Renderer/Xml.php +++ b/core/DataTable/Renderer/Xml.php @@ -154,8 +154,7 @@ class Xml extends Renderer foreach ($array as $key => $value) { // based on the type of array & the key, determine how this node will look if ($isAssociativeArray) { - $keyIsInvalidXmlElement = is_numeric($key) || is_numeric($key[0]); - if ($keyIsInvalidXmlElement) { + if (!self::isValidXmlTagName($key)) { $prefix = "<row key=\"$key\">"; $suffix = "</row>"; $emptyNode = "<row key=\"$key\"/>"; @@ -338,6 +337,8 @@ class Xml extends Renderer */ protected function renderDataTable($array, $prefixLine = "") { + $columnsHaveInvalidChars = $this->areTableLabelsInvalidXmlTagNames(reset($array)); + $out = ''; foreach ($array as $rowId => $row) { if (!is_array($row)) { @@ -373,10 +374,13 @@ class Xml extends Renderer } else { $value = self::formatValueXml($value); } + + list($tagStart, $tagEnd) = $this->getTagStartAndEndFor($name, $columnsHaveInvalidChars); + if (strlen($value) == 0) { - $out .= $prefixLine . "\t\t<$name />\n"; + $out .= $prefixLine . "\t\t<$tagStart />\n"; } else { - $out .= $prefixLine . "\t\t<$name>" . $value . "</$name>\n"; + $out .= $prefixLine . "\t\t<$tagStart>" . $value . "</$tagEnd>\n"; } } $out .= "\t"; @@ -399,15 +403,62 @@ class Xml extends Renderer $array = array('value' => $array); } + $columnsHaveInvalidChars = $this->areTableLabelsInvalidXmlTagNames($array); + $out = ''; foreach ($array as $keyName => $value) { $xmlValue = self::formatValueXml($value); + list($tagStart, $tagEnd) = $this->getTagStartAndEndFor($keyName, $columnsHaveInvalidChars); if (strlen($xmlValue) == 0) { - $out .= $prefixLine . "\t<$keyName />\n"; + $out .= $prefixLine . "\t<$tagStart />\n"; } else { - $out .= $prefixLine . "\t<$keyName>" . $xmlValue . "</$keyName>\n"; + $out .= $prefixLine . "\t<$tagStart>" . $xmlValue . "</$tagEnd>\n"; } } return $out; } -} + + /** + * Returns true if a string is a valid XML tag name, false if otherwise. + * + * @param string $str + * @return bool + */ + private static function isValidXmlTagName($str) + { + static $validTagRegex = null; + + if ($validTagRegex === null) { + $invalidTagChars = "!\"#$%&'()*+,\\/;<=>?@[\\]\\\\^`{|}~"; + $invalidTagStartChars = $invalidTagChars . "\\-.0123456789"; + $validTagRegex = "/^[^" . $invalidTagStartChars . "][^" . $invalidTagChars . "]*$/"; + } + + $result = preg_match($validTagRegex, $str); + return !empty($result); + } + + private function areTableLabelsInvalidXmlTagNames($rowArray) + { + if (!empty($rowArray)) { + foreach ($rowArray as $name => $value) { + if (!self::isValidXmlTagName($name)) { + return true; + } + } + } + return false; + } + + private function getTagStartAndEndFor($keyName, $columnsHaveInvalidChars) + { + if ($columnsHaveInvalidChars) { + $tagStart = "col name=\"" . self::formatValueXml($keyName) . "\""; + $tagEnd = "col"; + } else { + $tagStart = $tagEnd = $keyName; + } + + return array($tagStart, $tagEnd); + } +}
\ No newline at end of file diff --git a/core/EventDispatcher.php b/core/EventDispatcher.php index ce0815c6ba..d9d26b4377 100644 --- a/core/EventDispatcher.php +++ b/core/EventDispatcher.php @@ -201,12 +201,14 @@ class EventDispatcher extends Singleton } /** - * TODO + * Returns the Plugin\Manager instance used by the event dispatcher. + * + * @return Plugin\Manager */ private function getPluginManager() { if ($this->pluginManager === null) { - $this->pluginManager = \Piwik\Plugin\Manager::getInstance(); + $this->pluginManager = Plugin\Manager::getInstance(); } return $this->pluginManager; } diff --git a/core/Metrics.php b/core/Metrics.php index c88bb13294..03b9e2e094 100644 --- a/core/Metrics.php +++ b/core/Metrics.php @@ -181,6 +181,7 @@ class Metrics return $names; } + // TODO: this method is named wrong public static function getMappingFromIdToName() { $idToName = array_flip(self::$mappingFromIdToName); diff --git a/core/Plugin/ComponentFactory.php b/core/Plugin/ComponentFactory.php index 02cb6083e1..68415e9397 100644 --- a/core/Plugin/ComponentFactory.php +++ b/core/Plugin/ComponentFactory.php @@ -37,20 +37,14 @@ class ComponentFactory public static function factory($pluginName, $componentClassSimpleName, $componentTypeClass) { if (empty($pluginName) || empty($componentClassSimpleName)) { + Log::debug("ComponentFactory::%s: empty plugin name or component simple name requested (%s, %s)", + __FUNCTION__, $pluginName, $componentClassSimpleName); + return null; } - $pluginManager = PluginManager::getInstance(); - - try { - if (!$pluginManager->isPluginActivated($pluginName)) { - return null; - } - - $plugin = $pluginManager->getLoadedPlugin($pluginName); - } catch (Exception $e) { - Log::debug($e); - + $plugin = self::getActivatedPlugin(__FUNCTION__, $pluginName); + if (empty($plugin)) { return null; } @@ -63,6 +57,75 @@ class ComponentFactory return new $class(); } } + + Log::debug("ComponentFactory::%s: Could not find requested component (args = ['%s', '%s', '%s']).", + __FUNCTION__, $pluginName, $componentClassSimpleName, $componentTypeClass); + + return null; + } + + /** + * Finds a component instance that satisfies a given predicate. + * + * @param string $componentTypeClass The fully qualified class name of the component type, eg, + * `"Piwik\Plugin\Report"`. + * @param string $pluginName|false The name of the plugin the component is expected to belong to, + * eg, `'UserSettings'`. + * @param callback $predicate + * @return mixed The component that satisfies $predicate or null if not found. + */ + public static function getComponentIf($componentTypeClass, $pluginName, $predicate) + { + $pluginManager = PluginManager::getInstance(); + + // get components to search through + $subnamespace = $componentTypeClass::COMPONENT_SUBNAMESPACE; + if (empty($pluginName)) { + $components = $pluginManager->findMultipleComponents($subnamespace, $componentTypeClass); + } else { + $plugin = self::getActivatedPlugin(__FUNCTION__, $pluginName); + if (empty($plugin)) { + return null; + } + + $components = $plugin->findMultipleComponents($subnamespace, $componentTypeClass); + } + + // find component that satisfieds predicate + foreach ($components as $class) { + $component = new $class(); + if ($predicate($component)) { + return $component; + } + } + + Log::debug("ComponentFactory::%s: Could not find component that satisfies predicate (args = ['%s', '%s', '%s']).", + __FUNCTION__, $componentTypeClass, $pluginName, get_class($predicate)); + return null; } + + /** + * @param string $function + * @param string $pluginName + * @return null|\Piwik\Plugin + */ + private static function getActivatedPlugin($function, $pluginName) + { + $pluginManager = PluginManager::getInstance(); + try { + if (!$pluginManager->isPluginActivated($pluginName)) { + Log::debug("ComponentFactory::%s: component for deactivated plugin ('%s') requested.", + $function, $pluginName); + + return null; + } + + return $pluginManager->getLoadedPlugin($pluginName); + } catch (Exception $e) { + Log::debug($e); + + return null; + } + } }
\ No newline at end of file diff --git a/core/Plugin/Report.php b/core/Plugin/Report.php index d9ee86e5c3..a907b0f709 100644 --- a/core/Plugin/Report.php +++ b/core/Plugin/Report.php @@ -9,7 +9,10 @@ namespace Piwik\Plugin; use Piwik\API\Proxy; +use Piwik\API\Request; use Piwik\Cache\LanguageAwareStaticCache; +use Piwik\Columns\Dimension; +use Piwik\DataTable; use Piwik\Menu\MenuReporting; use Piwik\Metrics; use Piwik\Piwik; @@ -576,6 +579,57 @@ class Report } /** + * Returns the Dimension instance of this report's subtable report. + * + * @return Dimension|null The subtable report's dimension or null if there is subtable report or + * no dimension for the subtable report. + * @api + */ + public function getSubtableDimension() + { + if (empty($this->actionToLoadSubTables)) { + return null; + } + + list($subtableReportModule, $subtableReportAction) = $this->getSubtableApiMethod(); + + $subtableReport = self::factory($subtableReportModule, $subtableReportAction); + if (empty($subtableReport)) { + return null; + } + + return $subtableReport->getDimension(); + } + + /** + * Fetches the report represented by this instance. + * + * @param array $paramOverride Query parameter overrides. + * @return DataTable + * @api + */ + public function fetch($paramOverride = array()) + { + return Request::processRequest($this->module . '.' . $this->action, $paramOverride); + } + + /** + * Fetches a subtable for the report represented by this instance. + * + * @param int $idSubtable The subtable ID. + * @param array $paramOverride Query parameter overrides. + * @return DataTable + * @api + */ + public function fetchSubtable($idSubtable, $paramOverride = array()) + { + $paramOverride = array('idSubtable' => $idSubtable) + $paramOverride; + + list($module, $action) = $this->getSubtableApiMethod(); + return Request::processRequest($module . '.' . $action, $paramOverride); + } + + /** * Get an instance of a specific report belonging to the given module and having the given action. * @param string $module * @param string $action @@ -648,4 +702,29 @@ class Report { return 'menu' . ucfirst($this->action); } + + private function getSubtableApiMethod() + { + if (strpos($this->actionToLoadSubTables, '.') !== false) { + return explode('.', $this->actionToLoadSubTables); + } else { + return array($this->module, $this->actionToLoadSubTables); + } + } + + /** + * Finds a top level report that provides stats for a specific Dimension. + * + * @param Dimension $dimension The dimension whose report we're looking for. + * @return Report|null The + * @api + */ + public static function getForDimension(Dimension $dimension) + { + return ComponentFactory::getComponentIf(__CLASS__, $dimension->getModule(), function (Report $report) use ($dimension) { + return !$report->isSubtableReport + && $report->getDimension() + && $report->getDimension()->getId() == $dimension->getId(); + }); + } } diff --git a/core/Plugin/Segment.php b/core/Plugin/Segment.php index cc3392eebc..795d4da157 100644 --- a/core/Plugin/Segment.php +++ b/core/Plugin/Segment.php @@ -187,6 +187,16 @@ class Segment } /** + * Returns the name of this segment as it should appear in segment expressions. + * + * @return string + */ + public function getSegment() + { + return $this->segment; + } + + /** * Set callback which will be executed when user will call for suggested values for segment. * * @param callable $suggestedValuesCallback diff --git a/core/ViewDataTable/Config.php b/core/ViewDataTable/Config.php index 093f28b254..0389c213fb 100644 --- a/core/ViewDataTable/Config.php +++ b/core/ViewDataTable/Config.php @@ -9,7 +9,9 @@ namespace Piwik\ViewDataTable; use Piwik\API\Request as ApiRequest; +use Piwik\DataTable\Filter\PivotByDimension; use Piwik\Metrics; +use Piwik\Plugin\Report; use Piwik\Plugins\API\API; /** @@ -83,7 +85,8 @@ class Config * The list of ViewDataTable properties that are 'Client Side Properties'. */ public $clientSideProperties = array( - 'show_limit_control' + 'show_limit_control', + 'pivot_by_dimension' ); /** @@ -93,6 +96,7 @@ class Config 'show_goals', 'show_exclude_low_population', 'show_flatten_table', + 'show_pivot_by_subtable', 'show_table', 'show_table_all_columns', 'show_footer', @@ -185,6 +189,18 @@ class Config public $show_flatten_table = true; /** + * Whether to show the 'Pivot by subtable' option (visible in the popup that displays after clicking + * the 'cog' icon). + */ + public $show_pivot_by_subtable; + + /** + * The ID of the dimension to pivot by when the 'pivot by subtable' option is clicked. Defaults + * to the subtable dimension of the report being displayed. + */ + public $pivot_by_dimension; + + /** * Controls whether the footer icon that allows users to switch to the 'normal' DataTable view * is shown. */ @@ -447,6 +463,7 @@ class Config $this->report_id = $controllerName . '.' . $controllerAction; $this->loadDocumentation(); + $this->setShouldShowPivotBySubtable(); } /** Load documentation from the API */ @@ -635,4 +652,19 @@ class Config $this->addTranslation($key, $translation); } } + + private function setShouldShowPivotBySubtable() + { + $report = Report::factory($this->controllerName, $this->controllerAction); + + if (empty($report)) { + $this->show_pivot_by_subtable = false; + $this->pivot_by_dimension = false; + } else { + $this->show_pivot_by_subtable = PivotByDimension::isPivotingReportBySubtableSupported($report); + + $subtableDimension = $report->getSubtableDimension(); + $this->pivot_by_dimension = $subtableDimension ? $subtableDimension->getId() : false; + } + } } |