pivotColumn = $pivotColumn; $this->pivotByColumnLimit = $pivotByColumnLimit ?: self::getDefaultColumnLimit(); $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); $others = Piwik::translate('General_Others'); $defaultRow = $this->getPivotTableDefaultRowFromColumnSummary($columnSet, $others); Log::debug("PivotByDimension::%s: un-prepended default row: %s", __FUNCTION__, $defaultRow); // 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/aggregate cut-off columns $orderedColumns = $defaultRow; foreach ($row->getColumns() as $name => $value) { if (isset($orderedColumns[$name])) { $orderedColumns[$name] = $value; } else { $orderedColumns[$others] += $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. // prepend numerals to columns in a queued filter (this way, disable_queued_filters can be used // to get machine readable data from the API if needed) $prependedColumnNames = $this->getOrderedColumnsWithPrependedNumerals($defaultRow, $others); Log::debug("PivotByDimension::%s: prepended column name mapping: %s", __FUNCTION__, $prependedColumnNames); $table->queueFilter(function (DataTable $table) use ($prependedColumnNames) { foreach ($table->getRows() as $row) { $row->setColumns(array_combine($prependedColumnNames, $row->getColumns())); } }); } /** * 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 self::areDimensionsEqualAndNotNull($this->subtableDimension, $this->pivotByDimension); } 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); // TODO: segment + report API method query params should be stored in DataTable metadata so we don't have to access it here $originalSegment = Common::getRequestVar('segment', false); if (!empty($originalSegment)) { $segmentStr = $originalSegment . ';' . $segmentStr; } 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->isPivotDimensionSubtable()) { 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; } private function getPivotTableDefaultRowFromColumnSummary($columnSet, $othersRowLabel) { // sort columns by sum (to ensure deterministic ordering) arsort($columnSet); // limit columns if necessary (adding aggregate Others column at end) if ($this->pivotByColumnLimit > 0 && count($columnSet) > $this->pivotByColumnLimit ) { $columnSet = array_slice($columnSet, 0, $this->pivotByColumnLimit - 1, $preserveKeys = true); $columnSet[$othersRowLabel] = 0; } // make sure column names are utf8 encoded $utfKeys = array_map('utf8_encode', array_keys($columnSet)); $columnSet = array_combine($utfKeys, array_values($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('label' => false) + $columnSet; return $columnSet; } private function getOrderedColumnsWithPrependedNumerals($defaultRow, $othersRowLabel) { $nbsp = html_entity_decode(' '); // must use decoded character otherwise sort later will fail // (sort column will be set to decoded but columns will have  ) $result = array(); $currentIndex = 1; foreach ($defaultRow as $columnName => $ignore) { if ($columnName === $othersRowLabel || $columnName === 'label' ) { $result[] = $columnName; } else { $modifiedColumnName = $currentIndex . '.' . $nbsp . $columnName; $result[] = $modifiedColumnName; ++$currentIndex; } } return $result; } /** * 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) { return self::areDimensionsNotEqualAndNotNull($report->getSubtableDimension(), $report->getDimension()); } /** * 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']; } /** * Returns the default maximum number of columns to allow in a pivot table from the INI config. * Uses the **pivot_by_filter_default_column_limit** INI config option. * * @return int */ public static function getDefaultColumnLimit() { return Config::getInstance()->General['pivot_by_filter_default_column_limit']; } /** * @param Dimension|null $lhs * @param Dimension|null $rhs * @return bool */ private static function areDimensionsEqualAndNotNull($lhs, $rhs) { return !empty($lhs) && !empty($rhs) && $lhs->getId() == $rhs->getId(); } /** * @param Dimension|null $lhs * @param Dimension|null $rhs * @return bool */ private static function areDimensionsNotEqualAndNotNull($lhs, $rhs) { return !empty($lhs) && !empty($rhs) && $lhs->getId() != $rhs->getId(); } }