artabro/wire/core/PagesLoader.php

2158 lines
74 KiB
PHP
Raw Normal View History

2024-08-27 11:35:37 +02:00
<?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;
}
}