site-accueil-insa/matomo/core/DataTable.php

2055 lines
70 KiB
PHP

<?php
/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*
*/
namespace Piwik;
use Closure;
use Exception;
use Piwik\DataTable\DataTableInterface;
use Piwik\DataTable\Manager;
use Piwik\DataTable\Renderer\Html;
use Piwik\DataTable\Row;
use Piwik\DataTable\Row\DataTableSummaryRow;
use Piwik\DataTable\Simple;
use ReflectionClass;
/**
* @see Common::destroy()
*/
require_once PIWIK_INCLUDE_PATH . '/core/Common.php';
require_once PIWIK_INCLUDE_PATH . "/core/DataTable/Bridges.php";
/**
* The primary data structure used to store analytics data in Piwik.
*
* <a name="class-desc-the-basics"></a>
* ### The Basics
*
* DataTables consist of rows and each row consists of columns. A column value can be
* a numeric, a string or an array.
*
* Every row has an ID. The ID is either the index of the row or {@link ID_SUMMARY_ROW}.
*
* DataTables are hierarchical data structures. Each row can also contain an additional
* nested sub-DataTable (commonly referred to as a 'subtable').
*
* Both DataTables and DataTable rows can hold **metadata**. _DataTable metadata_ is information
* regarding all the data, such as the site or period that the data is for. _Row metadata_
* is information regarding that row, such as a browser logo or website URL.
*
* Finally, all DataTables contain a special _summary_ row. This row, if it exists, is
* always at the end of the DataTable.
*
* ### Populating DataTables
*
* Data can be added to DataTables in three different ways. You can either:
*
* 1. create rows one by one and add them through {@link addRow()} then truncate if desired,
* 2. create an array of DataTable\Row instances or an array of arrays and add them using
* {@link addRowsFromArray()} or {@link addRowsFromSimpleArray()}
* then truncate if desired,
* 3. or set the maximum number of allowed rows (with {@link setMaximumAllowedRows()})
* and add rows one by one.
*
* If you want to eventually truncate your data (standard practice for all Piwik plugins),
* the third method is the most memory efficient. It is, unfortunately, not always possible
* to use since it requires that the data be sorted before adding.
*
* ### Manipulating DataTables
*
* There are two ways to manipulate a DataTable. You can either:
*
* 1. manually iterate through each row and manipulate the data,
* 2. or you can use predefined filters.
*
* A filter is a class that has a 'filter' method which will manipulate a DataTable in
* some way. There are several predefined Filters that allow you to do common things,
* such as,
*
* - add a new column to each row,
* - add new metadata to each row,
* - modify an existing column value for each row,
* - sort an entire DataTable,
* - and more.
*
* Using these filters instead of writing your own code will increase code clarity and
* reduce code redundancy. Additionally, filters have the advantage that they can be
* applied to DataTable\Map instances. So you can visit every DataTable in a {@link DataTable\Map}
* without having to write a recursive visiting function.
*
* All predefined filters exist in the **Piwik\DataTable\BaseFilter** namespace.
*
* _Note: For convenience, [anonymous functions](http://www.php.net/manual/en/functions.anonymous.php)
* can be used as DataTable filters._
*
* ### Applying Filters
*
* Filters can be applied now (via {@link filter()}), or they can be applied later (via
* {@link queueFilter()}).
*
* Filters that sort rows or manipulate the number of rows should be applied right away.
* Non-essential, presentation filters should be queued.
*
* ### Learn more
*
* - See **{@link ArchiveProcessor}** to learn how DataTables are persisted.
*
* ### Examples
*
* **Populating a DataTable**
*
* // adding one row at a time
* $dataTable = new DataTable();
* $dataTable->addRow(new Row(array(
* Row::COLUMNS => array('label' => 'thing1', 'nb_visits' => 1, 'nb_actions' => 1),
* Row::METADATA => array('url' => 'http://thing1.com')
* )));
* $dataTable->addRow(new Row(array(
* Row::COLUMNS => array('label' => 'thing2', 'nb_visits' => 2, 'nb_actions' => 2),
* Row::METADATA => array('url' => 'http://thing2.com')
* )));
*
* // using an array of rows
* $dataTable = new DataTable();
* $dataTable->addRowsFromArray(array(
* array(
* Row::COLUMNS => array('label' => 'thing1', 'nb_visits' => 1, 'nb_actions' => 1),
* Row::METADATA => array('url' => 'http://thing1.com')
* ),
* array(
* Row::COLUMNS => array('label' => 'thing2', 'nb_visits' => 2, 'nb_actions' => 2),
* Row::METADATA => array('url' => 'http://thing2.com')
* )
* ));
*
* // using a "simple" array
* $dataTable->addRowsFromSimpleArray(array(
* array('label' => 'thing1', 'nb_visits' => 1, 'nb_actions' => 1),
* array('label' => 'thing2', 'nb_visits' => 2, 'nb_actions' => 2)
* ));
*
* **Getting & setting metadata**
*
* $dataTable = \Piwik\Plugins\Referrers\API::getInstance()->getSearchEngines($idSite = 1, $period = 'day', $date = '2007-07-24');
* $oldPeriod = $dataTable->metadata['period'];
* $dataTable->metadata['period'] = Period\Factory::build('week', Date::factory('2013-10-18'));
*
* **Serializing & unserializing**
*
* $maxRowsInTable = Config::getInstance()->General['datatable_archiving_maximum_rows_standard'];j
*
* $dataTable = // ... build by aggregating visits ...
* $serializedData = $dataTable->getSerialized($maxRowsInTable, $maxRowsInSubtable = $maxRowsInTable,
* $columnToSortBy = Metrics::INDEX_NB_VISITS);
*
* $serializedDataTable = $serializedData[0];
* $serailizedSubTable = $serializedData[$idSubtable];
*
* **Filtering for an API method**
*
* public function getMyReport($idSite, $period, $date, $segment = false, $expanded = false)
* {
* $dataTable = Archive::createDataTableFromArchive('MyPlugin_MyReport', $idSite, $period, $date, $segment, $expanded);
* $dataTable->filter('Sort', array(Metrics::INDEX_NB_VISITS, 'desc', $naturalSort = false, $expanded));
* $dataTable->queueFilter('ColumnCallbackAddMetadata', array('label', 'url', __NAMESPACE__ . '\getUrlFromLabelForMyReport'));
* return $dataTable;
* }
*
*
* @api
*/
class DataTable implements DataTableInterface, \IteratorAggregate, \ArrayAccess
{
const MAX_DEPTH_DEFAULT = 15;
/** Name for metadata that describes when a report was archived. */
const ARCHIVED_DATE_METADATA_NAME = 'ts_archived';
/** Name for metadata that describes which columns are empty and should not be shown. */
const EMPTY_COLUMNS_METADATA_NAME = 'empty_column';
/** Name for metadata that describes the number of rows that existed before the Limit filter was applied. */
const TOTAL_ROWS_BEFORE_LIMIT_METADATA_NAME = 'total_rows_before_limit';
/**
* Name for metadata that describes how individual columns should be aggregated when {@link addDataTable()}
* or {@link Piwik\DataTable\Row::sumRow()} is called.
*
* This metadata value must be an array that maps column names with valid operations. Valid aggregation operations are:
*
* - `'skip'`: do nothing
* - `'max'`: does `max($column1, $column2)`
* - `'min'`: does `min($column1, $column2)`
* - `'sum'`: does `$column1 + $column2`
*
* See {@link addDataTable()} and {@link DataTable\Row::sumRow()} for more information.
*/
const COLUMN_AGGREGATION_OPS_METADATA_NAME = 'column_aggregation_ops';
/**
* Name for metadata that stores array of generic filters that should not be run on the table.
*/
const GENERIC_FILTERS_TO_DISABLE_METADATA_NAME = 'generic_filters_to_disable';
/** The ID of the Summary Row. */
const ID_SUMMARY_ROW = -1;
/**
* The ID of the special metadata row. This row only exists in the serialized row data and stores the datatable metadata.
*
* This allows us to save datatable metadata in archive data.
*/
const ID_ARCHIVED_METADATA_ROW = -3;
/** The original label of the Summary Row. */
const LABEL_SUMMARY_ROW = -1;
const LABEL_TOTALS_ROW = -2;
const LABEL_ARCHIVED_METADATA_ROW = '__datatable_metadata__';
/**
* Name for metadata that contains extra {@link Piwik\Plugin\ProcessedMetric}s for a DataTable.
* These metrics will be added in addition to the ones specified in the table's associated
* {@link Piwik\Plugin\Report} class.
*/
const EXTRA_PROCESSED_METRICS_METADATA_NAME = 'extra_processed_metrics';
/**
* Maximum nesting level.
*/
private static $maximumDepthLevelAllowed = self::MAX_DEPTH_DEFAULT;
/**
* Array of Row
*
* @var Row[]
*/
protected $rows = array();
/**
* Id assigned to the DataTable, used to lookup the table using the DataTable_Manager
*
* @var int
*/
protected $currentId;
/**
* Current depth level of this data table
* 0 is the parent data table
*
* @var int
*/
protected $depthLevel = 0;
/**
* This flag is set to false once we modify the table in a way that outdates the index
*
* @var bool
*/
protected $indexNotUpToDate = true;
/**
* This flag sets the index to be rebuild whenever a new row is added,
* as opposed to re-building the full index when getRowFromLabel is called.
* This is to optimize and not rebuild the full Index in the case where we
* add row, getRowFromLabel, addRow, getRowFromLabel thousands of times.
*
* @var bool
*/
protected $rebuildIndexContinuously = false;
/**
* Column name of last time the table was sorted
*
* @var string
*/
protected $tableSortedBy = false;
/**
* List of BaseFilter queued to this table
*
* @var array
*/
protected $queuedFilters = array();
/**
* List of disabled filter names eg 'Limit' or 'Sort'
*
* @var array
*/
protected $disabledFilters = array();
/**
* We keep track of the number of rows before applying the LIMIT filter that deletes some rows
*
* @var int
*/
protected $rowsCountBeforeLimitFilter = 0;
/**
* Defaults to false for performance reasons (most of the time we don't need recursive sorting so we save a looping over the dataTable)
*
* @var bool
*/
protected $enableRecursiveSort = false;
/**
* When the table and all subtables are loaded, this flag will be set to true to ensure filters are applied to all subtables
*
* @var bool
*/
protected $enableRecursiveFilters = false;
/**
* @var array
*/
protected $rowsIndexByLabel = array();
/**
* @var \Piwik\DataTable\Row
*/
protected $summaryRow = null;
/**
* @var \Piwik\DataTable\Row
*/
protected $totalsRow = null;
/**
* Table metadata. Read [this](#class-desc-the-basics) to learn more.
*
* Any data that describes the data held in the table's rows should go here.
*
* Note: this field is protected so derived classes will serialize it.
*
* @var array
*/
protected $metadata = array();
/**
* Maximum number of rows allowed in this datatable (including the summary row).
* If adding more rows is attempted, the extra rows get summed to the summary row.
*
* @var int
*/
protected $maximumAllowedRows = 0;
/**
* Constructor. Creates an empty DataTable.
*/
public function __construct()
{
// registers this instance to the manager
$this->currentId = Manager::getInstance()->addTable($this);
}
/**
* Destructor. Makes sure DataTable memory will be cleaned up.
*/
public function __destruct()
{
static $depth = 0;
// destruct can be called several times
if ($depth < self::$maximumDepthLevelAllowed
&& isset($this->rows)
) {
$depth++;
foreach ($this->rows as $row) {
Common::destroy($row);
}
if (isset($this->summaryRow)) {
Common::destroy($this->summaryRow);
}
unset($this->rows);
Manager::getInstance()->setTableDeleted($this->currentId);
$depth--;
}
}
/**
* Clone. Called when cloning the datatable. We need to make sure to create a new datatableId.
* If we do not increase tableId it can result in segmentation faults when destructing a datatable.
*/
public function __clone()
{
// registers this instance to the manager
$this->currentId = Manager::getInstance()->addTable($this);
}
public function setLabelsHaveChanged()
{
$this->indexNotUpToDate = true;
}
/**
* @ignore
* does not update the summary row!
*/
public function setRows($rows)
{
unset($this->rows);
$this->rows = $rows;
$this->indexNotUpToDate = true;
}
/**
* Sorts the DataTable rows using the supplied callback function.
*
* @param string $functionCallback A comparison callback compatible with {@link usort}.
* @param string $columnSortedBy The column name `$functionCallback` sorts by. This is stored
* so we can determine how the DataTable was sorted in the future.
*/
public function sort($functionCallback, $columnSortedBy)
{
$this->setTableSortedBy($columnSortedBy);
usort($this->rows, $functionCallback);
if ($this->isSortRecursiveEnabled()) {
foreach ($this->getRowsWithoutSummaryRow() as $row) {
$subTable = $row->getSubtable();
if ($subTable) {
$subTable->enableRecursiveSort();
$subTable->sort($functionCallback, $columnSortedBy);
}
}
}
}
public function setTotalsRow(Row $totalsRow)
{
$this->totalsRow = $totalsRow;
}
public function getTotalsRow()
{
return $this->totalsRow;
}
public function getSummaryRow()
{
return $this->summaryRow;
}
/**
* Returns the name of the column this table was sorted by (if any).
*
* See {@link sort()}.
*
* @return false|string The sorted column name or false if none.
*/
public function getSortedByColumnName()
{
return $this->tableSortedBy;
}
/**
* Enables recursive sorting. If this method is called {@link sort()} will also sort all
* subtables.
*/
public function enableRecursiveSort()
{
$this->enableRecursiveSort = true;
}
/**
* @ignore
*/
public function isSortRecursiveEnabled()
{
return $this->enableRecursiveSort === true;
}
/**
* @ignore
*/
public function setTableSortedBy($column)
{
$this->indexNotUpToDate = true;
$this->tableSortedBy = $column;
}
/**
* Enables recursive filtering. If this method is called then the {@link filter()} method
* will apply filters to every subtable in addition to this instance.
*/
public function enableRecursiveFilters()
{
$this->enableRecursiveFilters = true;
}
/**
* @ignore
*/
public function disableRecursiveFilters()
{
$this->enableRecursiveFilters = false;
}
/**
* Applies a filter to this datatable.
*
* If {@link enableRecursiveFilters()} was called, the filter will be applied
* to all subtables as well.
*
* @param string|Closure $className Class name, eg. `"Sort"` or "Piwik\DataTable\Filters\Sort"`. If no
* namespace is supplied, `Piwik\DataTable\BaseFilter` is assumed. This parameter
* can also be a closure that takes a DataTable as its first parameter.
* @param array $parameters Array of extra parameters to pass to the filter.
*/
public function filter($className, $parameters = array())
{
if ($className instanceof \Closure
|| is_array($className)
) {
array_unshift($parameters, $this);
call_user_func_array($className, $parameters);
return;
}
if (in_array($className, $this->disabledFilters)) {
return;
}
if (!class_exists($className, true)) {
$className = 'Piwik\DataTable\Filter\\' . $className;
}
$reflectionObj = new ReflectionClass($className);
// the first parameter of a filter is the DataTable
// we add the current datatable as the parameter
$parameters = array_merge(array($this), $parameters);
$filter = $reflectionObj->newInstanceArgs($parameters);
$filter->enableRecursive($this->enableRecursiveFilters);
$filter->filter($this);
}
/**
* Applies a filter to all subtables but not to this datatable.
*
* @param string|Closure $className Class name, eg. `"Sort"` or "Piwik\DataTable\Filters\Sort"`. If no
* namespace is supplied, `Piwik\DataTable\BaseFilter` is assumed. This parameter
* can also be a closure that takes a DataTable as its first parameter.
* @param array $parameters Array of extra parameters to pass to the filter.
*/
public function filterSubtables($className, $parameters = array())
{
foreach ($this->getRowsWithoutSummaryRow() as $row) {
$subtable = $row->getSubtable();
if ($subtable) {
$subtable->filter($className, $parameters);
$subtable->filterSubtables($className, $parameters);
}
}
}
/**
* Adds a filter and a list of parameters to the list of queued filters of all subtables. These filters will be
* executed when {@link applyQueuedFilters()} is called.
*
* Filters that prettify the column values or don't need the full set of rows should be queued. This
* way they will be run after the table is truncated which will result in better performance.
*
* @param string|Closure $className The class name of the filter, eg. `'Limit'`.
* @param array $parameters The parameters to give to the filter, eg. `array($offset, $limit)` for the Limit filter.
*/
public function queueFilterSubtables($className, $parameters = array())
{
foreach ($this->getRowsWithoutSummaryRow() as $row) {
$subtable = $row->getSubtable();
if ($subtable) {
$subtable->queueFilter($className, $parameters);
$subtable->queueFilterSubtables($className, $parameters);
}
}
}
/**
* Adds a filter and a list of parameters to the list of queued filters. These filters will be
* executed when {@link applyQueuedFilters()} is called.
*
* Filters that prettify the column values or don't need the full set of rows should be queued. This
* way they will be run after the table is truncated which will result in better performance.
*
* @param string|Closure $className The class name of the filter, eg. `'Limit'`.
* @param array $parameters The parameters to give to the filter, eg. `array($offset, $limit)` for the Limit filter.
*/
public function queueFilter($className, $parameters = array())
{
if (!is_array($parameters)) {
$parameters = array($parameters);
}
$this->queuedFilters[] = array('className' => $className, 'parameters' => $parameters);
}
/**
* Disable a specific filter to run on this DataTable in case you have already applied this filter or if you will
* handle this filter manually by using a custom filter. Be aware if you disable a given filter, that filter won't
* be ever executed. Even if another filter calls this filter on the DataTable.
*
* @param string $className eg 'Limit' or 'Sort'. Passing a `Closure` or an `array($class, $methodName)` is not
* supported yet. We check for exact match. So if you disable 'Limit' and
* call `->filter('Limit')` this filter won't be executed. If you call
* `->filter('Piwik\DataTable\Filter\Limit')` that filter will be executed. See it as a
* feature.
* @ignore
*/
public function disableFilter($className)
{
$this->disabledFilters[] = $className;
}
/**
* Applies all filters that were previously queued to the table. See {@link queueFilter()}
* for more information.
*/
public function applyQueuedFilters()
{
foreach ($this->queuedFilters as $filter) {
$this->filter($filter['className'], $filter['parameters']);
}
$this->clearQueuedFilters();
}
/**
* Sums a DataTable to this one.
*
* This method will sum rows that have the same label. If a row is found in `$tableToSum` whose
* label is not found in `$this`, the row will be added to `$this`.
*
* If the subtables for this table are loaded, they will be summed as well.
*
* Rows are summed together by summing individual columns. By default columns are summed by
* adding one column value to another. Some columns cannot be aggregated this way. In these
* cases, the {@link COLUMN_AGGREGATION_OPS_METADATA_NAME}
* metadata can be used to specify a different type of operation.
*
* @param \Piwik\DataTable $tableToSum
* @throws Exception
*/
public function addDataTable(DataTable $tableToSum)
{
if ($tableToSum instanceof Simple) {
if ($tableToSum->getRowsCount() > 1) {
throw new Exception("Did not expect a Simple table with more than one row in addDataTable()");
}
$row = $tableToSum->getFirstRow();
$this->aggregateRowFromSimpleTable($row);
} else {
$columnAggregationOps = $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME);
foreach ($tableToSum->getRowsWithoutSummaryRow() as $row) {
$this->aggregateRowWithLabel($row, $columnAggregationOps);
}
// we do not use getRows() as this method might get called 100k times when aggregating many datatables and
// this takes a lot of time.
$row = $tableToSum->getRowFromId(DataTable::ID_SUMMARY_ROW);
if ($row) {
$this->aggregateRow($this->summaryRow, $row, $columnAggregationOps, true);
}
}
}
/**
* Returns the Row whose `'label'` column is equal to `$label`.
*
* This method executes in constant time except for the first call which caches row
* label => row ID mappings.
*
* @param string $label `'label'` column value to look for.
* @return Row|false The row if found, `false` if otherwise.
*/
public function getRowFromLabel($label)
{
$rowId = $this->getRowIdFromLabel($label);
if (is_int($rowId) && isset($this->rows[$rowId])) {
return $this->rows[$rowId];
}
if ($rowId == self::ID_SUMMARY_ROW
&& !empty($this->summaryRow)
) {
return $this->summaryRow;
}
if (empty($rowId)
&& !empty($this->totalsRow)
&& $label == $this->totalsRow->getColumn('label')
) {
return $this->totalsRow;
}
if ($rowId instanceof Row) {
return $rowId;
}
return false;
}
/**
* Returns the row id for the row whose `'label'` column is equal to `$label`.
*
* This method executes in constant time except for the first call which caches row
* label => row ID mappings.
*
* @param string $label `'label'` column value to look for.
* @return int The row ID.
*/
public function getRowIdFromLabel($label)
{
if ($this->indexNotUpToDate) {
$this->rebuildIndex();
}
$label = (string) $label;
if (!isset($this->rowsIndexByLabel[$label])) {
// in case label is '-1' and there is no normal row w/ that label. Note: this is for BC since
// in the past, it was possible to get the summary row by searching for the label '-1'
if ($label == self::LABEL_SUMMARY_ROW
&& !is_null($this->summaryRow)
) {
return self::ID_SUMMARY_ROW;
}
return false;
}
return $this->rowsIndexByLabel[$label];
}
/**
* Returns an empty DataTable with the same metadata and queued filters as `$this` one.
*
* @param bool $keepFilters Whether to pass the queued filter list to the new DataTable or not.
* @return DataTable
*/
public function getEmptyClone($keepFilters = true)
{
$clone = new DataTable;
if ($keepFilters) {
$clone->queuedFilters = $this->queuedFilters;
}
$clone->metadata = $this->metadata;
return $clone;
}
/**
* Rebuilds the index used to lookup a row by label
* @internal
*/
public function rebuildIndex()
{
$this->rowsIndexByLabel = array();
$this->rebuildIndexContinuously = true;
foreach ($this->rows as $id => $row) {
$label = $row->getColumn('label');
if ($label !== false) {
$this->rowsIndexByLabel[$label] = $id;
}
}
$this->indexNotUpToDate = false;
}
/**
* Returns a row by ID. The ID is either the index of the row or {@link ID_SUMMARY_ROW}.
*
* @param int $id The row ID.
* @return Row|false The Row or false if not found.
*/
public function getRowFromId($id)
{
if ($id == self::ID_SUMMARY_ROW
&& !is_null($this->summaryRow)
) {
return $this->summaryRow;
}
if (!isset($this->rows[$id])) {
return false;
}
return $this->rows[$id];
}
/**
* Returns the row that has a subtable with ID matching `$idSubtable`.
*
* @param int $idSubTable The subtable ID.
* @return Row|false The row or false if not found
*/
public function getRowFromIdSubDataTable($idSubTable)
{
$idSubTable = (int)$idSubTable;
foreach ($this->rows as $row) {
if ($row->getIdSubDataTable() === $idSubTable) {
return $row;
}
}
return false;
}
/**
* Adds a row to this table.
*
* If {@link setMaximumAllowedRows()} was called and the current row count is
* at the maximum, the new row will be summed to the summary row. If there is no summary row,
* this row is set as the summary row.
*
* @param Row $row
* @return Row `$row` or the summary row if we're at the maximum number of rows.
*/
public function addRow(Row $row)
{
// if there is a upper limit on the number of allowed rows and the table is full,
// add the new row to the summary row
if ($this->maximumAllowedRows > 0
&& $this->getRowsCount() >= $this->maximumAllowedRows - 1
) {
if ($this->summaryRow === null) {
// create the summary row if necessary
$columns = array('label' => self::LABEL_SUMMARY_ROW) + $row->getColumns();
$this->addSummaryRow(new Row(array(Row::COLUMNS => $columns)));
} else {
$this->summaryRow->sumRow(
$row, $enableCopyMetadata = false, $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME));
}
return $this->summaryRow;
}
$this->rows[] = $row;
if (!$this->indexNotUpToDate
&& $this->rebuildIndexContinuously
) {
$label = $row->getColumn('label');
if ($label !== false) {
$this->rowsIndexByLabel[$label] = count($this->rows) - 1;
}
}
return $row;
}
/**
* Sets the summary row.
*
* _Note: A DataTable can have only one summary row._
*
* @param Row $row
* @return Row Returns `$row`.
*/
public function addSummaryRow(Row $row)
{
$this->summaryRow = $row;
$row->setIsSummaryRow();
// NOTE: the summary row does not go in the index, since it will overwrite rows w/ label == -1
return $row;
}
/**
* Returns the DataTable ID.
*
* @return int
*/
public function getId()
{
return $this->currentId;
}
/**
* Adds a new row from an array.
*
* You can add row metadata with this method.
*
* @param array $row eg. `array(Row::COLUMNS => array('visits' => 13, 'test' => 'toto'),
* Row::METADATA => array('mymetadata' => 'myvalue'))`
*/
public function addRowFromArray($row)
{
$this->addRowsFromArray(array($row));
}
/**
* Adds a new row a from an array of column values.
*
* Row metadata cannot be added with this method.
*
* @param array $row eg. `array('name' => 'google analytics', 'license' => 'commercial')`
*/
public function addRowFromSimpleArray($row)
{
$this->addRowsFromSimpleArray(array($row));
}
/**
* Returns the array of Rows.
* Internal logic in Matomo core should avoid using this method as it is time and memory consuming when being
* executed thousands of times. The alternative is to use {@link getRowsWithoutSummaryRow()} + get the summary
* row manually.
*
* @return Row[]
*/
public function getRows()
{
if (is_null($this->summaryRow)) {
return $this->rows;
} else {
return $this->rows + array(self::ID_SUMMARY_ROW => $this->summaryRow);
}
}
/**
* @ignore
*/
public function getRowsWithoutSummaryRow()
{
return $this->rows;
}
/**
* @ignore
*/
public function getRowsCountWithoutSummaryRow()
{
return count($this->rows);
}
/**
* Returns an array containing all column values for the requested column.
*
* @param string $name The column name.
* @return array The array of column values.
*/
public function getColumn($name)
{
$columnValues = array();
foreach ($this->getRows() as $row) {
$columnValues[] = $row->getColumn($name);
}
return $columnValues;
}
/**
* Returns an array containing all column values of columns whose name starts with `$name`.
*
* @param string $namePrefix The column name prefix.
* @return array The array of column values.
*/
public function getColumnsStartingWith($namePrefix)
{
$columnValues = array();
foreach ($this->getRows() as $row) {
$columns = $row->getColumns();
foreach ($columns as $column => $value) {
if (strpos($column, $namePrefix) === 0) {
$columnValues[] = $row->getColumn($column);
}
}
}
return $columnValues;
}
/**
* Returns the names of every column this DataTable contains. This method will return the
* columns of the first row with data and will assume they occur in every other row as well.
*
*_ Note: If column names still use their in-database INDEX values (@see Metrics), they
* will be converted to their string name in the array result._
*
* @return array Array of string column names.
*/
public function getColumns()
{
$result = array();
foreach ($this->getRows() as $row) {
$columns = $row->getColumns();
if (!empty($columns)) {
$result = array_keys($columns);
break;
}
}
// make sure column names are not DB index values
foreach ($result as &$column) {
if (isset(Metrics::$mappingFromIdToName[$column])) {
$column = Metrics::$mappingFromIdToName[$column];
}
}
return $result;
}
/**
* Returns an array containing the requested metadata value of each row.
*
* @param string $name The metadata column to return.
* @return array
*/
public function getRowsMetadata($name)
{
$metadataValues = array();
foreach ($this->getRows() as $row) {
$metadataValues[] = $row->getMetadata($name);
}
return $metadataValues;
}
/**
* Delete row metadata by name in every row.
*
* @param $name
* @param bool $deleteRecursiveInSubtables
*/
public function deleteRowsMetadata($name, $deleteRecursiveInSubtables = false)
{
foreach ($this->rows as $row) {
$row->deleteMetadata($name);
$subTable = $row->getSubtable();
if ($subTable) {
$subTable->deleteRowsMetadata($name, $deleteRecursiveInSubtables);
}
}
if (!is_null($this->summaryRow)) {
$this->summaryRow->deleteMetadata($name);
}
if (!is_null($this->totalsRow)) {
$this->totalsRow->deleteMetadata($name);
}
}
/**
* Returns the number of rows in the table including the summary row.
*
* @return int
*/
public function getRowsCount()
{
if (is_null($this->summaryRow)) {
return count($this->rows);
} else {
return count($this->rows) + 1;
}
}
/**
* Returns the first row of the DataTable.
*
* @return Row|false The first row or `false` if it cannot be found.
*/
public function getFirstRow()
{
if (count($this->rows) == 0) {
if (!is_null($this->summaryRow)) {
return $this->summaryRow;
}
return false;
}
return reset($this->rows);
}
/**
* Returns the last row of the DataTable. If there is a summary row, it
* will always be considered the last row.
*
* @return Row|false The last row or `false` if it cannot be found.
*/
public function getLastRow()
{
if (!is_null($this->summaryRow)) {
return $this->summaryRow;
}
if (count($this->rows) == 0) {
return false;
}
return end($this->rows);
}
/**
* Returns the number of rows in the entire DataTable hierarchy. This is the number of rows in this DataTable
* summed with the row count of each descendant subtable.
*
* @return int
*/
public function getRowsCountRecursive()
{
$totalCount = 0;
foreach ($this->rows as $row) {
$subTable = $row->getSubtable();
if ($subTable) {
$count = $subTable->getRowsCountRecursive();
$totalCount += $count;
}
}
$totalCount += $this->getRowsCount();
return $totalCount;
}
/**
* Delete a column by name in every row. This change is NOT applied recursively to all
* subtables.
*
* @param string $name Column name to delete.
*/
public function deleteColumn($name)
{
$this->deleteColumns(array($name));
}
public function __sleep()
{
return array('rows', 'summaryRow', 'metadata', 'totalsRow');
}
/**
* Rename a column in every row. This change is applied recursively to all subtables.
*
* @param string $oldName Old column name.
* @param string $newName New column name.
*/
public function renameColumn($oldName, $newName)
{
foreach ($this->rows as $row) {
$row->renameColumn($oldName, $newName);
$subTable = $row->getSubtable();
if ($subTable) {
$subTable->renameColumn($oldName, $newName);
}
}
if (!is_null($this->summaryRow)) {
$this->summaryRow->renameColumn($oldName, $newName);
}
if (!is_null($this->totalsRow)) {
$this->totalsRow->renameColumn($oldName, $newName);
}
}
/**
* Deletes several columns by name in every row.
*
* @param array $names List of column names to delete.
* @param bool $deleteRecursiveInSubtables Whether to apply this change to all subtables or not.
*/
public function deleteColumns($names, $deleteRecursiveInSubtables = false)
{
foreach ($this->rows as $row) {
foreach ($names as $name) {
$row->deleteColumn($name);
}
$subTable = $row->getSubtable();
if ($subTable) {
$subTable->deleteColumns($names, $deleteRecursiveInSubtables);
}
}
if (!is_null($this->summaryRow)) {
foreach ($names as $name) {
$this->summaryRow->deleteColumn($name);
}
}
if (!is_null($this->totalsRow)) {
foreach ($names as $name) {
$this->totalsRow->deleteColumn($name);
}
}
}
/**
* Deletes a row by ID.
*
* @param int $id The row ID.
* @throws Exception If the row `$id` cannot be found.
*/
public function deleteRow($id)
{
if ($id === self::ID_SUMMARY_ROW) {
$this->summaryRow = null;
return;
}
if (!isset($this->rows[$id])) {
throw new Exception("Trying to delete unknown row with idkey = $id");
}
unset($this->rows[$id]);
}
/**
* Deletes rows from `$offset` to `$offset + $limit`.
*
* @param int $offset The offset to start deleting rows from.
* @param int|null $limit The number of rows to delete. If `null` all rows after the offset
* will be removed.
* @return int The number of rows deleted.
*/
public function deleteRowsOffset($offset, $limit = null)
{
if ($limit === 0) {
return 0;
}
$count = $this->getRowsCount();
if ($offset >= $count) {
return 0;
}
// if we delete until the end, we delete the summary row as well
if (is_null($limit)
|| $limit >= $count
) {
$this->summaryRow = null;
}
if (is_null($limit)) {
array_splice($this->rows, $offset);
} else {
array_splice($this->rows, $offset, $limit);
}
return $count - $this->getRowsCount();
}
/**
* Deletes a set of rows by ID.
*
* @param array $rowIds The list of row IDs to delete.
* @throws Exception If a row ID cannot be found.
*/
public function deleteRows(array $rowIds)
{
foreach ($rowIds as $key) {
$this->deleteRow($key);
}
}
/**
* Returns a string representation of this DataTable for convenient viewing.
*
* _Note: This uses the **html** DataTable renderer._
*
* @return string
*/
public function __toString()
{
$renderer = new Html();
$renderer->setTable($this);
return (string)$renderer;
}
/**
* Returns true if both DataTable instances are exactly the same.
*
* DataTables are equal if they have the same number of rows, if
* each row has a label that exists in the other table, and if each row
* is equal to the row in the other table with the same label. The order
* of rows is not important.
*
* @param \Piwik\DataTable $table1
* @param \Piwik\DataTable $table2
* @return bool
*/
public static function isEqual(DataTable $table1, DataTable $table2)
{
$table1->rebuildIndex();
$table2->rebuildIndex();
if ($table1->getRowsCount() != $table2->getRowsCount()) {
return false;
}
$rows1 = $table1->getRows();
foreach ($rows1 as $row1) {
$row2 = $table2->getRowFromLabel($row1->getColumn('label'));
if ($row2 === false
|| !Row::isEqual($row1, $row2)
) {
return false;
}
}
return true;
}
/**
* Serializes an entire DataTable hierarchy and returns the array of serialized DataTables.
*
* The first element in the returned array will be the serialized representation of this DataTable.
* Every subsequent element will be a serialized subtable.
*
* This DataTable and subtables can optionally be truncated before being serialized. In most
* cases where DataTables can become quite large, they should be truncated before being persisted
* in an archive.
*
* The result of this method is intended for use with the {@link ArchiveProcessor::insertBlobRecord()} method.
*
* @throws Exception If infinite recursion detected. This will occur if a table's subtable is one of its parent tables.
* @param int $maximumRowsInDataTable If not null, defines the maximum number of rows allowed in the serialized DataTable.
* @param int $maximumRowsInSubDataTable If not null, defines the maximum number of rows allowed in serialized subtables.
* @param string $columnToSortByBeforeTruncation The column to sort by before truncating, eg, `Metrics::INDEX_NB_VISITS`.
* @param array $aSerializedDataTable Will contain all the output arrays
* @return array The array of serialized DataTables:
*
* array(
* // this DataTable (the root)
* 0 => 'eghuighahgaueytae78yaet7yaetae',
*
* // a subtable
* 1 => 'gaegae gh gwrh guiwh uigwhuige',
*
* // another subtable
* 2 => 'gqegJHUIGHEQjkgneqjgnqeugUGEQHGUHQE',
*
* // etc.
* );
*/
public function getSerialized($maximumRowsInDataTable = null,
$maximumRowsInSubDataTable = null,
$columnToSortByBeforeTruncation = null,
&$aSerializedDataTable = array())
{
static $depth = 0;
// make sure subtableIds are consecutive from 1 to N
static $subtableId = 0;
if ($depth > self::$maximumDepthLevelAllowed) {
$depth = 0;
$subtableId = 0;
throw new Exception("Maximum recursion level of " . self::$maximumDepthLevelAllowed . " reached. Maybe you have set a DataTable\Row with an associated DataTable belonging already to one of its parent tables?");
}
// gather metadata before filters are called, so their metadata is not stored in serialized form
$metadata = $this->getAllTableMetadata();
foreach ($metadata as $key => $value) {
if (!is_scalar($value) && !is_string($value)) {
unset($metadata[$key]);
}
}
if (!is_null($maximumRowsInDataTable)) {
$this->filter('Truncate',
array($maximumRowsInDataTable - 1,
DataTable::LABEL_SUMMARY_ROW,
$columnToSortByBeforeTruncation,
$filterRecursive = false)
);
}
$consecutiveSubtableIds = array();
$forcedId = $subtableId;
// For each row (including the summary row), get the serialized row
// If it is associated to a sub table, get the serialized table recursively ;
// but returns all serialized tables and subtable in an array of 1 dimension
foreach ($this->getRows() as $id => $row) {
$subTable = $row->getSubtable();
if ($subTable) {
$consecutiveSubtableIds[$id] = ++$subtableId;
$depth++;
$subTable->getSerialized($maximumRowsInSubDataTable, $maximumRowsInSubDataTable, $columnToSortByBeforeTruncation, $aSerializedDataTable);
$depth--;
} else {
$row->removeSubtable();
}
}
// if the datatable is the parent we force the Id at 0 (this is part of the specification)
if ($depth == 0) {
$forcedId = 0;
$subtableId = 0;
}
// we then serialize the rows and store them in the serialized dataTable
$rows = array();
foreach ($this->rows as $id => $row) {
if (isset($consecutiveSubtableIds[$id])) {
$backup = $row->subtableId;
$row->subtableId = $consecutiveSubtableIds[$id];
$rows[$id] = $row->export();
$row->subtableId = $backup;
} else {
$rows[$id] = $row->export();
}
}
if (isset($this->summaryRow)) {
$id = self::ID_SUMMARY_ROW;
$row = $this->summaryRow;
// duplicating code above so we don't create a new array w/ getRows() above in this function which is
// used heavily in matomo.
if (isset($consecutiveSubtableIds[$id])) {
$backup = $row->subtableId;
$row->subtableId = $consecutiveSubtableIds[$id];
$rows[$id] = $row->export();
$row->subtableId = $backup;
} else {
$rows[$id] = $row->export();
}
}
if (!empty($metadata)) {
$metadataRow = new Row();
$metadataRow->setColumns($metadata);
// set the label so the row will be indexed correctly internally
$metadataRow->setColumn('label', self::LABEL_ARCHIVED_METADATA_ROW);
$rows[self::ID_ARCHIVED_METADATA_ROW] = $metadataRow->export();
}
$aSerializedDataTable[$forcedId] = serialize($rows);
unset($rows);
return $aSerializedDataTable;
}
private static $previousRowClasses = array('O:39:"Piwik\DataTable\Row\DataTableSummaryRow"', 'O:19:"Piwik\DataTable\Row"', 'O:36:"Piwik_DataTable_Row_DataTableSummary"', 'O:19:"Piwik_DataTable_Row"');
private static $rowClassToUseForUnserialize = 'O:29:"Piwik_DataTable_SerializedRow"';
/**
* It is faster to unserialize existing serialized Row instances to "Piwik_DataTable_SerializedRow" and access the
* `$row->c` property than implementing a "__wakeup" method in the Row instance to map the "$row->c" to $row->columns
* etc. We're talking here about 15% faster reports aggregation in some cases. To be concrete: We have a test where
* Archiving a year takes 1700 seconds with "__wakeup" and 1400 seconds with this method. Yes, it takes 300 seconds
* to wake up millions of rows. We should be able to remove this code here end 2015 and use the "__wakeup" way by then.
* Why? By then most new archives will have only arrays serialized anyway and therefore this mapping is rather an overhead.
*
* @param string $serialized
* @return array
* @throws Exception In case the unserialize fails
*/
private function unserializeRows($serialized)
{
$serialized = str_replace(self::$previousRowClasses, self::$rowClassToUseForUnserialize, $serialized);
$rows = Common::safe_unserialize($serialized, [
Row::class,
DataTableSummaryRow::class,
\Piwik_DataTable_SerializedRow::class
]);
if ($rows === false) {
throw new Exception("The unserialization has failed!");
}
return $rows;
}
/**
* Adds a set of rows from a serialized DataTable string.
*
* See {@link serialize()}.
*
* _Note: This function will successfully load DataTables serialized by Piwik 1.X._
*
* @param string $serialized A string with the format of a string in the array returned by
* {@link serialize()}.
* @throws Exception if `$serialized` is invalid.
*/
public function addRowsFromSerializedArray($serialized)
{
$rows = $this->unserializeRows($serialized);
if (array_key_exists(self::ID_SUMMARY_ROW, $rows)) {
if (is_array($rows[self::ID_SUMMARY_ROW])) {
$this->summaryRow = new Row($rows[self::ID_SUMMARY_ROW]);
$this->summaryRow->setIsSummaryRow();
} elseif (isset($rows[self::ID_SUMMARY_ROW]->c)) {
$this->summaryRow = new Row($rows[self::ID_SUMMARY_ROW]->c); // Pre Piwik 2.13
$this->summaryRow->setIsSummaryRow();
}
unset($rows[self::ID_SUMMARY_ROW]);
}
if (array_key_exists(self::ID_ARCHIVED_METADATA_ROW, $rows)) {
$metadata = $rows[self::ID_ARCHIVED_METADATA_ROW][Row::COLUMNS];
unset($metadata['label']);
$this->setAllTableMetadata($metadata);
unset($rows[self::ID_ARCHIVED_METADATA_ROW]);
}
foreach ($rows as $id => $row) {
if (isset($row->c)) {
$this->addRow(new Row($row->c)); // Pre Piwik 2.13
} else {
$this->addRow(new Row($row));
}
}
}
/**
* Adds multiple rows from an array.
*
* You can add row metadata with this method.
*
* @param array $array Array with the following structure
*
* array(
* // row1
* array(
* Row::COLUMNS => array( col1_name => value1, col2_name => value2, ...),
* Row::METADATA => array( metadata1_name => value1, ...), // see Row
* ),
* // row2
* array( ... ),
* )
*/
public function addRowsFromArray($array)
{
foreach ($array as $id => $row) {
if (is_array($row)) {
$row = new Row($row);
}
if ($id == self::ID_SUMMARY_ROW) {
$this->summaryRow = $row;
$this->summaryRow->setIsSummaryRow();
} else {
$this->addRow($row);
}
}
}
/**
* Adds multiple rows from an array containing arrays of column values.
*
* Row metadata cannot be added with this method.
*
* @param array $array Array with the following structure:
*
* array(
* array( col1_name => valueA, col2_name => valueC, ...),
* array( col1_name => valueB, col2_name => valueD, ...),
* )
* @throws Exception if `$array` is in an incorrect format.
*/
public function addRowsFromSimpleArray($array)
{
if (count($array) === 0) {
return;
}
$exceptionText = " Data structure returned is not convertible in the requested format." .
" Try to call this method with the parameters '&format=original&serialize=1'" .
"; you will get the original php data structure serialized." .
" The data structure looks like this: \n \$data = %s; ";
// first pass to see if the array has the structure
// array(col1_name => val1, col2_name => val2, etc.)
// with val* that are never arrays (only strings/numbers/bool/etc.)
// if we detect such a "simple" data structure we convert it to a row with the correct columns' names
$thisIsNotThatSimple = false;
foreach ($array as $columnValue) {
if (is_array($columnValue) || is_object($columnValue)) {
$thisIsNotThatSimple = true;
break;
}
}
if ($thisIsNotThatSimple === false) {
// case when the array is indexed by the default numeric index
if (array_keys($array) === array_keys(array_fill(0, count($array), true))) {
foreach ($array as $row) {
$this->addRow(new Row(array(Row::COLUMNS => array($row))));
}
} else {
$this->addRow(new Row(array(Row::COLUMNS => $array)));
}
// we have converted our simple array to one single row
// => we exit the method as the job is now finished
return;
}
foreach ($array as $key => $row) {
// stuff that looks like a line
if (is_array($row)) {
/**
* We make sure we can convert this PHP array without losing information.
* We are able to convert only simple php array (no strings keys, no sub arrays, etc.)
*
*/
// if the key is a string it means that some information was contained in this key.
// it cannot be lost during the conversion. Because we are not able to handle properly
// this key, we throw an explicit exception.
if (is_string($key)) {
// we define an exception we may throw if at one point we notice that we cannot handle the data structure
throw new Exception(sprintf($exceptionText, var_export($array, true)));
}
// if any of the sub elements of row is an array we cannot handle this data structure...
foreach ($row as $subRow) {
if (is_array($subRow)) {
throw new Exception(sprintf($exceptionText, var_export($array, true)));
}
}
$row = new Row(array(Row::COLUMNS => $row));
} // other (string, numbers...) => we build a line from this value
else {
$row = new Row(array(Row::COLUMNS => array($key => $row)));
}
$this->addRow($row);
}
}
/**
* Rewrites the input `$array`
*
* array (
* LABEL => array(col1 => X, col2 => Y),
* LABEL2 => array(col1 => X, col2 => Y),
* )
*
* to a DataTable with rows that look like:
*
* array (
* array( Row::COLUMNS => array('label' => LABEL, col1 => X, col2 => Y)),
* array( Row::COLUMNS => array('label' => LABEL2, col1 => X, col2 => Y)),
* )
*
* Will also convert arrays like:
*
* array (
* LABEL => X,
* LABEL2 => Y,
* )
*
* to:
*
* array (
* array( Row::COLUMNS => array('label' => LABEL, 'value' => X)),
* array( Row::COLUMNS => array('label' => LABEL2, 'value' => Y)),
* )
*
* @param array $array Indexed array, two formats supported, see above.
* @param array|null $subtablePerLabel An array mapping label values with DataTable instances to associate as a subtable.
* @return \Piwik\DataTable
*/
public static function makeFromIndexedArray($array, $subtablePerLabel = null)
{
$table = new DataTable();
foreach ($array as $label => $row) {
$cleanRow = array();
// Support the case of an $array of single values
if (!is_array($row)) {
$row = array('value' => $row);
}
// Put the 'label' column first
$cleanRow[Row::COLUMNS] = array('label' => $label) + $row;
// Assign subtable if specified
if (isset($subtablePerLabel[$label])) {
$cleanRow[Row::DATATABLE_ASSOCIATED] = $subtablePerLabel[$label];
}
if ($label === RankingQuery::LABEL_SUMMARY_ROW) {
$table->addSummaryRow(new Row($cleanRow));
} else {
$table->addRow(new Row($cleanRow));
}
}
return $table;
}
/**
* Sets the maximum depth level to at least a certain value. If the current value is
* greater than `$atLeastLevel`, the maximum nesting level is not changed.
*
* The maximum depth level determines the maximum number of subtable levels in the
* DataTable tree. For example, if it is set to `2`, this DataTable is allowed to
* have subtables, but the subtables are not.
*
* @param int $atLeastLevel
*/
public static function setMaximumDepthLevelAllowedAtLeast($atLeastLevel)
{
self::$maximumDepthLevelAllowed = max($atLeastLevel, self::$maximumDepthLevelAllowed);
if (self::$maximumDepthLevelAllowed < 1) {
self::$maximumDepthLevelAllowed = 1;
}
}
/**
* Returns metadata by name.
*
* @param string $name The metadata name.
* @return mixed|false The metadata value or `false` if it cannot be found.
*/
public function getMetadata($name)
{
if (!isset($this->metadata[$name])) {
return false;
}
return $this->metadata[$name];
}
/**
* Sets a metadata value by name.
*
* @param string $name The metadata name.
* @param mixed $value
*/
public function setMetadata($name, $value)
{
$this->metadata[$name] = $value;
}
/**
* Deletes a metadata property by name.
*
* @param bool|string $name The metadata name (omit to delete all metadata)
* @return bool True if the requested metadata was deleted
*/
public function deleteMetadata($name = false) : bool
{
if ($name === false) {
$this->metadata = [];
return true;
}
if (!isset($this->metadata[$name])) {
return false;
}
unset($this->metadata[$name]);
return true;
}
/**
* Returns all table metadata.
*
* @return array
*/
public function getAllTableMetadata()
{
return $this->metadata;
}
/**
* Sets several metadata values by name.
*
* @param array $values Array mapping metadata names with metadata values.
*/
public function setMetadataValues($values)
{
foreach ($values as $name => $value) {
$this->metadata[$name] = $value;
}
}
/**
* Sets metadata, erasing existing values.
*
* @param array $values Array mapping metadata names with metadata values.
*/
public function setAllTableMetadata($metadata)
{
$this->metadata = $metadata;
}
/**
* Sets the maximum number of rows allowed in this datatable (including the summary
* row). If adding more then the allowed number of rows is attempted, the extra
* rows are summed to the summary row.
*
* @param int $maximumAllowedRows If `0`, the maximum number of rows is unset.
*/
public function setMaximumAllowedRows($maximumAllowedRows)
{
$this->maximumAllowedRows = $maximumAllowedRows;
}
/**
* Traverses a DataTable tree using an array of labels and returns the row
* it finds or `false` if it cannot find one. The number of path segments that
* were successfully walked is also returned.
*
* If `$missingRowColumns` is supplied, the specified path is created. When
* a subtable is encountered w/o the required label, a new row is created
* with the label, and a new subtable is added to the row.
*
* Read [http://en.wikipedia.org/wiki/Tree_(data_structure)#Traversal_methods](http://en.wikipedia.org/wiki/Tree_(data_structure)#Traversal_methods)
* for more information about tree walking.
*
* @param array $path The path to walk. An array of label values. The first element
* refers to a row in this DataTable, the second in a subtable of
* the first row, the third a subtable of the second row, etc.
* @param array|bool $missingRowColumns The default columns to use when creating new rows.
* If this parameter is supplied, new rows will be
* created for path labels that cannot be found.
* @param int $maxSubtableRows The maximum number of allowed rows in new subtables. New
* subtables are only created if `$missingRowColumns` is provided.
* @return array First element is the found row or `false`. Second element is
* the number of path segments walked. If a row is found, this
* will be == to `count($path)`. Otherwise, it will be the index
* of the path segment that we could not find.
*/
public function walkPath($path, $missingRowColumns = false, $maxSubtableRows = 0)
{
$pathLength = count($path);
$table = $this;
$next = false;
for ($i = 0; $i < $pathLength; ++$i) {
$segment = $path[$i];
$next = $table->getRowFromLabel($segment);
if ($next === false) {
// if there is no table to advance to, and we're not adding missing rows, return false
if ($missingRowColumns === false) {
return array(false, $i);
} else {
// if we're adding missing rows, add a new row
$row = new DataTableSummaryRow();
$row->setColumns(array('label' => $segment) + $missingRowColumns);
$next = $table->addRow($row);
if ($next !== $row) {
// if the row wasn't added, the table is full
// Summary row, has no metadata
$next->deleteMetadata();
return array($next, $i);
}
}
}
$table = $next->getSubtable();
if ($table === false) {
// if the row has no table (and thus no child rows), and we're not adding
// missing rows, return false
if ($missingRowColumns === false) {
return array(false, $i);
} elseif ($i != $pathLength - 1) {
// create subtable if missing, but only if not on the last segment
$table = new DataTable();
$table->setMaximumAllowedRows($maxSubtableRows);
$table->metadata[self::COLUMN_AGGREGATION_OPS_METADATA_NAME]
= $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME);
$next->setSubtable($table);
// Summary row, has no metadata
$next->deleteMetadata();
}
}
}
return array($next, $i);
}
/**
* Returns a new DataTable in which the rows of this table are replaced with the aggregatated rows of all its subtables.
*
* @param string|bool $labelColumn If supplied the label of the parent row will be added to
* a new column in each subtable row.
*
* If set to, `'label'` each subtable row's label will be prepended
* w/ the parent row's label. So `'child_label'` becomes
* `'parent_label - child_label'`.
* @param bool $useMetadataColumn If true and if `$labelColumn` is supplied, the parent row's
* label will be added as metadata and not a new column.
* @return \Piwik\DataTable
*/
public function mergeSubtables($labelColumn = false, $useMetadataColumn = false)
{
$result = new DataTable();
$result->setAllTableMetadata($this->getAllTableMetadata());
foreach ($this->getRowsWithoutSummaryRow() as $row) {
$subtable = $row->getSubtable();
if ($subtable !== false) {
$parentLabel = $row->getColumn('label');
// add a copy of each subtable row to the new datatable
foreach ($subtable->getRows() as $id => $subRow) {
$copy = clone $subRow;
// if the summary row, add it to the existing summary row (or add a new one)
if ($id == self::ID_SUMMARY_ROW) {
$existing = $result->getRowFromId(self::ID_SUMMARY_ROW);
if ($existing === false) {
$result->addSummaryRow($copy);
} else {
$existing->sumRow($copy, $copyMeta = true, $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME));
}
} else {
if ($labelColumn !== false) {
// if we're modifying the subtable's rows' label column, then we make
// sure to prepend the existing label w/ the parent row's label. otherwise
// we're just adding the parent row's label as a new column/metadata.
$newLabel = $parentLabel;
if ($labelColumn == 'label') {
$newLabel .= ' - ' . $copy->getColumn('label');
}
// modify the child row's label or add new column/metadata
if ($useMetadataColumn) {
$copy->setMetadata($labelColumn, $newLabel);
} else {
$copy->setColumn($labelColumn, $newLabel);
}
}
$result->addRow($copy);
}
}
}
}
return $result;
}
/**
* Returns a new DataTable created with data from a 'simple' array.
*
* See {@link addRowsFromSimpleArray()}.
*
* @param array $array
* @return \Piwik\DataTable
*/
public static function makeFromSimpleArray($array)
{
$dataTable = new DataTable();
$dataTable->addRowsFromSimpleArray($array);
return $dataTable;
}
/**
* Creates a new DataTable instance from a serialized DataTable string.
*
* See {@link getSerialized()} and {@link addRowsFromSerializedArray()}
* for more information on DataTable serialization.
*
* @param string $data
* @return \Piwik\DataTable
*/
public static function fromSerializedArray($data)
{
$result = new DataTable();
$result->addRowsFromSerializedArray($data);
return $result;
}
/**
* Aggregates the $row columns to this table.
*
* $row must have a column "label". The $row will be summed to this table's row with the same label.
*
* @param $row
* @params null|array $columnAggregationOps
* @throws \Exception
*/
protected function aggregateRowWithLabel(Row $row, $columnAggregationOps)
{
$labelToLookFor = $row->getColumn('label');
if ($labelToLookFor === false) {
$message = sprintf("Label column not found in the table to add in addDataTable(). Row: %s",
var_export($row->getColumns(), 1)
);
throw new Exception($message);
}
$rowFound = $this->getRowFromLabel($labelToLookFor);
// if we find the summary row in the other table, ignore it, since we're aggregating normal rows in this method.
// the summary row is aggregated explicitly after this method is called.
if (!empty($rowFound)
&& $rowFound->isSummaryRow()
) {
$rowFound = false;
}
$this->aggregateRow($rowFound, $row, $columnAggregationOps, $isSummaryRow = false);
}
private function aggregateRow($thisRow, Row $otherRow, $columnAggregationOps, $isSummaryRow)
{
if (empty($thisRow)) {
$thisRow = new Row();
$otherRowLabel = $otherRow->getColumn('label');
if ($otherRowLabel !== false) {
$thisRow->addColumn('label', $otherRowLabel);
}
$thisRow->setAllMetadata($otherRow->getMetadata());
if ($isSummaryRow) {
$this->addSummaryRow($thisRow);
} else {
$this->addRow($thisRow);
}
}
$thisRow->sumRow($otherRow, $copyMeta = true, $columnAggregationOps);
// if the row to add has a subtable whereas the current row doesn't
// we simply add it (cloning the subtable)
// if the row has the subtable already
// then we have to recursively sum the subtables
$subTable = $otherRow->getSubtable();
if ($subTable) {
$subTable->metadata[self::COLUMN_AGGREGATION_OPS_METADATA_NAME] = $columnAggregationOps;
$thisRow->sumSubtable($subTable);
}
}
/**
* @param $row
*/
protected function aggregateRowFromSimpleTable($row)
{
if ($row === false) {
return;
}
$thisRow = $this->getFirstRow();
if ($thisRow === false) {
$thisRow = new Row;
$this->addRow($thisRow);
}
$thisRow->sumRow($row, $copyMeta = true, $this->getMetadata(self::COLUMN_AGGREGATION_OPS_METADATA_NAME));
}
/**
* Unsets all queued filters.
*/
public function clearQueuedFilters()
{
$this->queuedFilters = array();
}
public function getQueuedFilters()
{
return $this->queuedFilters;
}
/**
* @return \ArrayIterator|Row[]
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->getRows());
}
public function offsetExists($offset): bool
{
$row = $this->getRowFromId($offset);
return false !== $row;
}
public function offsetGet($offset): Row
{
return $this->getRowFromId($offset);
}
public function offsetSet($offset, $value): void
{
$this->rows[$offset] = $value;
}
public function offsetUnset($offset): void
{
$this->deleteRow($offset);
}
}