artabro/wire/core/PagesLoader.php
2024-08-27 11:35:37 +02:00

2157 lines
74 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 Loader
*
* Implements page finding/loading methods of the $pages API variable
*
* ProcessWire 3.x, Copyright 2022 by Ryan Cramer
* https://processwire.com
*
*/
class PagesLoader extends Wire {
/**
* Controls the outputFormatting state for pages that are loaded
*
*/
protected $outputFormatting = false;
/**
* Autojoin allowed?
*
* @var bool
*
*/
protected $autojoin = true;
/**
* @var Pages
*
*/
protected $pages;
/**
* Columns native to pages table
*
* @var array
*
*/
protected $nativeColumns = array();
/**
* Total number of pages loaded by getById()
*
* @var int
*
*/
protected $totalPagesLoaded = 0;
/**
* Last used instance of PageFinder
*
* @var PageFinder|null
*
*/
protected $lastPageFinder = null;
/**
* Debug mode for pages class
*
* @var bool
*
*/
protected $debug = false;
/**
* Are we currenty loading pages?
*
* @var bool
*
*/
protected $loading = false;
/**
* Page instance ID
*
* @var int
*
*/
static protected $pageInstanceID = 0;
/**
* Construct
*
* @param Pages $pages
*
*/
public function __construct(Pages $pages) {
parent::__construct();
$this->pages = $pages;
$this->debug = $pages->debug();
}
/**
* Set whether loaded pages have their outputFormatting turned on or off
*
* By default, it is turned on.
*
* @param bool $outputFormatting
*
*/
public function setOutputFormatting($outputFormatting = true) {
$this->outputFormatting = $outputFormatting ? true : false;
}
/**
* Get whether loaded pages have their outputFormatting turned on or off
*
* @return bool
*
*/
public function getOutputFormatting() {
return $this->outputFormatting;
}
/**
* Enable or disable use of autojoin for all queries
*
* Default should always be true, and you may use this to turn it off temporarily, but
* you should remember to turn it back on
*
* @param bool $autojoin
*
*/
public function setAutojoin($autojoin = true) {
$this->autojoin = $autojoin ? true : false;
}
/**
* Get whether autojoin is enabled for page loading queries
*
* @return bool
*
*/
public function getAutojoin() {
return $this->autojoin;
}
/**
* Normalize a selector string
*
* @param string $selector
* @param bool $convertIDs Normalize to integer ID or array of integer IDs when possible (default=true)
* @return array|int|string
*
*/
protected function normalizeSelectorString($selector, $convertIDs = true) {
$selector = trim($selector, ', ');
if(ctype_digit($selector)) {
// normalize to page ID (int)
$selector = (int) $selector;
} else if($selector === '/' || $selector === 'path=/') {
// normalize selectors that indicate homepage to just be ID 1
$selector = (int) $this->wire()->config->rootPageID;
} else if($selector[0] === '/') {
// if selector begins with a slash, it is referring to a path
$selector = "path=$selector";
} else if(strpos($selector, ',') === false) {
// there is just one “key=value” or “value” selector that needs further processing
if(strpos($selector, 'id=')) {
if($convertIDs) {
// string like id=123 or id=123|456|789 converted to int or int-array
$s = substr($selector, 3); // skip over 'id='
if(ctype_digit($s)) {
// id=123
$selector = (int) $s;
} else if(strpos($selector, '|') && ctype_digit(str_replace('|', '', $s))) {
// id=123|456|789
$a = explode('|', $s);
foreach($a as $k => $v) $a[$k] = (int) $v;
$selector = $a;
}
}
} else if(!Selectors::stringHasOperator($selector)) {
// no operator indicates this is just referring to a page name
$sanitizer = $this->wire()->sanitizer;
if($sanitizer->pageNameUTF8($selector) === $selector) {
// sanitized value consistent with a page name
// optimize selector rather than determining value here
$selector = 'name=' . $sanitizer->selectorValue($selector);
}
}
}
if(is_int($selector) || ctype_digit("$selector")) {
// page ID integer
if($convertIDs) {
$selector = (int) $selector;
} else {
$selector = "id=$selector";
}
}
/** @var array|int|string $selector */
return $selector;
}
/**
* Normalize a selector
*
* @param string|int|array $selector
* @param bool $convertIDs Convert ID-only selectors to integers or arrays of integers?
* @return array|int|string
*
*/
protected function normalizeSelector($selector, $convertIDs = true) {
if(empty($selector)) return '';
if(is_int($selector)) {
if(!$convertIDs) $selector = "id=$selector";
} else if(is_string($selector)) {
$selector = $this->normalizeSelectorString($selector, $convertIDs);
} else if(is_array($selector)) {
// array that is not associative, not selector array, and consists of only numbers
if($this->isIdArray($selector)) {
if(!$convertIDs) $selector = 'id=' . implode('|', $selector);
}
}
return $selector;
}
/**
* Is this an array of IDs? Also sanitizes to all integers when true
*
* @param array $a
* @return bool
*
*/
protected function isIdArray(array &$a) {
if(ctype_digit(implode('', array_keys($a))) && !is_array(reset($a)) && ctype_digit(implode('', $a))) {
// regular array of page IDs, we delegate that to getById() method, but with access/visibility control
foreach($a as $k => $v) $a[$k] = (int) $v;
return true;
} else {
return false;
}
}
/**
* Helper for find() method to attempt to shortcut the find when possible
*
* @param string|array|Selectors $selector
* @param array $options
* @param array $loadOptions
* @return bool|Page|PageArray Returns boolean false when no shortcut available
*
*/
protected function findShortcut($selector, $options, $loadOptions) {
if(empty($selector)) {
return $this->pages->newPageArray($loadOptions);
}
$value = false;
$filter = empty($options['findAll']);
$selector = $this->normalizeSelector($selector, true);
if(is_array($selector)) {
if($this->isIdArray($selector)) {
$value = $this->getById($selector, $loadOptions);
$filter = true;
}
} else if(is_int($selector)) {
// page ID integer
$value = $this->getById(array($selector), $loadOptions);
}
if($value) {
if($filter) {
$includeMode = isset($options['include']) ? $options['include'] : '';
$value = $this->filterListable($value, $includeMode, $loadOptions);
}
if($this->debug) {
$this->pages->debugLog('find', $selector . " [optimized]", $value);
}
}
return $value;
}
/**
* Given a Selector string, return the Page objects that match in a PageArray.
*
* Non-visible pages are excluded unless an include=hidden|unpublished|all mode is specified in the selector string,
* or in the $options array. If 'all' mode is specified, then non-accessible pages (via access control) can also be included.
*
* @param string|int|array|Selectors $selector Specify selector (standard usage), but can also accept page ID or array of page IDs.
* @param array|string $options Optional one or more options that can modify certain behaviors. May be assoc array or key=value string.
* - `findOne` (bool): Apply optimizations for finding a single page.
* - `findAll` (bool): Find all pages with no exclusions (same as include=all option).
* - `findIDs` (bool|int): Makes method return raw array rather than PageArray, specify one of the following:
* • `true` (bool): return array of [ [id, templates_id, parent_id] ] for each page.
* • `1` (int): Return just array of just page IDs, [id, id, id]
* • `2` (int): Return all pages table columns in associative array for each page (3.0.153+).
* • `3` (int): Same as 2 + dates are unix timestamps + has 'pageArray' key w/blank PageArray for pagination info (3.0.172+).
* • `4` (int): Same as 3 + return PageArray instead if one is available in cache (3.0.172+).
* - `getTotal` (bool): Whether to set returning PageArray's "total" property (default: true except when findOne=true)
* - `cache` (bool): Allow caching of selectors and pages loaded (default=true). Also sets loadOptions[cache].
* - `allowCustom` (bool): Whether to allow use of "_custom=new selector" in selectors (default=false).
* - `lazy` (bool): Makes find() return Page objects that don't have any data populated to them (other than id and template).
* - `loadPages` (bool): Whether to populate the returned PageArray with found pages (default: true).
* The only reason why you'd want to change this to false would be if you only needed the count details from
* the PageArray: getTotal(), getStart(), getLimit, etc. This is intended as an optimization for Pages::count().
* Does not apply if $selectorString argument is an array.
* - `caller` (string): Name of calling function, for debugging purposes, i.e. pages.count
* - `include` (string): Inclusion mode of 'hidden', 'unpublished' or 'all'. Default=none. Typically you would specify this
* directly in the selector string, so the option is mainly useful if your first argument is not a string.
* - `stopBeforeID` (int): Stop loading pages once page matching this ID is found (default=0).
* - `startAfterID` (int): Start loading pages once page matching this ID is found (default=0).
* - `loadOptions` (array): Assoc array of options to pass to getById() load options. (does not apply when 'findIds' > 3).
* - `joinFields` (array): Names of fields to autojoin, or empty array to join none; overrides field autojoin settings (default=null) 3.0.172+
* @return PageArray|array
*
*/
public function find($selector, $options = array()) {
if(is_string($options)) $options = Selectors::keyValueStringToArray($options);
$loadOptions = isset($options['loadOptions']) && is_array($options['loadOptions']) ? $options['loadOptions'] : array();
$loadPages = array_key_exists('loadPages', $options) ? (bool) $options['loadPages'] : true;
$caller = isset($options['caller']) ? $options['caller'] : 'pages.find';
$lazy = empty($options['lazy']) ? false : true;
$findIDs = isset($options['findIDs']) ? $options['findIDs'] : false;
$debug = $this->debug && !$lazy;
$allowShortcuts = $loadPages && !$lazy && (!$findIDs || $findIDs === 4);
$joinFields = isset($options['joinFields']) ? $options['joinFields'] : array();
$cachePages = isset($options['cache']) ? $options['cache'] : true;
if($cachePages) {
$options['cache'] = $cachePages;
$loadOptions['cache'] = $cachePages;
} else if(!isset($loadOptions['cache'])) {
$loadOptions['cache'] = false;
}
if($allowShortcuts) {
$pages = $this->findShortcut($selector, $options, $loadOptions);
if($pages) return $pages;
}
if($selector instanceof Selectors) {
$selectors = $selector;
} else {
$selector = $this->normalizeSelector($selector, false);
$selectors = $this->wire(new Selectors()); /** @var Selectors $selectors */
$selectors->init($selector);
}
if(isset($options['include']) && in_array($options['include'], array('hidden', 'unpublished', 'all'))) {
$selectors->add(new SelectorEqual('include', $options['include']));
}
$selectorString = is_string($selector) ? $selector : (string) $selectors;
// check whether the joinFields option will be used
if(!$lazy && !$findIDs) {
$fields = $this->wire()->fields;
// support the joinFields option when selector contains 'field=a|b|c' or 'join=a|b|c'
foreach(array('field', 'join') as $name) {
if(strpos($selectorString, "$name=") === false || $fields->get($name)) continue;
foreach($selectors as $selector) {
if($selector->field() !== $name) continue;
$joinFields = array_merge($joinFields, $selector->values());
$selectors->remove($selector);
}
}
if(count($joinFields)) {
unset($options['include']); // because it was moved into $selectors earlier
return $this->findMin($selectors, array_merge($options, array('joinFields' => $joinFields)));
}
}
// see if this has been cached and return it if so
if($allowShortcuts) {
$pages = $this->pages->cacher()->getSelectorCache($selectorString, $options);
if($pages !== null) {
if($debug) $this->pages->debugLog('find', $selectorString, $pages . ' [from-cache]');
return $pages;
}
}
$pageFinder = $this->pages->getPageFinder();
$pagesInfo = array();
$pagesIDs = array();
if($debug) Debug::timer("$caller($selectorString)", true);
$profiler = $this->wire()->profiler;
$profilerEvent = $profiler ? $profiler->start("$caller($selectorString)", "Pages") : null;
if(($lazy || $findIDs) && strpos($selectorString, 'limit=') === false) $options['getTotal'] = false;
if($lazy) {
// [ pageID => templateID ]
$pagesIDs = $pageFinder->findTemplateIDs($selectors, $options);
} else if($findIDs === 1) {
// [ pageID ]
$pagesIDs = $pageFinder->findIDs($selectors, $options);
} else if($findIDs === 2) {
// [ pageID => [ all pages columns ] ]
$pagesInfo = $pageFinder->findVerboseIDs($selectors, $options);
} else if($findIDs === 3 || $findIDs === 4) {
// [ pageID => [ all pages columns + sortfield + dates as unix timestamps ],
// 'pageArray' => PageArray(blank but with pagination info populated) ] ]
$options['joinSortfield'] = true;
$options['getNumChildren'] = true;
$options['unixTimestamps'] = true;
$pagesInfo = $pageFinder->findVerboseIDs($selectors, $options);
} else {
// [ [ 'id' => 3, 'templates_id' => 2, 'parent_id' => 1, 'score' => 1.123 ]
$pagesInfo = $pageFinder->find($selectors, $options);
}
if($debug && empty($loadOptions['caller'])) {
$loadOptions['caller'] = "$caller($selectorString)";
}
// note that we save this pagination state here and set it at the end of this method
// because it's possible that more find operations could be executed as the pages are loaded
$total = $pageFinder->getTotal();
$limit = $pageFinder->getLimit();
$start = $pageFinder->getStart();
if($lazy) {
// lazy load: create empty pages containing only id and template
$templates = $this->wire()->templates;
$pages = $this->pages->newPageArray($loadOptions);
$pages->finderOptions($options);
$pages->setDuplicateChecking(false);
$loadPages = false;
$cachePages = false;
$template = null;
$templatesByID = array();
$loading = $this->loading;
if(!$loading) $this->loading = true;
foreach($pagesIDs as $id => $templateID) {
if(isset($templatesByID[$templateID])) {
$template = $templatesByID[$templateID];
} else {
$template = $templates->get($templateID);
$templatesByID[$templateID] = $template;
}
$page = $this->pages->newPage($template);
$page->_lazy($id);
$page->loaderCache = false;
$pages->add($page);
}
if(!$loading) $this->loading = false;
$pages->setDuplicateChecking(true);
if(count($pagesIDs)) $pages->_lazy(true);
unset($template, $templatesByID);
} else if($findIDs) {
$loadPages = false;
$cachePages = false;
// PageArray for hooks or for findIDs==3 option
$pages = $this->pages->newPageArray($loadOptions);
} else if($loadPages) {
// parent_id is null unless a single parent was specified in the selectors
$templates = $this->wire()->templates;
$parent_id = $pageFinder->getParentID();
$idsSorted = array();
$idsByTemplate = array();
$scores = array();
// organize the pages by template ID
foreach($pagesInfo as $page) {
$tpl_id = (int) $page['templates_id'];
$id = (int) $page['id'];
if(!isset($idsByTemplate[$tpl_id])) $idsByTemplate[$tpl_id] = array();
$idsByTemplate[$tpl_id][] = $id;
$idsSorted[] = $id;
if(!empty($page['score'])) $scores[$id] = (float) $page['score'];
}
if(count($idsByTemplate) > 1) {
// perform a load for each template, which results in unsorted pages
// @todo use $idsUnsorted array rather than $unsortedPages PageArray
$unsortedPages = $this->pages->newPageArray($loadOptions);
foreach($idsByTemplate as $tpl_id => $ids) {
$opt = $loadOptions;
$opt['template'] = $templates->get($tpl_id);
$opt['parent_id'] = $parent_id;
$unsortedPages->import($this->getById($ids, $opt));
}
// put pages back in the order that the selectorEngine returned them in, while double checking that the selector matches
$pages = $this->pages->newPageArray($loadOptions);
foreach($idsSorted as $id) {
foreach($unsortedPages as $page) {
if($page->id == $id) {
$pages->add($page);
break;
}
}
}
} else {
// there is only one template used, so no resorting is necessary
$pages = $this->pages->newPageArray($loadOptions);
reset($idsByTemplate);
$opt = $loadOptions;
$opt['template'] = $templates->get(key($idsByTemplate));
$opt['parent_id'] = $parent_id;
$pages->import($this->getById($idsSorted, $opt));
}
$sortsAfter = $pageFinder->getSortsAfter();
if(count($sortsAfter)) $pages->sort($sortsAfter);
if(count($scores)) {
foreach($pages as $page) {
$score = isset($scores[$page->id]) ? $scores[$page->id] : 0;
$page->setQuietly('_pfscore', $score);
}
}
} else {
$pages = $this->pages->newPageArray($loadOptions);
}
$pageFinder->getPageArrayData($pages);
$pages->setTotal($total);
$pages->setLimit($limit);
$pages->setStart($start);
$pages->setSelectors($selectorString);
$pages->setTrackChanges(true);
$this->lastPageFinder = $pageFinder;
if($loadPages && $cachePages) {
if(strpos($selectorString, 'sort=random') !== false) {
if($selectors->getSelectorByFieldValue('sort', 'random')) $cachePages = false;
}
if($cachePages) {
$this->pages->cacher()->selectorCache($selectorString, $options, $pages);
}
}
if($debug) {
$this->pages->debugLog('find', $selectorString, $pages);
$count = $pages->count();
$note = ($count == $total ? $count : $count . "/$total") . " page(s)";
if($count) {
$note .= ": " . $pages->first()->path;
if($count > 1) $note .= " ... " . $pages->last()->path;
}
if(substr($caller, -1) !== ')') $caller .= "($selectorString)";
Debug::saveTimer($caller, $note);
foreach($pages as $item) {
if($item->_debug_loader) continue;
$item->setQuietly('_debug_loader', $caller);
}
}
if($profilerEvent) $profiler->stop($profilerEvent);
if($this->pages->hasHook('found()')) $this->pages->found($pages, array(
'pageFinder' => $pageFinder,
'pagesInfo' => $pagesInfo,
'options' => $options
));
if($findIDs) {
if($findIDs === 3 || $findIDs === 4) $pagesInfo['pageArray'] = $pages;
return $findIDs === 1 ? $pagesIDs : $pagesInfo;
}
return $pages;
}
/**
* Minimal find for reduced or delayed overload in some circumstances
*
* This combines the page finding and page loading operation into a single operation
* and single query, unlike a regular find() which finds matching page IDs in one
* query and then loads them in a separate query. As a result this method does not
* need to call the getByIds() method to load pages, as it is able to load them itself.
*
* This strategy may eventually replace the “find() + getByIds()” strategy, but for the
* moment is only used when the `$pages->find()` method specifies `field=name` in
* the selector. In that selector, `name` can be any field name, or group of them, i.e.
* `title|date|summary`, or a non-existing field like `none` to specify that no fields
* should be autojoin (for fastest performance).
*
* Note that while this might reduce overhead in some cases, it can also increase the
* overall request time if you omit fields that are actually used on the resulting pages.
* For instance, if the `title` field is an autojoin field (as it is by default), and
* we do a `$pages->find('template=blog-post, field=none');` and then render a list of
* blog post titles, then we have just increased overhead because PW would have to
* perform a separate query to load each blog-post pages title. On the other hand, if
* we render a list of blog post titles with date and summary, and the date and summary
* fields are not configured as autojoin fields, then we can specify all those that we
* use in our rendered list to greatly improve performance, like this:
* `$pages->find('template=blog-post, field=title|date|summary');`.
*
* While this method combines what find() and getById() do in one query, there does not
* appear to be any overhead benefit when the two strategies are dealing with identical
* conditions, like the same autojoin fields.
*
* @param string|array|Selectors $selector
* @param array $options
* - `cache` (bool): Allow pulling from and saving results to cache? (default=true)
* - `joinFields` (array): Names of fields to also join into the page load
* @return PageArray
* @throws WireException
* @since 3.0.172
*
*/
public function findMin($selector, array $options = array()) {
$useCache = isset($options['cache']) ? $options['cache'] : true;
$templates = $this->wire()->templates;
$languages = $this->wire()->languages;
$languageIds = array();
$templatesById = array();
$tmpAutojoinFields = array(); // fields to autojoin temporarily, just during this method call
if($languages) foreach($languages as $language) $languageIds[$language->id] = $language->id;
$options['findIDs'] = $useCache ? 4 : 3;
$joinFields = isset($options['joinFields']) ? $options['joinFields'] : array();
$rows = $this->find($selector, $options);
// if PageArray was already available in cache, return it now
if($rows instanceof PageArray) return $rows;
/** @var PageArray $pageArray */
$pageArray = $rows['pageArray'];
$pageArray->setTrackChanges(false);
$paginationTotal = $pageArray->getTotal();
/** @var array $joinResults PageFinder sets which fields supported autojoin true|false */
$joinResults = $pageArray->data('joinFields');
unset($rows['pageArray']);
foreach($rows as $row) {
$page = $useCache ? $this->pages->getCache($row['id']) : null;
$tid = (int) $row['templates_id'];
if($page) {
$pageArray->add($page);
continue;
}
if(isset($templatesById[$tid])) {
$template = $templatesById[$tid];
} else {
$template = $templates->get($tid);
if(!$template) continue;
$templatesById[$tid] = $template;
}
$sortfield = $template->sortfield;
if(empty($sortfield) && isset($row['sortfield'])) $sortfield = $row['sortfield'];
$set = array(
'pageClass' => $template->getPageClass(),
'isLoaded' => false,
'id' => $row['id'],
'template' => $template,
'parent_id' => $row['parent_id'],
'sortfield' => $sortfield,
);
unset($row['templates_id'], $row['parent_id'], $row['id'], $row['sortfield']);
$page = $this->pages->newPage($set);
$page->instanceID = ++self::$pageInstanceID;
if($languages) {
foreach($languageIds as $id) {
$key = "name$id";
if(isset($row[$key]) && strpos($row[$key], 'xn-') === 0) {
$page->setName($row[$key], $key);
unset($row[$key]);
}
}
}
foreach($row as $key => $value) {
if(strpos($key, '__')) {
if($value === null) {
$row[$key] = 'null'; // ensure detected by later isset in foreach($joinFields)
} else {
$page->setFieldValue($key, $value, false);
}
} else {
$page->setForced($key, $value);
}
}
foreach($joinFields as $joinField) {
if(empty($joinResults[$joinField])) continue; // field did not support autojoin
if(!$template->fieldgroup->hasField($joinField)) continue;
$field = $page->getField($joinField);
if(!$field || !$field->type) continue;
if(isset($row["{$joinField}__data"])) {
if(!$field->hasFlag(Field::flagAutojoin)) {
$field->addFlag(Field::flagAutojoin);
$tmpAutojoinFields[$field->id] = $field;
}
} else {
// set blank values where joinField didn't appear on page row
$blankValue = $field->type->getBlankValue($page, $field);
$page->setFieldValue($field->name, $blankValue, false);
}
}
$page->setIsLoaded(true);
$page->setIsNew(false);
$page->resetTrackChanges(true);
$page->setOutputFormatting($this->outputFormatting);
$this->totalPagesLoaded++;
$pageArray->add($page);
if($useCache) $this->pages->cache($page);
}
$pageArray->setTotal($paginationTotal);
$pageArray->resetTrackChanges(true);
foreach($tmpAutojoinFields as $field) { /** @var Field $field */
$field->removeFlag(Field::flagAutojoin)->untrackChange('flags');
}
if($useCache) {
$selectorString = $pageArray->getSelectors(true);
$this->pages->cacher()->selectorCache($selectorString, $options, $pageArray);
}
return $pageArray;
}
/**
* Like find() but returns only the first match as a Page object (not PageArray)
*
* This is functionally similar to the get() method except that its default behavior is to
* filter for access control and hidden/unpublished/etc. states, in the same way that the
* find() method does. You can add an `include=` to your selector with value `hidden`,
* `unpublished` or `all` to change this behavior, just like with find().
*
* Unlike the find() method, this method performs a secondary runtime access check by calling
* `$page->viewable()` with the found $page, and returns a `NullPage` if the page is not
* viewable with that call. In 3.0.142+, an `include=` mode of `all` or `unpublished` will
* override this, where appropriate.
*
* This method also accepts an `$options` array, whereas `Pages::get()` does not.
*
* @param string|int|array|Selectors $selector
* @param array|string $options See $options for `Pages::find`
* @return Page|NullPage
*
*/
public function findOne($selector, $options = array()) {
if(empty($selector)) return $this->pages->newNullPage();
if(is_string($options)) $options = Selectors::keyValueStringToArray($options);
$defaults = array(
'findOne' => true, // find only one page
'getTotal' => false, // don't count totals
'caller' => 'pages.findOne'
);
$options = array_merge($defaults, $options);
$items = $this->pages->find($selector, $options);
$page = $items->first();
if($page && !$page->viewable(false)) {
// page found but is not viewable, check if include mode was specified and would allow the page
$selectors = $items->getSelectors();
if($selectors) {
$include = $selectors->getSelectorByField('include');
$checkAccess = $selectors->getSelectorByField('check_access');
if(!$checkAccess) $checkAccess = $selectors->getSelectorByField('checkAccess');
$checkAccess = $checkAccess ? (bool) $checkAccess->value() : true;
} else {
$include = null;
$checkAccess = true;
}
if(!$include) {
// there was no “include=” selector present
if($checkAccess === true) $page = null;
} else if($include->value() === 'all') {
// allow $page to pass through with include=all mode
} else if($include->value() === 'unpublished' && $page->hasStatus(Page::statusUnpublished) && $checkAccess) {
// check if user would have access without unpublished status
$status = $page->status;
$page->setQuietly('status', $status & ~Page::statusUnpublished);
$viewable = $page->viewable(false);
$page->setQuietly('status', $status); // restore
if(!$viewable) $page = null;
} else {
if($checkAccess === true) $page = null;
}
}
return $page && $page->id ? $page : $this->pages->newNullPage();
}
/**
* Find pages and cache the result for specified period of time
*
* Use this when you want to cache a slow or complex page finding operation so that it doesnt
* have to be repated for every web request. Note that this only caches the find operation
* and not the loading of the found pages.
*
* ~~~~~
* $items = $pages->findCache("title%=foo"); // 60 seconds (default)
* $items = $pages->findCache("title%=foo", 3600); // 1 hour
* $items = $pages->findCache("title%=foo", "+1 HOUR"); // same as above
* ~~~~~
*
* @param string|array|Selectors $selector
* @param int|string|bool|null $expire When the cache should expire, one of the following:
* - Max age integer (in seconds).
* - Any string accepted by PHPs `strtotime()` that specifies when the cache should be expired.
* - Any `WireCache::expire…` constant or anything accepted by the `WireCache::get()` $expire argument.
* @param array $options Options to pass to `$pages->getByIDs()`, or:
* - `findIDs` (bool): Return just the page IDs rather then the actual pages? (default=false)
* @return PageArray|array
* @since 3.0.218
*
*/
public function findCache($selector, $expire = 60, $options = array()) {
$user = $this->wire()->user;
$cache = $this->wire()->cache;
$ns = 'pages.findCache';
$items = null;
if(is_string($selector)) {
$selectorStr = $selector;
$selectors = $selector;
} else {
$selectors = $this->wire(new Selectors($selector));
$selectorStr = (string) $selectors;
}
$rolesStr = (string) $user->roles;
if(strpos($rolesStr, '|')) {
$rolesArray = explode('|', $rolesStr);
sort($rolesArray);
$rolesStr = implode('|', $rolesArray);
}
$optionsStr = '';
foreach($options as $key => $value) {
if(!is_string($value)) {
if(is_array($value)) $value = print_r($value, true);
$value = (string) $value;
}
$optionsStr .= "$key==$value,";
}
$cacheName = "$rolesStr\r$selectorStr\r$optionsStr";
$pageNum = $this->wire()->input->pageNum();
if($pageNum > 1 && Selectors::selectorHasField($selectors, 'limit')) {
if(!Selectors::selectorHasField($selectors, 'start')) $cacheName .= "\r$pageNum";
}
$cacheName = md5($cacheName);
$data = $cache->getFor($ns, $cacheName, $expire);
if(!empty($data) && $data['selector'] === $selectorStr && $data['roles'] === $rolesStr) {
$ids = $data['pages'];
} else {
$ids = null;
if(strpos($selectorStr, 'template') !== false && empty($options['template'])) {
$info = Selectors::selectorHasField($selectors, array('template', 'templates_id'), array('verbose' => true));
if($info['result']) $options['template'] = $this->wire()->templates->get($info['value']);
echo "template=$options[template]\n";
}
}
if($ids === null) {
if(empty($options['findIDs'])) {
$items = $this->find($selectors, $options);
$ids = $items->explode('id');
} else {
$ids = $this->pages->findIDs($selectors, $options);
}
$data = array(
'selector' => $selectorStr,
'roles' => $rolesStr,
'pages' => $ids
);
$cache->saveFor($ns, $cacheName, $data, $expire);
} else if(empty($options['findIDs'])) {
$items = $this->pages->getByIDs($ids, $options);
}
if(!empty($options['findIDs'])) return $ids;
foreach($items as $item) {
if($item instanceof NullPage || $item->status & Page::statusTrash) {
$items->remove($item);
}
}
return $items;
}
/**
* Returns the first page matching the given selector with no exclusions
*
* @param string|int|array|Selectors $selector
* @param array $options See Pages::find method for options
* @return Page|NullPage Always returns a Page object, but will return NullPage (with id=0) when no match found
*
*/
public function get($selector, $options = array()) {
if(empty($selector)) return $this->pages->newNullPage();
if(is_int($selector)) {
$getCache = true;
} else if(is_string($selector) && (ctype_digit($selector) || strpos($selector, 'id=') === 0)) {
$getCache = true;
} else {
$getCache = false;
}
if($getCache) {
// if cache is possible, allow user-specified options to dictate whether cache is allowed
if(isset($options['loadOptions']) && isset($options['loadOptions']['getFromCache'])) {
$getCache = (bool) $options['loadOptions']['getFromCache'];
}
if($getCache) {
$page = $this->pages->getCache($selector); // selector is either 123 or id=123
if($page) return $page;
}
}
$defaults = array(
'findOne' => true, // find only one page
'findAll' => true, // no exclusions
'getTotal' => false, // don't count totals
'caller' => 'pages.get'
);
$options = count($options) ? array_merge($defaults, $options) : $defaults;
$page = $this->pages->find($selector, $options)->first();
if(!$page) $page = $this->pages->newNullPage();
return $page;
}
/**
* Is there any page that matches the given $selector in the system? (with no exclusions)
*
* - This can be used as an “exists” or “getID” type of method.
* - Returns ID of first matching page if any exist, or 0 if none exist (returns array if `$verbose` is true).
* - Like with the `get()` method, no pages are excluded, so an `include=all` is not necessary in selector.
* - If you need to quickly check if something exists, this method is preferable to using a count() or get().
*
* When `$verbose` option is used, an array is returned instead. Verbose return array includes all columns
* from the matching row in the pages table.
*
* @param string|int|array|Selectors $selector
* @param bool $verbose Return verbose array with all pages columns rather than just page id? (default=false)
* @param array $options Additional options to pass in find() $options argument (not currently applicable)
* @return array|int
* @since 3.0.153
*
*/
public function has($selector, $verbose = false, array $options = array()) {
$defaults = array(
'findOne' => true, // find only one page
'findAll' => true, // no exclusions
'findIDs' => $verbose ? 2 : 1, // 2=all cols, 1=IDs only
'getTotal' => false, // don't count totals
'caller' => 'pages.has',
);
$options = count($options) ? array_merge($defaults, $options) : $defaults;
if(empty($selector)) return $verbose ? array() : 0;
if((is_string($selector) || is_int($selector)) && !$verbose) {
// see if any matching page is already in the cache
$page = $this->pages->getCache($selector);
if($page) return $page->id;
}
$items = $this->pages->find($selector, $options);
if($verbose) {
$value = count($items) ? reset($items) : array();
} else {
$value = count($items) ? (int) reset($items) : 0;
}
return $value;
}
/**
* Given an array or CSV string of Page IDs, return a PageArray
*
* Optionally specify an $options array rather than a template for argument 2. When present, the 'template' and 'parent_id' arguments may be provided
* in the given $options array. These options may be specified:
*
* LOAD OPTIONS (argument 2 array):
* - cache: boolean, default=true. place loaded pages in memory cache?
* - getFromCache: boolean, default=true. Allow use of previously cached pages in memory (rather than re-loading it from DB)?
* - template: instance of Template (see $template argument)
* - parent_id: integer (see $parent_id argument)
* - getNumChildren: boolean, default=true. Specify false to disable retrieval and population of 'numChildren' Page property.
* - getOne: boolean, default=false. Specify true to return just one Page object, rather than a PageArray.
* - autojoin: boolean, default=true. Allow use of autojoin option?
* - joinFields: array, default=empty. Autojoin the field names specified in this array, regardless of field settings (requires autojoin=true).
* - joinSortfield: boolean, default=true. Whether the 'sortfield' property will be joined to the page.
* - findTemplates: boolean, default=true. Determine which templates will be used (when no template specified) for more specific autojoins.
* - pageClass: string, default=auto-detect. Class to instantiate Page objects with. Leave blank to determine from template.
* - pageArrayClass: string, default=PageArray. PageArray-derived class to store pages in (when 'getOne' is false).
* - pageArray: PageArray, default=null. Optional predefined PageArray to populate to.
* - page (Page|null): Existing Page object to populate (also requires the getOne option to be true). (default=null)
* - caller (string): Name of calling function, for debugging purposes (default=blank).
*
* Use the $options array for potential speed optimizations:
* - Specify a 'template' with your call, when possible, so that this method doesn't have to determine it separately.
* - Specify false for 'getNumChildren' for potential speed optimization when you know for certain pages will not have children.
* - Specify false for 'autojoin' for potential speed optimization in certain scenarios (can also be a bottleneck, so be sure to test).
* - Specify false for 'joinSortfield' for potential speed optimization when you know the Page will not have children or won't need to know the order.
* - Specify false for 'findTemplates' so this method doesn't have to look them up. Potential speed optimization if you have few autojoin fields globally.
* - Note that if you specify false for 'findTemplates' the pageClass is assumed to be 'Page' unless you specify something different for the 'pageClass' option.
*
* @param array|WireArray|string|int $_ids Array of page IDs, comma or pipe-separated string of IDs, or single page ID (string or int)
* or in 3.0.156+ array of associative arrays where each in format: [ 'id' => 123, 'templates_id' => 456 ]
* @param Template|array|string|int|null $template Specify a template to make the load faster, because it won't have to attempt to join all possible fields...
* just those used by the template. Optionally specify an $options array instead, see the method notes above.
* @param int|null $parent_id Specify a parent to make the load faster, as it reduces the possibility for full table scans.
* This argument is ignored when an options array is supplied for the $template.
* @return PageArray|Page|NullPage Returns Page only if the 'getOne' option is specified, otherwise always returns a PageArray.
* @throws WireException
*
*/
public function getById($_ids, $template = null, $parent_id = null) {
$options = array(
'cache' => true,
'getFromCache' => true,
'template' => null,
'parent_id' => null,
'getNumChildren' => true,
'getOne' => false,
'autojoin' => true,
'findTemplates' => true,
'joinSortfield' => true,
'joinFields' => array(),
'page' => null,
'pageClass' => '', // blank = auto detect
'pageArray' => null, // PageArray to populate to
'pageArrayClass' => 'PageArray',
'caller' => '',
);
$templates = $this->wire()->templates;
$database = $this->wire()->database;
$idsByTemplate = array();
$loading = $this->loading;
if(is_array($template)) {
// $template property specifies an array of options
$options = array_merge($options, $template);
$template = $options['template'];
$parent_id = $options['parent_id'];
if("$options[cache]" === "1") $options['cache'] = true;
} else if(!is_null($template) && !$template instanceof Template) {
throw new WireException('getById argument 2 must be Template or $options array');
}
if(!is_null($parent_id) && !is_int($parent_id)) {
// convert Page object or string to integer id
$parent_id = (int) ((string) $parent_id);
}
if(!is_null($template) && !is_object($template)) {
// convert template string or id to Template object
$template = $templates->get($template);
}
if(is_string($_ids)) {
// convert string of IDs to array
$_ids = trim($_ids, '|, ');
if(ctype_digit($_ids)) {
$_ids = array((int) $_ids); // single ID: "123"
} else if(strpos($_ids, '|')) {
$_ids = explode('|', $_ids); // pipe-separated IDs: "123|456|789"
} else if(strpos($_ids, ',')) {
$_ids = explode(',', $_ids); // comma-separated IDs: "123,456,789"
} else {
$_ids = array(); // unrecognized ID string: fail
}
} else if(is_int($_ids)) {
$_ids = array($_ids);
}
if(!WireArray::iterable($_ids) || !count($_ids)) {
// return blank if $_ids isn't iterable or is empty
return $options['getOne'] ? $this->pages->newNullPage() : $this->pages->newPageArray($options);
}
if(is_object($_ids)) $_ids = $_ids->getArray(); // ArrayObject or the like
$loaded = array(); // array of id => Page objects that have been loaded
$ids = array(); // sanitized version of $_ids
// sanitize ids and determine which pages we can pull from cache
foreach($_ids as $key => $id) {
if(!is_int($id)) {
if(is_array($id)) {
if(!isset($id['id'])) continue;
$tid = isset($id['templates_id']) ? (int) $id['templates_id'] : 0;
$id = (int) $id['id'];
if($tid) {
if(!isset($idsByTemplate[$tid])) $idsByTemplate[$tid] = array();
$idsByTemplate[$tid][] = $id;
}
} else {
$id = trim($id);
if(!ctype_digit($id)) continue;
$id = (int) $id;
}
}
if($id < 1) continue;
$key = (int) $key;
if($options['getOne'] && is_object($options['page'])) {
// single page that will be populated directly
$loaded[$id] = '';
$ids[$key] = $id;
} else if($options['getFromCache'] && $page = $this->pages->getCache($id)) {
// page is already available in the cache
if($template && $page->template->id != $template->id) {
// do not load: does not match specified template
} else if($parent_id && $page->parent_id != $parent_id) {
// do not load: does not match specified parent_id
} else {
$loaded[$id] = $page;
}
} else if(isset(Page::$loadingStack[$id])) {
// if the page is already in the process of being loaded, point to it rather than attempting to load again.
// the point of this is to avoid a possible infinite loop with autojoin fields referencing each other.
$p = Page::$loadingStack[$id];
if($p) {
$loaded[$id] = $p;
// cache the pre-loaded version so that other pages referencing it point to this instance rather than loading again
$this->pages->cache($loaded[$id]);
}
} else {
$loaded[$id] = ''; // reserve the spot, in this order
$ids[$key] = $id; // queue id to be loaded
}
}
$idCnt = count($ids); // idCnt contains quantity of remaining page ids to load
if(!$idCnt) {
// if there are no more pages left to load, we can return what we've got
if($options['getOne']) {
$page = count($loaded) ? reset($loaded) : null;
return $page instanceof Page ? $page : $this->pages->newNullPage();
}
$pages = $this->pages->newPageArray($options);
$pages->setDuplicateChecking(false);
$pages->import($loaded);
$pages->setDuplicateChecking(true);
return $pages;
}
if(!$loading) $this->loading = true;
if(count($idsByTemplate)) {
// ok
} else if($template === null && $options['findTemplates']) {
// template was not defined with the function call, so we determine
// which templates are used by each of the pages we have to load
$sql = 'SELECT id, templates_id FROM pages';
if($idCnt == 1) {
$query = $database->prepare("$sql WHERE id=:id");
$query->bindValue(':id', (int) reset($ids), \PDO::PARAM_INT);
} else {
$ids = array_map('intval', $ids);
$sql = "$sql WHERE id IN(" . implode(',', $ids) . ")";
$query = $database->prepare($sql);
}
$result = $database->execute($query);
if($result) {
/** @noinspection PhpAssignmentInConditionInspection */
while($row = $query->fetch(\PDO::FETCH_NUM)) {
list($id, $templates_id) = $row;
$id = (int) $id;
$templates_id = (int) $templates_id;
if(!isset($idsByTemplate[$templates_id])) $idsByTemplate[$templates_id] = array();
$idsByTemplate[$templates_id][] = $id;
}
}
$query->closeCursor();
} else if($template === null) {
// no template provided, and autojoin not needed (so we don't need to know template)
$idsByTemplate = array(0 => $ids);
} else {
// template was provided
$idsByTemplate = array($template->id => $ids);
}
foreach($idsByTemplate as $templates_id => $ids) {
if($templates_id && (!$template || $template->id != $templates_id)) {
$template = $templates->get($templates_id);
}
if($template) {
$fields = $template->fieldgroup;
} else {
$fields = $this->wire()->fields;
}
/** @var DatabaseQuerySelect $query */
$query = $this->wire(new DatabaseQuerySelect());
$sortfield = $template ? $template->sortfield : '';
$joinSortfield = empty($sortfield) && $options['joinSortfield'];
// note that "false AS isLoaded" triggers the setIsLoaded() function in Page intentionally
$select = 'false AS isLoaded, pages.templates_id AS templates_id, pages.*, ';
if($joinSortfield) {
$select .= 'pages_sortfields.sortfield, ';
}
if($options['getNumChildren']) {
$select .= "\n(SELECT COUNT(*) FROM pages AS children WHERE children.parent_id=pages.id) AS numChildren";
}
$query->select(rtrim($select, ', '));
$query->from('pages');
if($joinSortfield) $query->leftjoin('pages_sortfields ON pages_sortfields.pages_id=pages.id');
if($options['autojoin'] && $this->autojoin) {
foreach($fields as $field) {
/** @var Field $field */
if(!empty($options['joinFields']) && in_array($field->name, $options['joinFields'])) {
// joinFields option specified to force autojoin this field
} else {
// check if autojoin not enabled for field
if(!($field->flags & Field::flagAutojoin)) continue;
// non-fieldgroup, autojoin only if global flag is set
if($fields instanceof Fields && !($field->flags & Field::flagGlobal)) continue;
}
$table = $database->escapeTable($field->table);
// check autojoin not allowed, otherwise merge in the autojoin query
$fieldtype = $field->type;
if(!$fieldtype || !$fieldtype->getLoadQueryAutojoin($field, $query)) continue;
// complete autojoin
$query->leftjoin("$table ON $table.pages_id=pages.id"); // QA
}
}
if(count($ids) > 1) {
$ids = array_map('intval', $ids);
$query->where('pages.id IN(' . implode(',', $ids) . ')');
} else {
$id = reset($ids);
$query->where('pages.id=:id');
$query->bindValue(':id', (int) $id, \PDO::PARAM_INT);
}
if(!is_null($parent_id)) {
$query->where('pages.parent_id=:parent_id');
$query->bindValue(':parent_id', (int) $parent_id, \PDO::PARAM_INT);
}
if($template) {
$query->where('pages.templates_id=:templates_id');
$query->bindValue(':templates_id', (int) $template->id, \PDO::PARAM_INT);
}
$query->groupby('pages.id');
$stmt = $query->prepare();
$database->execute($stmt);
$class = $options['pageClass'];
if(empty($class)) $class = $template ? $template->getPageClass() : __NAMESPACE__ . "\\Page";
// page to populate, if provided in 'getOne' mode
/** @var Page|null $_page */
$_page = $options['getOne'] && $options['page'] instanceof Page ? $options['page'] : null;
try {
// while($page = $stmt->fetchObject($_class, array($template))) {
/** @noinspection PhpAssignmentInConditionInspection */
while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
if($_page) {
// populate provided Page object
$page = $_page;
$page->set('template', $template ? $template : (int) $row['templates_id']);
if(!$page->get('parent_id')) $page->set('parent_id', (int) $row['parent_id']);
} else {
// create new Page object
$pageTemplate = $template ? $template : $templates->get((int) $row['templates_id']);
$pageClass = empty($options['pageClass']) && $pageTemplate ? $pageTemplate->getPageClass() : $class;
$page = $this->pages->newPage(array(
'pageClass' => $pageClass,
'template' => $pageTemplate ? $pageTemplate : $row['templates_id'],
'parent' => $row['parent_id'],
));
}
unset($row['templates_id'], $row['parent_id']);
$page->loaderCache = $options['cache'];
foreach($row as $key => $value) $page->set($key, $value);
$page->instanceID = ++self::$pageInstanceID;
$page->setIsLoaded(true);
$page->setIsNew(false);
$page->resetTrackChanges(true);
$page->setOutputFormatting($this->outputFormatting);
$loaded[$page->id] = $page;
if($options['cache'] === true) {
$this->pages->cache($page);
} else if($options['cache']) {
$this->pages->cacher()->cacheGroup($page, $options['cache']);
}
$this->totalPagesLoaded++;
}
} catch(\Exception $e) {
$error = $e->getMessage() . " [pageClass=$class, template=$template]";
$user = $this->wire()->user;
if($user && $user->isSuperuser()) $this->error($error);
$this->wire()->log->error($error);
$this->trackException($e, false);
}
$stmt->closeCursor();
$template = null;
}
if($options['getOne']) {
if(!$loading) $this->loading = false;
$page = count($loaded) ? reset($loaded) : null;
return $page instanceof Page ? $page : $this->pages->newNullPage();
}
$pages = $this->pages->newPageArray($options);
$pages->setDuplicateChecking(false);
$pages->import($loaded);
$pages->setDuplicateChecking(true);
if(!$loading) $this->loading = false;
// debug mode only
if($this->debug) {
$page = $this->wire()->page;
if($page && $page->template == 'admin') {
if(empty($options['caller'])) {
$_template = is_null($template) ? '' : ", $template";
$_parent_id = is_null($parent_id) ? '' : ", $parent_id";
if(count($_ids) > 10) {
$_ids = '[' . reset($_ids) . '…' . end($_ids) . ', ' . count($_ids) . ' pages]';
} else {
$_ids = count($_ids) > 1 ? "[" . implode(',', $_ids) . "]" : implode('', $_ids);
}
$options['caller'] = "pages.getById($_ids$_template$_parent_id)";
}
foreach($pages as $item) {
$item->setQuietly('_debug_loader', $options['caller']);
}
}
}
return $pages;
}
/**
* Find page(s) by name
*
* This method is optimized just for finding pages by name and it does
* not perform any filtering or access checking.
*
* @param string $name Match this page name
* @param array $options
* - `parent' (int|Page): Match this parent ID (default=0)
* - `parentName` (string): Match this parent name (default='')
* - `getArray` (bool): Get PHP info array rather than Page|NullPage|PageArray? (default=false)
* - `getOne` (bool|int): Get just one match of Page or NullPage? (default=false)
* When true, if multiple pages match then NullPage will be returned. To instead return
* the first match, specify int `1` instead of boolean true.
* @return array|NullPage|Page|PageArray
*
*/
public function findByName($name, array $options = array()) {
$defaults = array(
'parent' => 0,
'parentName' => '',
'getArray' => false,
'getOne' => false,
);
$options = array_merge($defaults, $options);
$getArray = $options['getArray'];
$getOne = $options['getOne'];
$blankRow = array(
'id' => 0,
'templates_id' => 0,
'parent_id' => 0,
);
$joins = array();
$selects = array(
'pages.id',
'pages.parent_id',
'pages.templates_id',
);
$wheres = array(
'pages.name=:name',
);
$binds = array(
'name' => $name,
);
if($options['parent']) {
$wheres[] = 'pages.parent_id=:parentId';
$binds['parentId'] = (int) "$options[parent]";
}
if($options['parentName']) {
$joins[] = 'JOIN pages AS parent ON pages.parent_id=parent.id AND parent.name=:parentName';
$binds['parentName'] = $options['parentName'];
}
$sql =
'SELECT ' . implode(', ', $selects) . ' ' .
'FROM pages ' . implode(' ', $joins) . ' ' .
'WHERE ' . implode(' AND ', $wheres) . ' ';
if($getOne) $sql .= 'LIMIT 2';
$query = $this->wire()->database->prepare($sql);
foreach($binds as $bindKey => $bindValue) {
$query->bindValue(":$bindKey", $bindValue);
}
$query->execute();
$rowCount = (int) $query->rowCount();
$rows = array();
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$rows[] = $row;
}
$query->closeCursor();
if($getOne === 1 && $rowCount > 1) {
// multiple rows found but only first one requested
$rowCount = 1;
}
if($rowCount === 0) {
// no rows matched
if($getOne) {
return $getArray ? $blankRow : $this->pages->newNullPage();
} else {
return $getArray ? array() : $this->pages->newPageArray();
}
} else if($rowCount === 1) {
// one row matched
if($getOne) {
return $getArray ? reset($rows) : $this->pages->getByIDs($rows, array('getOne' => true));
} else {
return $getArray ? $rows : $this->pages->getByIDs($rows);
}
} else {
// multiple rows matched
if($getOne) {
// return blank (multiple not allowed here)
return $getArray ? $blankRow : $this->pages->newNullPage();
} else {
// return all
return $getArray ? $rows : $this->pages->getByIDs($rows);
}
}
}
/**
* Given an ID return a path to a page, without loading the actual page
*
* Please note
* ===========
* 1) Always returns path in default language, unless a language argument/option is specified.
* 2) Path may be different from 'url' as it doesn't include $config->urls->root at the beginning.
* 3) In most cases, it's preferable to use $page->path() rather than this method. This method is
* here just for cases where a path is needed without loading the page.
* 4) It's possible for there to be Page::path() hooks, and this method completely bypasses them,
* which is another reason not to use it unless you know such hooks aren't applicable to you.
*
* @param int|Page $id ID of the page you want the path to
* @param null|array|Language|int|string $options Specify $options array or Language object, id or name. Allowed options:
* - language (int|string|anguage): To retrieve in non-default language, specify language object, ID or name (default=null)
* - useCache (bool): Allow pulling paths from already loaded pages? (default=true)
* - usePagePaths (bool): Allow pulling paths from PagePaths module, if installed? (default=true)
* @return string Path to page or blank on error/not-found
*
*/
public function getPath($id, $options = array()) {
$modules = $this->wire()->modules;
$database = $this->wire()->database;
$languages = $this->wire()->languages;
$config = $this->wire()->config;
$defaults = array(
'language' => null,
'useCache' => true,
'usePagePaths' => true
);
if(!is_array($options)) {
// language was specified rather than $options
$defaults['language'] = $options;
$options = array();
}
$options = array_merge($defaults, $options);
if($id instanceof Page) {
if($options['useCache']) return $id->path();
$id = $id->id;
}
$id = (int) $id;
if(!$id || $id < 0) return '';
if($languages && !$languages->hasPageNames()) $languages = null;
$language = $options['language'];
$languageID = 0;
$homepageID = (int) $config->rootPageID;
if(!empty($language) && $languages) {
if(is_string($language) || is_int($language)) $language = $languages->get($language);
if(!$language->isDefault()) $languageID = (int) $language->id;
}
// if page is already loaded and cache allowed, then get the path from it
if($options['useCache'] && $page = $this->pages->getCache($id)) {
/** @var Page $page */
if($languageID) $languages->setLanguage($language);
$path = $page->path();
if($languageID) $languages->unsetLanguage();
return $path;
} else if($id === $homepageID && $languages && !$languageID) {
// default language in multi-language environment, let $page handle it since there is additional
// hooked logic there provided by LanguageSupportPageNames
$page = $this->pages->get($homepageID);
$languages->setDefault();
$path = $page->path();
$languages->unsetDefault();
return $path;
}
// if PagePaths module is installed, and not in multi-language environment, attempt to get from PagePaths module
if(!$languages && !$languageID && $options['usePagePaths'] && $modules->isInstalled('PagePaths')) {
/** @var PagePaths $pagePaths */
$pagePaths = $modules->get('PagePaths');
$path = $pagePaths->getPath($id);
if($path) return $path;
}
$path = '';
$templatesID = 0;
$parentID = $id;
$maxParentID = $language ? 0 : 1;
$cols = 'parent_id, templates_id, name';
if($languageID) $cols .= ", name$languageID"; // col=3
$query = $database->prepare("SELECT $cols FROM pages WHERE id=:parent_id");
do {
$query->bindValue(":parent_id", (int) $parentID, \PDO::PARAM_INT);
$database->execute($query);
$row = $query->fetch(\PDO::FETCH_NUM);
if(!$row) {
$path = '';
break;
}
$parentID = (int) $row[0];
$templatesID = (int) $row[1];
$name = empty($row[3]) ? $row[2] : $row[3];
if($parentID) {
// non-homepage
$path = $name . '/' . $path;
} else {
// homepage
if($name !== Pages::defaultRootName && !empty($name)) {
$path = $name . '/' . $path;
}
}
} while($parentID > $maxParentID);
if(!strlen($path) || $path === '/') return $path;
$path = trim($path, '/');
if($templatesID) {
$template = $this->wire()->templates->get($templatesID);
if($template->slashUrls) $path .= '/';
}
return '/' . ltrim($path, '/');
}
/**
* Get a page by its path, similar to $pages->get('/path/to/page/') but with more options
*
* Please note
* ===========
* 1) There are no exclusions for page status or access. If needed, you should validate access
* on any page returned from this method.
* 2) In a multi-language environment, you must specify the $useLanguages option to be true, if you
* want a result for a $path that is (or might be) a multi-language path. Otherwise, multi-language
* paths will make this method return a NullPage (or 0 if getID option is true).
* 3) Partial paths may also match, so long as the partial path is completely unique in the site.
* If you don't want that behavior, double check the path of the returned page.
* 4) See also the newer/more capable `$pages->pathFinder()` methods `get('/path/')` and `getPage('/path/')`.
*
* @param string $path
* @param array|bool $options array of options (below), or specify boolean for $useLanguages option only.
* - `getID` (bool): Specify true to just return the page ID (default=false)
* - `useLanguages` (bool): Specify true to allow retrieval by language-specific paths (default=false)
* - `useHistory` (bool): Allow use of previous paths used by the page, if PagePathHistory module is installed (default=false)
* - `allowUrl` (bool): Allow getting page by path OR url? Specify false to find only by path. This option only applies if
* the site happens to run from a subdirectory. (default=true) 3.0.184+
* - `allowPartial` (bool): Allow partial paths to match? (default=true) 3.0.184+
* - `allowUrlSegments` (bool): Allow paths with URL segments to match? When true and page match cannot be found, the closest
* parent page that allows URL segments will be returned. Found URL segments are populated to a `_urlSegments` array
* property on the returned page object. This also cancels the allowPartial setting. (default=false) 3.0.184+
* @return Page|int
* @see PagesPathFinder::get(), PagesPathFinder::getPage()
*
*/
public function getByPath($path, $options = array()) {
$modules = $this->wire()->modules;
$sanitizer = $this->wire()->sanitizer;
$config = $this->wire()->config;
$database = $this->wire()->database;
$defaults = array(
'getID' => false,
'useLanguages' => false,
'useHistory' => false,
'allowUrl' => true,
'allowPartial' => true,
'allowUrlSegments' => false,
'_isRecursive' => false,
);
if(!is_array($options)) {
$defaults['useLanguages'] = (bool) $options;
$options = array();
}
$options = array_merge($defaults, $options);
if(isset($options['getId'])) $options['getID'] = $options['getId']; // case alternate
$homepageID = (int) $config->rootPageID;
$rootUrl = $this->wire()->config->urls->root;
if($options['allowUrl'] && $rootUrl !== '/' && strpos($path, $rootUrl) === 0) {
// root URL is subdirectory and path has that subdirectory
$rootName = trim($rootUrl, '/');
if(strpos($rootName, '/')) {
// root URL has multiple levels of subdirectories, remove them from path
list(,$path) = explode(rtrim($rootUrl, '/'), $path, 2);
} else {
// one subdirectory, see if a page has the same name
$query = $database->prepare('SELECT id FROM pages WHERE parent_id=1 AND name=:name');
$query->bindValue(':name', $rootName);
$query->execute();
if($query->rowCount() > 0) {
// leave subdirectory in path because page in site also matches subdirectory name
} else {
// remove root URL subdirectory from path
list(,$path) = explode(rtrim($rootUrl, '/'), $path, 2);
}
$query->closeCursor();
}
}
if($path === '/') {
// this can only be homepage
return $options['getID'] ? $homepageID : $this->getById($homepageID, array('getOne' => true));
} else if(empty($path)) {
// path is empty and cannot match anything
return $options['getID'] ? 0 : $this->pages->newNullPage();
}
$_path = $path;
$path = $sanitizer->pagePathName($path, Sanitizer::toAscii);
$pathParts = explode('/', trim($path, '/'));
$_pathParts = $pathParts;
$languages = $options['useLanguages'] ? $this->wire()->languages : null;
if($languages && !$languages->hasPageNames()) $languages = null;
$langKeys = array(':name' => 'name');
if($languages) {
foreach($languages as $language) {
if($language->isDefault()) continue;
$languageID = (int) $language->id;
$langKeys[":name$languageID"] = "name$languageID";
}
}
$pageID = 0;
$templatesID = 0;
$parentID = 0;
if($options['allowPartial'] && !$options['allowUrlSegments']) {
// first see if we can find a single page just having the name that's the last path part
// this is an optimization if the page name happens to be globally unique in the system, which is often the case
$name = end($pathParts);
$binds = array(':name' => $name);
$wheres = array();
$numParts = count($pathParts);
// can match 'name' or 'name123' cols where 123 is language ID
foreach($langKeys as $bindKey => $colName) {
$wheres[] = "$colName=$bindKey";
$binds[$bindKey] = $name;
}
$sql = 'SELECT id, templates_id, parent_id FROM pages WHERE (' . implode(' OR ', $wheres) . ') ';
if($numParts == 1) {
$sql .= ' AND (parent_id=:parent_id ';
$binds[':parent_id'] = $homepageID;
if($languages) {
$sql .= 'OR id=:homepage_id ';
$binds[':homepage_id'] = $homepageID;
}
$sql .= ') ';
}
$sql .= 'LIMIT 2';
$query = $database->prepare($sql);
foreach($binds as $key => $value) $query->bindValue($key, $value);
$database->execute($query);
$numRows = $query->rowCount();
if($numRows == 1) {
// if only 1 page matches then weve found what were looking for
list($pageID, $templatesID, $parentID) = $query->fetch(\PDO::FETCH_NUM);
} else if($numRows == 0) {
// no page can possibly match last segment
} else if($numRows > 1) {
// multiple pages match
}
$query->closeCursor();
}
if(!$pageID) {
// multiple pages have the name or partial path match is not allowed
// build a query joining all the path parts
$joins = array();
$wheres = array();
$binds = array();
$n = 0;
$lastAlias = "pages";
$lastPart = array_pop($pathParts);
while(count($pathParts)) {
$n++;
$alias = "_pages$n";
$part = array_pop($pathParts);
$whereORs = array();
foreach($langKeys as $bindKey => $colName) {
$bindKey .= "_$n";
$whereORs[] = "$alias.$colName=$bindKey";
$binds[$bindKey] = $part;
}
$where = '(' . implode(' OR ', $whereORs) . ')';
$joins[] = "\nJOIN pages AS $alias ON $lastAlias.parent_id=$alias.id AND $where";
//$wheres[] = $where; // appears to be redundant as where only needed in join
$lastAlias = $alias;
}
$isRootParent = !$n;
// there were no pathParts, so we are matching just a rootParent
if($isRootParent) $wheres[] = "pages.parent_id=1";
$whereORs = array();
foreach($langKeys as $bindKey => $colName) {
$whereORs[] = "pages.$colName=$bindKey";
$binds[$bindKey] = $lastPart;
}
$wheres[] = '(' . implode(' OR ', $whereORs) . ')';
$sql =
'SELECT pages.id, pages.templates_id, pages.parent_id, pages.name ' .
'FROM pages ' . implode(' ', $joins) . " \n" .
'WHERE (' . implode(' AND ', $wheres) . ') ';
$query = $database->prepare($sql);
foreach($binds as $key => $value) $query->bindValue($key, $value);
$database->execute($query);
$rowCount = $query->rowCount();
if($rowCount === 1) {
// just one page matched
$row = $query->fetch(\PDO::FETCH_NUM);
list($pageID, $templatesID, $parentID, ) = $row;
} else if($rowCount > 1 && $isRootParent) {
// multiple pages matched off root
// use either 'default' language match or first matching language
$rows = array();
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$rows[] = $row;
if($row['name'] !== $lastPart) continue;
$rows = array($row); // force use of only this row (default language)
break;
}
$row = reset($rows);
list($pageID, $templatesID, $parentID) = array($row['id'], $row['templates_id'], $row['parent_id']);
} else if($rowCount > 1) {
// multiple pages matched somewhere in site, we need a stronger tool (pagesPathFinder)
$pathFinder = $this->pages->pathFinder();
$info = $pathFinder->get($_path, array(
'useLanguages' => $options['useLanguages'],
'useHistory' => $options['useHistory'],
));
if(!empty($info['page']['id'])) {
// pathFinder found a match
if(count($info['urlSegments']) && !$options['allowUrlSegments']) {
// found URL segments and they weren't allowed by options
} else {
$pageID = $info['page']['id'];
$templatesID = $info['page']['templates_id'];
$parentID = $info['page']['parent_id'];
}
}
} else if($isRootParent) {
// no page matches possible, maybe a URL segment for homepage?
} else {
// no match found yet
}
$query->closeCursor();
}
if(!$pageID && $options['useHistory'] && $modules->isInstalled('PagePathHistory')) {
// if finding failed, check if there is a previous path it lived at, if history module available
$pph = $modules->get('PagePathHistory'); /** @var PagePathHistory $pph */
$page = $pph->getPage($sanitizer->pagePathNameUTF8($_path));
if($page->id) return $options['getID'] ? $page->id : $page;
}
if(!$pageID && $options['allowUrlSegments'] && !$options['_isRecursive'] && count($_pathParts)) {
// attempt to match parent pages that allow URL segments
$pathParts = $_pathParts;
$urlSegments = array();
$recursiveOptions = array_merge($options, array(
'getID' => false,
'allowUrlSegments' => false,
'allowPartial' => false,
'_isRecursive' => true
));
do {
$urlSegment = array_pop($pathParts);
array_unshift($urlSegments, $urlSegment);
$path = '/' . implode('/', $pathParts);
$page = $this->getByPath($path, $recursiveOptions);
} while(count($pathParts) && !$page->id);
if($page->id) {
if($page->template->urlSegments) {
// matched page template allows URL segments
$page->setQuietly('_urlSegments', $urlSegments);
if(!$options['getID']) return $page;
$pageID = $page->id;
} else {
// page template does not allow URL segments, so path cannot match
$pageID = 0;
}
}
}
if($options['getID']) return (int) $pageID;
if(!$pageID) return $this->pages->newNullPage();
return $this->getById((int) $pageID, array(
'template' => $templatesID ? $this->wire()->templates->get((int) $templatesID) : null,
'parent_id' => (int) $parentID,
'getOne' => true
));
}
/**
* Get a fresh, non-cached copy of a Page from the database
*
* This method is the same as `$pages->get()` except that it skips over all memory caches when loading a Page.
* Meaning, if the Page is already in memory, it doesnt use the one in memory and instead reloads from the DB.
* Nor does it place the Page it loads in any memory cache. Use this method to load a fresh copy of a page
* that you might need to compare to an existing loaded copy, or to load a copy that wont be seen or touched
* by anything in ProcessWire other than your own code.
*
* ~~~~~
* $p1 = $pages->get(1234);
* $p2 = $pages->get($p1->path);
* $p1 === $p2; // true: same Page instance
*
* $p3 = $pages->getFresh($p1);
* $p1 === $p3; // false: same Page but different instance
* ~~~~~
*
* #pw-advanced
*
* @param Page|string|array|Selectors|int $selectorOrPage Specify Page to get copy of, selector or ID
* @param array $options Options to modify behavior
* @return Page|NullPage
* @since 3.0.172
*
*/
public function getFresh($selectorOrPage, $options = array()) {
if(!isset($options['cache'])) $options['cache'] = false;
if(!isset($options['loadOptions'])) $options['loadOptions'] = array();
if(!isset($options['caller'])) $options['caller'] = 'pages.loader.getFresh';
$options['loadOptions']['getFromCache'] = false;
if(!isset($options['loadOptions']['cache'])) $options['loadOptions']['cache'] = false;
$selector = $selectorOrPage instanceof Page ? $selectorOrPage->id : $selectorOrPage;
return $this->get($selector, $options);
}
/**
* Load total number of children from DB for given page
*
* @param int|Page $page Page or Page ID
* @return int
* @throws WireException
* @since 3.0.172
*
*/
public function getNumChildren($page) {
$pageId = $page instanceof Page ? $page->id : (int) $page;
$sql = 'SELECT COUNT(*) FROM pages WHERE parent_id=:id';
$query = $this->wire()->database->prepare($sql);
$query->bindValue(':id', $pageId, \PDO::PARAM_INT);
$query->execute();
$numChildren = (int) $query->fetchColumn();
$query->closeCursor();
return $numChildren;
}
/**
* Count and return how many pages will match the given selector string
*
* @param string|array|Selectors $selector Specify selector, or omit to retrieve a site-wide count.
* @param array|string $options See $options in Pages::find
* @return int
*
*/
public function count($selector = '', $options = array()) {
if(is_string($options)) $options = Selectors::keyValueStringToArray($options);
if(empty($selector)) {
if(empty($options)) {
// optimize away a simple site-wide total count
$query = $this->wire()->database->query("SELECT COUNT(*) FROM pages");
$count = (int) $query->fetch(\PDO::FETCH_COLUMN);
$query->closeCursor();
return (int) $count;
} else {
// no selector string, but options specified
$selector = "id>0";
}
}
$options['loadPages'] = false;
$options['getTotal'] = true;
$options['caller'] = 'pages.count';
$options['returnVerbose'] = false;
//if($this->wire('config')->debug) $options['getTotalType'] = 'count'; // test count method when in debug mode
if(is_string($selector)) {
$selector .= ", limit=1";
} else if(is_array($selector)) {
$selector['limit'] = 1;
} else if($selector instanceof Selectors) {
$selector->add(new SelectorEqual('limit', 1));
}
return $this->pages->find($selector, $options)->getTotal();
}
/**
* Remove pages from already-loaded PageArray aren't visible or accessible
*
* @param PageArray $items
* @param string $includeMode Optional inclusion mode:
* - 'hidden': Allow pages with 'hidden' status'
* - 'unpublished': Allow pages with 'unpublished' or 'hidden' status
* - 'all': Allow all pages (not much point in calling this method)
* @param array $options loadOptions
* @return PageArray
*
*/
protected function filterListable(PageArray $items, $includeMode = '', array $options = array()) {
if($includeMode === 'all') return $items;
$itemsAllowed = $this->pages->newPageArray($options);
foreach($items as $item) {
if($includeMode === 'unpublished') {
$allow = $item->status < Page::statusTrash;
} else if($includeMode === 'hidden') {
$allow = $item->status < Page::statusUnpublished;
} else {
$allow = $item->status < Page::statusHidden;
}
if($allow) $allow = $item->listable(); // confirm access
if($allow) $itemsAllowed->add($item);
}
$itemsAllowed->resetTrackChanges(true);
return $itemsAllowed;
}
/**
* Returns an array of all columns native to the pages table
*
* @return array of column names, also indexed by column name
*
*/
public function getNativeColumns() {
if(empty($this->nativeColumns)) {
$query = $this->wire()->database->prepare("SELECT * FROM pages WHERE id=:id");
$query->bindValue(':id', $this->wire()->config->rootPageID, \PDO::PARAM_INT);
$query->execute();
$row = $query->fetch(\PDO::FETCH_ASSOC);
foreach(array_keys($row) as $colName) {
$this->nativeColumns[$colName] = $colName;
}
$query->closeCursor();
}
return $this->nativeColumns;
}
/**
* Get value of of a native column in pages table for given page ID
*
* @param int|Page $id Page ID
* @param string $column
* @return int|string|bool Returns int/string value on success or boolean false if no matching row
* @since 3.0.156
* @throws \PDOException|WireException
*
*/
public function getNativeColumnValue($id, $column) {
$id = (is_object($id) ? (int) "$id" : (int) $id);
if($id < 1) return false;
$database = $this->wire()->database;
if($database->escapeCol($column) !== $column) throw new WireException("Invalid column name: $column");
$query = $database->prepare("SELECT `$column` FROM pages WHERE id=:id");
$query->bindValue(':id', $id, \PDO::PARAM_INT);
$query->execute();
$value = $query->fetchColumn();
$query->closeCursor();
if(ctype_digit("$value") && strpos($column, 'name') !== 0) $value = (int) $value;
return $value;
}
/**
* Is the given column name native to the pages table?
*
* @param $columnName
* @return bool
*
*/
public function isNativeColumn($columnName) {
$nativeColumns = $this->getNativeColumns();
return isset($nativeColumns[$columnName]);
}
/**
* Get or set debug state
*
* @param bool|null $debug
* @return bool
*
*/
public function debug($debug = null) {
$value = $this->debug;
if(!is_null($debug)) $this->debug = (bool) $debug;
return $value;
}
/**
* Return the total quantity of pages loaded by getById()
*
* @return int
*
*/
public function getTotalPagesLoaded() {
return $this->totalPagesLoaded;
}
/**
* Get last used instance of PageFinder (for debugging purposes)
*
* @return PageFinder|null
* @since 3.0.146
*
*/
public function getLastPageFinder() {
return $this->lastPageFinder;
}
/**
* Are we currently loading pages?
*
* @return bool
* @since 3.0.195
*
*
*/
public function isLoading() {
return $this->loading;
}
}