praiadeseselle/wire/core/PagesParents.php
2022-03-08 15:55:41 +01:00

757 lines
No EOL
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php namespace ProcessWire;
/**
* ProcessWire Pages Parents
*
* Implements page parents helper methods for the $pages API variable
* and manages the pages_parents DB table.
*
* This is not intended for the public API and instead used internally by
* the $pages classes, but available at $pages->parents()->methodName() if
* you want to use anything here.
*
* ~~~~~~
* // Rebuild the entire pages_parents table
* $numRows = $pages->parents()->rebuildAll();
* ~~~~~~
*
* ProcessWire 3.x, Copyright 2020 by Ryan Cramer
* https://processwire.com
*
* @since 3.0.156
*
*/
class PagesParents extends Wire {
/**
* @var Pages
*
*/
protected $pages;
/**
* Debug mode for pages class
*
* @var bool
*
*/
protected $debug = false;
/**
* Construct
*
* @param Pages $pages
*
*/
public function __construct(Pages $pages) {
$this->pages = $pages;
$this->debug = $pages->debug();
}
/**
* Get parents for given Page and/or specific columns from them
*
* - Return value has no exclusions for access control or status.
* - Return value order is closest parent to furthest.
* - This attempts to return all pages in 1 optimized query, making it potentially faster
* than other methods.
* - When using `column` or `columns` options, specify only one or the other, and include
* column(s) native to pages DB table only, with 1 exception—you may specify `path` as
* a column, which will return the generated page path(s).
*
* @param Page|int $page Page or page ID
* @param array $options
* - `column` (string): Just return array of values from this column (use `columns` option when you need multiple columns). (default='')
* - `columns` (array): Return array of associative arrays containing these columns for each page (not to be combined with `column` option)
* - `indexBy` (string): Return array indexed by column `id` or `parent_id`, applies only if given column/columns (default='')
* - `includePage` (bool): Also include data for given $page in return value? (default=false)
* - `noHome` (bool): Omit homepage from return value (default=false)
* - `joinQty` (int): Number of parents to join in query before going recursive, for internal use (default=4).
* @return array|PageArray If given column/columns returns an array, otherwise returns a PageArray
* @since 3.0.156
*
*/
public function getParents($page, array $options = array()) {
$defaults = array(
'column' => '', // get 1 column
'columns' => array(), // get array multiple columns
'joinQty' => 4,
'indexBy' => '', // id or parent_id or blank
'includePage' => false,
'noHome' => false,
);
$options = array_merge($defaults, $options);
$values = array();
$columns = array();
$joinQty = (int) $options['joinQty'];
$joins = array();
$selects = array('pages.parent_id AS parent_id');
$indexBy = $options['indexBy'] ? $options['indexBy'] : 'id';
$lastTable = 'pages';
$database = $this->wire('database');
$getOneCol = empty($options['columns']) ? $options['column'] : '';
$getPath = false;
$getPages = empty($options['columns']) && empty($options['column']);
$id = (int) "$page";
$lastParentID = $id;
$blankReturn = $getPages ? $this->pages->newPageArray() : array();
if($id <= 0 || (!$options['includePage'] && $id <= 1)) return $blankReturn;
if($indexBy) {
$options['columns'][] = $indexBy;
}
if($getPages) {
$options['columns'][] = 'id';
$options['columns'][] = 'templates_id';
} else if($indexBy != 'id' && $indexBy != 'parent_id') {
throw new WireException('indexBy option must be "id" or "parent_id" or blank');
} else if($getOneCol) {
$options['columns'][] = $getOneCol;
}
foreach($options['columns'] as $col) {
if($col === 'parent_id') continue; // already a part of our query
$column = $database->escapeCol($col);
if($col === $column) $columns[$column] = $column;
}
if(!$getPages && isset($columns['path'])) {
$getPath = true;
unset($columns['path']);
$columns['name'] = 'name';
$columns['id'] = 'id';
}
for($n = 0; $n <= $joinQty; $n++) {
$key = $n ? $n : '';
$table = "pages$key";
$selects[] = "$table.parent_id AS parent_id$key";
foreach($columns as $col) {
$selects[] = "$table.$col AS $col$key";
}
if($n) $joins[] = "LEFT JOIN pages AS $table ON $lastTable.parent_id=$table.id";
$lastTable = $table;
}
$sql =
'SELECT ' . implode(', ', $selects) . ' ' .
'FROM pages ' . implode(' ', $joins) . ' ' .
'WHERE pages.id=:id';
$query = $database->prepare($sql);
$query->bindValue(':id', $id, \PDO::PARAM_INT);
$query->execute();
$row = $query->fetch(\PDO::FETCH_ASSOC);
$query->closeCursor();
if(empty($row)) return $blankReturn;
$parentID = $id;
for($n = 0; $n <= $joinQty; $n++) {
$key = $n ? $n : '';
$lastParentID = $parentID;
$parentID = (int) $row["parent_id$key"];
if(!$n && !$options['includePage']) {
// skip first
} else if(count($columns)) {
$value = array("id" => $lastParentID, "parent_id" => $parentID);
foreach($columns as $col) {
$value[$col] = $row["$col$key"];
}
$values[] = $value;
} else {
$values[] = $parentID;
}
if($parentID < 1 || ($parentID < 2 && $options['noHome'])) break;
}
if($lastParentID > 1 && count($values) >= $joinQty) {
// more parents to go, get rest recursively
$o = $options;
$o['columns'] = $columns;
foreach($this->getParents($lastParentID, $o) as $key => $value) {
$values[] = $value;
}
}
if(!count($values)) return $blankReturn;
if($getPath) {
$names = array();
foreach(array_reverse($values, true) as $key => $value) {
$name = $value['id'] > 1 ? $value['name'] : '';
if($name) $names[] = $name;
$values[$key]['path'] = '/' . implode('/', $names) . '/';
}
}
if($getPages) {
$values = $this->pages->loader()->getById($values);
} else if($getOneCol) {
$a = array();
foreach($values as $key => $value) {
$index = empty($options['indexBy']) ? $key : $value[$indexBy];
$a[$index] = $value[$getOneCol];
}
$values = $a;
} else {
$a = array();
foreach($values as $key => $value) {
$index = empty($options['indexBy']) ? $key : $value[$indexBy];
$a[$index] = array();
foreach($options['columns'] as $col) {
if(isset($value[$col])) $a[$index][$col] = $value[$col];
}
}
$values = $a;
}
return $values;
}
/**
* Find all pages that have children
*
* @param array $options
* @return array|PageArray
* @since 3.0.156
*
*/
public function findParents(array $options = array()) {
static $calls = 0;
$defaults = array(
'minChildren' => 1, // min children to match
'maxChildren' => 0, // max children to match
'numChildren' => 0, // exact children to match
'parent' => 0, // start from this parent, or negative int for minimum parent ID
'recursive' => true, // recursively find all (when combined with parent option)
'start' => 0,
'limit' => 0,
'column' => '', // get this 1 column (native columns only)
'columns' => array(), // get these columns (native columns only)
'sortByID' => false,
'indexByID' => false, // return value should be indexed by page ID?
'useIndexTable' => true, // allow use of pages_parents index table? (much faster)
'debug' => false, // debug mode makes it also return a debug index w/additional info
'_level' => 0, // recursion level (internal)
);
$options = array_merge($defaults, $options);
$parentID = isset($options['parent']) ? (int) "$options[parent]" : 0;
$database = $this->wire('database'); /** @var WireDatabasePDO $database */
$sql = array();
$bind = array();
$column = $options['column'];
$columns = $options['columns'];
if(empty($columns) && strlen($column)) $columns = array($column);
$getPages = empty($columns) && !$options['_level'] && !$options['debug'];
$forceRecursive = $options['recursive'] && !$options['useIndexTable'];
$indexByID = $getPages || $forceRecursive ? true : $options['indexByID'];
$debug = $options['debug'];
$timer = $debug && !$options['_level'] ? Debug::timer() : false;
if($debug) {
$calls++;
if(!in_array('parent_id', $columns)) $columns[] = 'parent_id';
if(!in_array('name', $columns)) $columns[] = 'name';
}
$sql['select'] = "SELECT pages.id, COUNT(children.id) AS numChildren";
if($getPages) $sql['select'] .= ", pages.templates_id";
foreach($columns as $key => $col) {
if($col === 'id' || $col === 'numChildren') continue; // already have it
$col = $database->escapeCol($col);
if($col !== $columns[$key]) continue;
$sql['select'] .= ", pages.$col";
}
$sql['from'] = "FROM pages";
$sql['join'] = "JOIN pages AS children ON children.parent_id=pages.id";
if($parentID) {
$op = '=';
if($parentID < 0) {
// minimum parent ID
$op = '>=';
$parentID = abs($parentID);
}
$bind[':parent_id'] = $parentID;
if($options['recursive'] && $options['useIndexTable']) {
$sql['join2'] = "JOIN pages_parents ON pages_parents.parents_id$op:parent_id AND pages_parents.pages_id=pages.id";
} else {
$sql['where'] = "WHERE pages.parent_id$op:parent_id";
}
}
$sql['group'] = "GROUP BY pages.id";
foreach(array('minChildren' => '>=', 'maxChildren' => '<=', 'numChildren' => '=') as $key => $op) {
$value = (int) $options[$key];
if(empty($value)) continue;
if($key === 'minChildren' && $value === 1) continue; // already implied
$sql['having'] = (empty($sql['having']) ? "HAVING " : "$sql[having] AND ") . "COUNT(children.id)$op:$key";
$bind[":$key"] = $value;
}
if($options['sortByID']) {
$sql['order'] = "ORDER BY pages.id";
}
if($options['limit']) {
$start = (int) $options['start'];
$limit = (int) $options['limit'];
$sql['limit'] = "LIMIT $start,$limit";
}
$query = $database->prepare(implode(' ', $sql));
foreach($bind as $key => $value) {
$query->bindValue($key, $value, \PDO::PARAM_INT);
}
$database->execute($query);
$values = array();
/** @noinspection PhpAssignmentInConditionInspection */
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$id = (int) $row['id'];
if($column) {
// single column
$value = isset($row[$column]) ? $row[$column] : null;
} else if(!empty($columns)) {
// multiple columns
$value = array();
foreach($columns as $col) {
if(isset($row[$col])) $value[$col] = $row[$col];
}
} else if($getPages) {
// get pages
$value = array(
'id' => $id,
'templates_id' => (int) $row['templates_id'],
'numChildren' => (int) $row['numChildren']
);
} else {
// undefined
$value = $row;
}
if($indexByID) {
$values[$id] = $value;
} else {
$values[] = $value;
}
}
$query->closeCursor();
if($forceRecursive) {
foreach(array_keys($values) as $pid) {
$options['parent'] = $pid;
$options['_level']++;
foreach($this->findParents($options) as $id => $value) {
$values[$id] = $value;
}
$options['_level']--;
}
if($options['sortByID'] && !$options['_level']) {
ksort($values);
}
}
if($getPages) {
$pageArray = $this->pages->loader()->getById($values, array(
// since we already have numChildren, we don't need getByID to spend time finding it
'getNumChildren' => false,
));
foreach($pageArray as $page) {
$page->setQuietly('numChildren', $values[$page->id]['numChildren']);
}
$values = $pageArray;
} else {
if($debug && !$options['_level']) {
$hash = md5(print_r($values, true));
$debugInfo = array(
'calls' => $calls,
'timer' => Debug::timer($timer),
'hash' => $hash,
);
$calls = 0;
// add in breadcrumb debug property
foreach($values as $id => $value) {
if(!isset($value['breadcrumb'])) $value['breadcrumb'] = array();
$pid = $value['parent_id'];
while(isset($values[$pid])) {
$name = $values[$pid]['name'];
$values[$id]['breadcrumb'][] = $name;
$pid = $values[$pid]['parent_id'];
}
}
} else {
$debugInfo = false;
}
if($indexByID && !$options['indexByID']) {
$values = array_values($values); // convert to regular PHP array
}
if($debugInfo) $values['debug'] = $debugInfo;
}
return $values;
}
/**
* Find IDs of all pages that are parents for other pages, optionally within a parent
*
* Does not rely on pages_parents index table, so can be used for rebuilding it.
*
* Faster than findParents() in cases where the pages_parents table cannot be used
* and there are potentially hundreds/thousands of parents to find. However, it does
* use more memory than findParents() even if it can be potentially a lot faster.
*
* ~~~~~
* // the following two calls should produce identical results (excluding order)
* // if they dont, the index may need to be rebuilt
* $pages->parents()->findParentIDs($id);
* $pages->parents()->findParents([
* 'parent' => $id,
* 'indexByID' => true,
* 'column' => 'parent_id'
* ]);
* ~~~~~
*
* @param null|Page|int $fromParent Specify parent to limit results within, or negative int for minimum parent_id,
* for instance a value of -2 would exclude homepage and items with homepage (id=1) as their parent.
* @return array Returns array in format [ id => parent_id ]
* @since 3.0.156
*
*/
public function findParentIDs($fromParent = null) {
static $parents = null;
static $level = 0;
$fromParent = (int) "$fromParent";
$minParentID = $fromParent < 0 ? abs($fromParent) : 0;
if($minParentID) $fromParent = 0;
if($parents == null) {
$parents = array();
$sql = "
SELECT parents.id, parents.parent_id
FROM pages
JOIN pages AS parents on pages.parent_id=parents.id AND parents.parent_id>=:id
GROUP BY pages.parent_id
";
$query = $this->database->prepare(trim($sql));
$query->bindValue(':id', $minParentID, \PDO::PARAM_INT);
$query->execute();
while($row = $query->fetch(\PDO::FETCH_NUM)) {
list($pages_id, $parents_id) = $row;
$parents[(int) $pages_id] = (int) $parents_id;
}
$query->closeCursor();
}
if($fromParent) {
// filter out parents not descendents of $fromParent
$a = array();
$level++;
foreach(array_keys($parents, $fromParent) as $id) {
$a[$id] = $fromParent;
$a += $this->findParentIDs($id);
}
$level--;
if(!$level) $parents = null;
} else {
$a = $parents;
$parents = null;
}
return $a;
}
/**
* Check if saved page needs any pages_parents updates and perform them when applicable
*
* @param Page $page
* @return int Number of rows updated
*
*/
public function save(Page $page) {
$numRows = 0;
// homepage not maintained in pages_parents table
// pages being cloned are not maintained till clone operation finishes
if($page->id < 2 || $page->_cloning || !$page->parent) return 0;
// first check if page parents need any updates
if($page->isNew()) {
// newly added page
if($page->parent->numChildren === 1) {
// first time parent gets added to pages_parents
$numRows += $this->rebuild($page->parent);
}
} else if($page->parentPrevious && $page->parentPrevious->id != $page->parent->id) {
// existing page with parent changed
$this->rebuildAll();
/*
if($page->parentPrevious->numChildren === 0) {
// parent no longer has children and doesnt need entry
$numRows += $this->delete($page->parentPrevious);
}
if($page->parent->numChildren === 1) {
// first time parent gets added to pages_parents
$numRows += $this->rebuild($page->parent);
}
*/
}
return $numRows;
}
/**
* Rebuild pages_parents table for given page
*
* This descends into both parents, and children that are themselves parents,
* and this method already calls the rebuildBranch() method when appropriate.
*
* @param Page $page
* @return int
* @since 3.0.156
*
*/
public function rebuild(Page $page) {
$pages_id = (int) $page->id;
$database = $this->wire('database'); /** @var WireDatabasePDO $database */
$inserts = array();
$rowCount = 0;
if(!$page->isNew()) $this->clear($page);
// if page has no children it does not need pages_parents entries
if(!$page->numChildren) return 0;
// identify parents to store for $page
foreach($page->parents() as $parent) {
$parents_id = (int) $parent->id;
if($parents_id > 1) $inserts[] = "$pages_id,$parents_id";
}
if(count($inserts)) {
// if parents found to insert, rebuild parents of $page
$inserts = implode('),(', $inserts);
$query = $database->prepare("INSERT INTO pages_parents (pages_id, parents_id) VALUES($inserts)");
$query->execute();
$rowCount += $query->rowCount();
}
// rebuild parents within pages children
$rowCount += $this->rebuildBranch($page->id);
return $rowCount;
}
/**
* Rebuild pages_parents branch starting at $fromParent and into all descendents
*
* @param Page|int $fromParent From parent Page or ID
* @return int Number of rows inserted
*
*/
public function rebuildBranch($fromParent) {
return $this->rebuildAll($fromParent);
}
/**
* Rebuild pages_parents table entirely or from branch starting with a parent branch
*
* @param int|Page $fromParent Specify parent ID or page to rebuild from that parent, or omit to rebuild all
* @return int Number of rows inserted
* @since 3.0.156
*
*/
public function rebuildAll($fromParent = null) {
$database = $this->wire('database'); /** @var WireDatabasePDO $database */
$inserts = array();
$parents = $this->findParentIDs($fromParent ? $fromParent : -2); // find parents within children
$rowCount = 0;
foreach($parents as $pages_id => $parents_id) {
if(isset($this->excludeIDs[$parents_id])) continue;
$inserts[] = "$pages_id,$parents_id";
while(isset($parents[$parents_id])) {
$parents_id = $parents[$parents_id];
$inserts[] = "$pages_id,$parents_id";
}
}
if(count($parents)) {
$where = $fromParent ? 'WHERE pages_id IN(' . implode(',', array_keys($parents)) . ')' : '';
$sql = "DELETE FROM pages_parents $where";
$database->exec($sql);
}
if(count($inserts)) {
$inserts = array_unique($inserts);
$inserts = implode('),(', $inserts);
$query = $database->prepare("INSERT INTO pages_parents (pages_id, parents_id) VALUES($inserts)");
$query->execute();
$rowCount = $query->rowCount();
}
return $rowCount;
}
/**
* Clear page from pages_parents index
*
* @param Page|int $page
* @return int
* @since 3.0.156
*
*/
public function clear($page) {
$pages_id = (int) "$page";
$database = $this->wire('database'); /** @var WireDatabasePDO $database */
$query = $database->prepare("DELETE FROM pages_parents WHERE pages_id=:id");
$query->bindValue(':id', $pages_id, \PDO::PARAM_INT);
$query->execute();
$cnt = $query->rowCount();
$query->closeCursor();
return $cnt;
}
/**
* Delete page entirely from pages_parents table (both as page and parent)
*
* @param Page|int $page
* @return int
* @since 3.0.156
*
*/
public function delete($page) {
$pages_id = (int) "$page";
$database = $this->wire('database'); /** @var WireDatabasePDO $database */
$sql = "DELETE FROM pages_parents WHERE pages_id=:pages_id OR parents_id=:parents_id";
$query = $database->prepare($sql);
$query->bindValue(':pages_id', $pages_id, \PDO::PARAM_INT);
$query->bindValue(':parents_id', $pages_id, \PDO::PARAM_INT);
$query->execute();
$cnt = $query->rowCount();
$query->closeCursor();
return $cnt;
}
/**
* Tests a page and returns verbose details about what was found
*
* For debugging/development purposes to make sure pages_parents
* is working as intended.
*
* #pw-internal
*
* @param Page $page
* @return array
*
*/
public function pageTests(Page $page) {
$id = (int) "$page";
$path = $page->path;
$database = $this->wire('database'); /** @var WireDatabasePDO $database */
$tests = array(
'query-for-parents-of-page' => array(
'notes' => "Query DB for parents of $path. Result will exclude homepage.",
'query' => "SELECT parents_id FROM pages_parents WHERE pages_id=$id",
'timer' => '',
'count' => 0,
'pages' => array(),
'type' => 'database.query',
),
'query-for-pages-having-parent' => array(
'notes' => "Query DB for pages having parent $path. Result will exclude pages that are not themselves parents.",
'query' => "SELECT pages_id FROM pages_parents WHERE parents_id=$id",
'timer' => '',
'count' => 0,
'pages' => array(),
'type' => 'database.query',
),
'pages-find-descendents' => array(
'notes' => "Use \$pages->find() with selector to find all descendents of $path with no exclusions.",
'query' => "has_parent=$id, sort=parent_id, sort=id, include=all",
'timer' => '',
'count' => 0,
'pages' => array(),
'type' => 'pages.find',
),
'page-children-descendents' => array(
'notes' => "Use recursive \$page->children() to manually reproduce result from previous test (the two should match)",
'query' => "include=all, sort=id",
'timer' => '',
'count' => 0,
'pages' => array(),
'type' => 'descendents',
),
);
foreach($tests as $key => $test) {
$timer = Debug::timer();
if($test['type'] === 'database.query') {
$query = $database->prepare($test['query']);
$query->execute();
$test['count'] = $query->rowCount();
while($value = $query->fetchColumn()) {
$test['pages'][] = "$value: " . $this->pages->getPath($value);
}
$query->closeCursor();
} else if($test['type'] === 'pages.find') {
$this->pages->uncacheAll();
$items = $this->pages->find($test['query']);
$test['count'] = $items->count();
$test['pages'] = $items->explode("{id}: {path}");
} else if($test['type'] === 'descendents') {
$this->pages->uncacheAll();
$items = $this->descendents($page, $test['query']);
$test['count'] = $items->count();
$test['pages'] = $items->explode("{id}: {path}");
}
$test['timer'] = Debug::timer($timer);
$tests[$key] = $test;
}
return $tests;
}
/**
* Find descendents of $page by going recursive rather than using pages_parents table (for testing)
*
* @param Page $page
* @param string $selector
* @return PageArray
*
*/
protected function descendents(Page $page, $selector = 'include=all') {
$children = new PageArray();
foreach($page->children($selector) as $child) {
$children->add($child);
if(!$child->numChildren) continue;
foreach($this->descendents($child, $selector) as $item) {
$children->add($item);
}
}
return $children;
}
}