artabro/wire/core/PageFinder.php

3748 lines
119 KiB
PHP
Raw Permalink Normal View History

2024-08-27 11:35:37 +02:00
<?php namespace ProcessWire;
/**
* ProcessWire PageFinder
*
* Matches selector strings to pages
*
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
* https://processwire.com
*
* Hookable methods:
* =================
* @method array|DatabaseQuerySelect find(Selectors|string|array $selectors, $options = array())
* @method DatabaseQuerySelect getQuery($selectors, array $options)
* @method string getQueryAllowedTemplatesWhere(DatabaseQuerySelect $query, $where)
* @method void getQueryJoinPath(DatabaseQuerySelect $query, $selector)
* @method bool|Field getQueryUnknownField($fieldName, array $data);
*
* @property string $includeMode
* @property bool $checkAccess
*
*/
class PageFinder extends Wire {
/**
* Options (and their defaults) that may be provided as the 2nd argument to find()
*
*/
protected $defaultOptions = array(
/**
* Specify that you only want to find 1 page and don't need info for pagination
*
*/
'findOne' => false,
/**
* Specify that it's okay for hidden pages to be included in the results
*
*/
'findHidden' => false,
/**
* Specify that it's okay for hidden AND unpublished pages to be included in the results
*
*/
'findUnpublished' => false,
/**
* Specify that it's okay for hidden AND unpublished AND trashed pages to be included in the results
*
*/
'findTrash' => false,
/**
* Specify that no page should be excluded - results can include unpublished, trash, system, no-access pages, etc.
*
*/
'findAll' => false,
/**
* Always allow these page IDs to be included regardless of findHidden, findUnpublished, findTrash, findAll settings
*
*/
'alwaysAllowIDs' => array(),
/**
* This is an optimization used by the Pages::find method, but we observe it here as we may be able
* to apply some additional optimizations in certain cases. For instance, if loadPages=false, then
* we can skip retrieval of IDs and omit sort fields.
*
*/
'loadPages' => true,
/**
* When true, this function returns array of arrays containing page ID, parent ID, template ID and score.
* When false, returns only an array of page IDs. returnVerbose=true is required by most usage from Pages
* class. False is only for specific cases.
*
*/
'returnVerbose' => true,
/**
* Return parent IDs rather than page IDs? (requires that returnVerbose is false)
*
*/
'returnParentIDs' => false,
/**
* Return [ page_id => template_id ] IDs array? (cannot be combined with other 'return*' options)
* @since 3.0.152
*
*/
'returnTemplateIDs' => false,
/**
* Return all columns from pages table (cannot be combined with other 'return*' options)
* @since 3.0.153
*
*/
'returnAllCols' => false,
/**
* Additional options when when 'returnAllCols' option is true
* @since 3.0.172
*
*/
'returnAllColsOptions' => array(
'joinFields' => array(), // names of additional fields to join
'joinSortfield' => false, // include 'sortfield' in returned columns? (joined from pages_sortfields table)
'joinPath' => false, // include the 'path' in returned columns (joined from pages_paths table, requires PagePaths module)
'getNumChildren' => false, // include 'numChildren' in returned columns? (sub-select from pages table)
'unixTimestamps' => false, // return dates as unix timestamps?
),
/**
* When true, only the DatabaseQuery object is returned by find(), for internal use.
*
*/
'returnQuery' => false,
/**
* Whether the total quantity of matches should be determined and accessible from getTotal()
*
* null: determine automatically (disabled when limit=1, enabled in all other cases)
* true: always calculate total
* false: never calculate total
*
*/
'getTotal' => null,
/**
* Method to use when counting total records
*
* If 'count', total will be calculated using a COUNT(*).
* If 'calc, total will calculate using SQL_CALC_FOUND_ROWS.
* If blank or something else, method will be determined automatically.
*
*/
'getTotalType' => 'calc',
/**
* Only start loading pages after this ID
*
*/
'startAfterID' => 0,
/**
* Stop and load no more if a page having this ID is found
*
*/
'stopBeforeID' => 0,
/**
* For internal use with startAfterID or stopBeforeID (when combined with a 'limit=n' selector)
*
*/
'softLimit' => 0,
/**
* Reverse whatever sort is specified
*
*/
'reverseSort' => false,
/**
* Allow use of _custom="another selector" in Selectors?
*
*/
'allowCustom' => false,
/**
* Use sortsAfter feature where PageFinder lets you perform the sorting manually after the find()
*
* When in use, you can access the PageFinder::getSortsAfter() method to retrieve an array of sort
* fields that should be sent to PageArray::sort()
*
* So far this option seems to add more overhead in most cases (rather than save it) so recommend not
* using it. Kept for further experimenting.
*
*/
'useSortsAfter' => false,
/**
* Options passed to DatabaseQuery::bindOptions() for primary query generated by this PageFinder
*
*/
'bindOptions' => array(),
);
/**
* @var Fields
*
*/
protected $fields;
/**
* @var Pages
*
*/
protected $pages;
/**
* @var Sanitizer
*
*/
protected $sanitizer;
/**
* @var WireDatabasePDO
*
*/
protected $database;
/**
* @var Languages|null
*
*/
protected $languages;
/**
* @var Templates
*
*/
protected $templates;
/**
* @var Config
*
*/
protected $config;
/**
* Whether to find the total number of matches
*
* @var bool
*
*/
protected $getTotal = true;
/**
* Method to use for getting total, may be: 'calc', 'count', or blank to auto-detect.
*
* @var string
*
*/
protected $getTotalType = 'calc';
/**
* Total found
*
* @var int
*
*/
protected $total = 0;
/**
* Limit setting for pagination
*
* @var int
*
*/
protected $limit = 0;
/**
* Start setting for pagination
*
* @var int
*
*/
protected $start = 0;
/**
* Parent ID value when query includes a single parent
*
* @var int|null
*
*/
protected $parent_id = null;
/**
* Templates ID value when query includes a single template
* @var null
*
*/
protected $templates_id = null;
/**
* Check access enabled? Becomes false if check_access=0 or include=all
*
* @var bool
*
*/
protected $checkAccess = true;
/**
* Include mode (when specified): all, hidden, unpublished
*
* @var string
*
*/
protected $includeMode = '';
/**
* Number of times the getQueryNumChildren() method has been called
*
* @var int
*
*/
protected $getQueryNumChildren = 0;
/**
* Options that were used in the most recent find()
*
* @var array
*
*/
protected $lastOptions = array();
/**
* Extra OR selectors used for OR-groups, array of arrays indexed by group name
*
* @var array
*
*/
protected $extraOrSelectors = array(); // one from each field must match
/**
* Array of sortfields that should be applied to resulting PageArray after loaded
*
* Also see `useSortsAfter` option
*
* @var array
*
*/
protected $sortsAfter = array();
/**
* Reverse order of pages after load?
*
* @var bool
*
*/
protected $reverseAfter = false;
/**
* Data that should be conditionally populated back to any resulting PageArrays data() method
*
* @var array
*
*/
protected $pageArrayData = array(
/* may include:
'fields' => array()
'extends' => array()
'joinFields' => array()
*/
);
/**
* The fully parsed/final selectors used in the last find() operation
*
* @var Selectors|null
*
*/
protected $finalSelectors = null; // Fully parsed final selectors
/**
* Number of Selector objects that have alternate operators
*
* @var int
*
*/
protected $numAltOperators = 0;
/**
* Cached value from supportsLanguagePageNames() method
*
* @var null|bool
*
*/
protected $supportsLanguagePageNames = null;
/**
* Fields that can only be used by themselves (not OR'd with other fields)
*
* @var array
*
*/
protected $singlesFields = array(
'has_parent',
'hasParent',
'num_children',
'numChildren',
'children.count',
'limit',
'start',
);
// protected $extraSubSelectors = array(); // subselectors that are added in after getQuery()
// protected $extraJoins = array();
// protected $nativeWheres = array(); // where statements for native fields, to be reused in subselects where appropriate.
public function __get($name) {
if($name === 'includeMode') return $this->includeMode;
if($name === 'checkAccess') return $this->checkAccess;
return parent::__get($name);
}
/**
* Initialize new find operation and prepare options
*
* @param Selectors $selectors
* @param array $options
* @return array Returns updated options with all present
*
*/
protected function init(Selectors $selectors, array $options) {
$this->fields = $this->wire('fields');
$this->pages = $this->wire('pages');
$this->sanitizer = $this->wire('sanitizer');
$this->database = $this->wire('database');
$this->languages = $this->wire('languages');
$this->templates = $this->wire('templates');
$this->config = $this->wire('config');
$this->parent_id = null;
$this->templates_id = null;
$this->checkAccess = true;
$this->getQueryNumChildren = 0;
$this->pageArrayData = array();
$options = array_merge($this->defaultOptions, $options);
$options = $this->initSelectors($selectors, $options);
// move getTotal option to a class property, after initStatusChecks
$this->getTotal = $options['getTotal'];
$this->getTotalType = $options['getTotalType'] == 'count' ? 'count' : 'calc';
unset($options['getTotal']); // so we get a notice if we try to access it
$this->lastOptions = $options;
return $options;
}
/**
* Initialize the selectors to add Page status checks
*
* @param Selectors $selectors
* @param array $options
* @return array
*
*/
protected function initSelectors(Selectors $selectors, array $options) {
$limit = 0; // for getTotal auto detection
$start = 0;
$limitSelector = null;
$startSelector = null;
$addSelectors = array();
$hasParents = array(); // requests for parent(s) in the selector
$hasSort = false; // whether or not a sort is requested
// field names that do not accept array values
$noArrayFields = array(
'status' => 1, // 1: array not allowed for field only
'include' => 2, // 2: array not allowed for field or value
'check_access' => 2,
'checkAccess' => 2,
'limit' => 1,
'start' => 2,
'getTotal' => 2,
'get_total' => 2,
);
// include mode names to option names
$includeOptions = array(
'hidden' => 'findHidden',
'unpublished' => 'findUnpublished',
'trash' => 'findTrash',
'all' => 'findAll',
);
foreach($selectors as $key => $selector) {
$fieldName = $selector->field;
$operator = $selector->operator;
$value = $selector->value;
$disallow = '';
if(is_array($fieldName)) {
foreach($fieldName as $name) {
if(isset($noArrayFields[$name])) $disallow = "field:$name";
if($disallow) break;
}
$fieldName = $selector->field(); // force string
} else if(isset($noArrayFields[$fieldName]) && is_array($value)) {
if($noArrayFields[$fieldName] > 1) $disallow = 'value';
}
if($disallow) {
$this->syntaxError("OR-condition not supported for $disallow in '$selector'");
}
if($fieldName === 'include') {
$value = strtolower($value);
if($operator !== '=') {
// disallowed operator for include
$this->syntaxError("Unsupported operator '$operator' in '$selector'");
} else if(!isset($includeOptions[$value])) {
// unrecognized include option
$useOnly = implode(', ', array_keys($includeOptions));
$this->syntaxError("Unrecognized '$value' in '$selector' - use only: $useOnly");
} else {
// i.e. hidden=findHidden, findUnpublished, findTrash, findAll
$option = $includeOptions[$value];
$options[$option] = true;
$this->includeMode = $value;
$selectors->remove($key);
}
} else if($fieldName === 'limit') {
// for getTotal auto detect
if(is_array($value)) {
if(count($value) === 2) {
// limit and start, i.e. limit=20,10 means start at 20 and limit to 10
$limit = (int) $value[1];
if(!$startSelector) {
// use start value only if it was not previously specified
$start = (int) $value[0];
$startSelector = new SelectorEqual('start', $start);
$addSelectors['start'] = $startSelector;
}
} else {
$limit = (int) $value[0];
}
$selector->value = $limit;
} else {
$limit = (int) $value;
}
$limitSelector = $selector;
} else if($fieldName === 'start') {
// for getTotal auto detect
$start = (int) $value;
$startSelector = $selector;
unset($addSelectors['start']); // just in case specified twice
} else if($fieldName === 'sort') {
// sorting is not needed if we are only retrieving totals
if($options['loadPages'] === false) $selectors->remove($selector);
$hasSort = true;
} else if($fieldName === 'parent' || $fieldName === 'parent_id') {
$hasParents[] = $value;
} else if($fieldName === 'getTotal' || $fieldName === 'get_total') {
// whether to retrieve the total, and optionally what type: calc or count
// this applies only if user hasn't themselves created a field called getTotal or get_total
if($this->fields->get($fieldName)) {
// user has created a field having name 'getTotal' or 'get_total'
// so we do not provide the getTotal option
} else {
if(ctype_digit("$value")) {
$options['getTotal'] = (bool) ((int) $value);
} else if($value === 'calc' || $value === 'count') {
$options['getTotal'] = true;
$options['getTotalType'] = $value;
} else {
// warning: unknown getTotal type
$options['getTotal'] = $value ? true : false;
}
$selectors->remove($selector);
}
} else if($fieldName === 'children' || $fieldName === 'child') {
// i.e. children=/path/to/page|/another/path - convert to IDs
$values = is_array($value) ? $value : array($value);
foreach($values as $k => $v) {
if(ctype_digit("$v")) continue;
if(strpos($v, '/') !== 0) continue;
$child = $this->pages->get($v);
$values[$k] = $child->id;
}
$selector->value = count($values) > 1 ? $values : reset($values);
}
} // foreach($selectors)
foreach($addSelectors as $selector) {
$selectors->add($selector);
}
// find max status, and update selector to bitwise when needed
$this->initStatus($selectors, $options);
if($options['findOne']) {
// findOne option is never paginated, always starts at 0
if($startSelector) $selectors->remove($startSelector);
$selectors->add(new SelectorEqual('start', 0));
if(empty($options['startAfterID']) && empty($options['stopBeforeID'])) {
if($limitSelector) $selectors->remove($limitSelector);
$selectors->add(new SelectorEqual('limit', 1));
}
// getTotal default is false when only finding 1 page
if(is_null($options['getTotal'])) $options['getTotal'] = false;
} else if(!$limit && !$start) {
// getTotal is not necessary since there is no limit specified (getTotal=same as count)
if(is_null($options['getTotal'])) $options['getTotal'] = false;
} else {
// get Total default is true when finding multiple pages
if(is_null($options['getTotal'])) $options['getTotal'] = true;
}
if(count($hasParents) === 1 && !$hasSort) {
// if single parent specified and no sort requested, default to the sort specified with the requested parent
try {
$parent = $this->pages->get(reset($hasParents));
} catch(\Exception $e) {
// don't try to add sort
$parent = null;
}
if($parent && $parent->id) {
$sort = $parent->template->sortfield;
if(!$sort) $sort = $parent->sortfield;
if($sort) $selectors->add(new SelectorEqual('sort', $sort));
}
}
if(!$options['findOne'] && $limitSelector && ($options['startAfterID'] || $options['stopBeforeID'])) {
$options['softLimit'] = $limit;
$selectors->remove($limitSelector);
}
return $options;
}
/**
* Initialize status checks
*
* @param Selectors $selectors
* @param array $options
*
*/
protected function initStatus(Selectors $selectors, array $options) {
$maxStatus = null;
$lessStatus = 0;
$statuses = array(); // i.e. [ 'hidden' => 1024, 'unpublished' => 2048, ], etc
$checkAccessSpecified = false;
$findAll = $options['findAll'];
$findTrash = $options['findTrash'];
$findHidden = $options['findHidden'];
$findUnpublished = $options['findUnpublished'];
foreach($selectors as $key => $selector) {
$fieldName = $selector->field();
if($fieldName === 'check_access' || $fieldName === 'checkAccess') {
if($fieldName === 'checkAccess') $selector->field = 'check_access';
$this->checkAccess = ((int) $selector->value()) > 0 ? true : false;
$checkAccessSpecified = true;
$selectors->remove($key);
continue;
} else if($fieldName !== 'status') {
continue;
}
$operator = $selector->operator;
$values = $selector->values();
$qty = count($values);
$not = false;
// convert status name labels to status integers
foreach($values as $k => $v) {
if(ctype_digit("$v")) {
$v = (int) $v;
} else {
// allow use of some predefined labels for Page statuses
$v = strtolower($v);
if(empty($statuses)) $statuses = Page::getStatuses();
$v = isset($statuses[$v]) ? $statuses[$v] : 1;
}
$values[$k] = $v;
}
if(($operator === '!=' && !$selector->not) || ($selector->not && $operator === '=')) {
// NOT MATCH condition: replace with bitwise AND NOT selector
/** @var Selector $s */
$s = $this->wire(new SelectorBitwiseAnd('status', $qty > 1 ? $values : reset($values)));
$s->not = true;
$not = true;
$selectors[$key] = $s;
} else if($operator === '=' || ($operator === '!=' && $selector->not)) {
// MATCH condition: replace with bitwise AND selector
$selectors[$key] = $this->wire(new SelectorBitwiseAnd('status', $qty > 1 ? $values : reset($values)));
} else {
// some other operator like: >, <, >=, <=, &
$not = $selector->not;
}
if($not) {
// NOT condition does not apply to maxStatus
} else {
foreach($values as $v) {
if($maxStatus === null || $v > $maxStatus) $maxStatus = (int) $v;
}
}
}
if($findAll) {
// findAll option means that unpublished, hidden, trash, system may be included
if(!$checkAccessSpecified) $this->checkAccess = false;
} else if($findHidden) {
$lessStatus = Page::statusUnpublished;
} else if($findUnpublished) {
$lessStatus = Page::statusTrash;
} else if($findTrash) {
$lessStatus = Page::statusDeleted;
} else if($maxStatus !== null) {
// status already present in the selector, without a findAll/findUnpublished/findHidden: use maxStatus value
if($maxStatus < Page::statusHidden) {
$lessStatus = Page::statusHidden;
} else if($maxStatus < Page::statusUnpublished) {
$lessStatus = Page::statusUnpublished;
} else if($maxStatus < Page::statusTrash) {
$lessStatus = Page::statusTrash;
}
} else {
// no status is present, so exclude everything hidden and above
$lessStatus = Page::statusHidden;
}
if($lessStatus) {
$selectors->add(new SelectorLessThan('status', $lessStatus));
}
}
/**
* Return all pages matching the given selector.
*
* @param Selectors|string|array $selectors Selectors object, selector string or selector array
* @param array $options
* - `findOne` (bool): Specify that you only want to find 1 page and don't need info for pagination (default=false).
* - `findHidden` (bool): Specify that it's okay for hidden pages to be included in the results (default=false).
* - `findUnpublished` (bool): Specify that it's okay for hidden AND unpublished pages to be included in the
* results (default=false).
* - `findTrash` (bool): Specify that it's okay for hidden AND unpublished AND trashed pages to be included in the
* results (default=false).
* - `findAll` (bool): Specify that no page should be excluded - results can include unpublished, trash, system,
* no-access pages, etc. (default=false)
* - `getTotal` (bool|null): Whether the total quantity of matches should be determined and accessible from
* getTotal() method call.
* - null: determine automatically (default is disabled when limit=1, enabled in all other cases).
* - true: always calculate total.
* - false: never calculate total.
* - `getTotalType` (string): Method to use to get total, specify 'count' or 'calc' (default='calc').
* - `returnQuery` (bool): When true, only the DatabaseQuery object is returned by find(), for internal use. (default=false)
* - `loadPages` (bool): This is an optimization used by the Pages::find() method, but we observe it here as we
* may be able to apply some additional optimizations in certain cases. For instance, if loadPages=false, then
* we can skip retrieval of IDs and omit sort fields. (default=true)
* - `stopBeforeID` (int): Stop loading pages once a page matching this ID is found. Page having this ID will be
* excluded as well (default=0).
* - `startAfterID` (int): Start loading pages once a page matching this ID is found. Page having this ID will be
* excluded as well (default=0).
* - `reverseSort` (bool): Reverse whatever sort is specified.
* - `returnVerbose` (bool): When true, this function returns array of arrays containing page ID, parent ID,
* template ID and score. When false, returns only an array of page IDs. True is required by most usage from
* Pages class. False is only for specific cases.
* - `returnParentIDs` (bool): Return parent IDs only? (default=false, requires that 'returnVerbose' option is false).
* - `returnTemplateIDs` (bool): Return [pageID => templateID] array? [3.0.152+ only] (default=false, cannot be combined with other 'return*' options).
* - `returnAllCols` (bool): Return [pageID => [ all columns ]] array? [3.0.153+ only] (default=false, cannot be combined with other 'return*' options).
* - `allowCustom` (bool): Whether or not to allow _custom='selector string' type values (default=false).
* - `useSortsAfter` (bool): When true, PageFinder may ask caller to perform sort manually in some cases (default=false).
* @return array|DatabaseQuerySelect
* @throws PageFinderException
*
*/
public function ___find($selectors, array $options = array()) {
if(is_string($selectors) || is_array($selectors)) {
list($s, $selectors) = array($selectors, $this->wire(new Selectors()));
/** @var Selectors $selectors */
$selectors->init($s);
} else if(!$selectors instanceof Selectors) {
throw new PageFinderException("find() requires Selectors object, string or array");
}
$options = $this->init($selectors, $options);
$stopBeforeID = (int) $options['stopBeforeID'];
$startAfterID = (int) $options['startAfterID'];
$database = $this->database;
$matches = array();
$query = $this->getQuery($selectors, $options); /** @var DatabaseQuerySelect $query */
if($options['returnQuery']) return $query;
if($options['loadPages'] || $this->getTotalType === 'calc') {
try {
$stmt = $query->prepare();
$database->execute($stmt);
$error = '';
} catch(\Exception $e) {
$this->trackException($e, true);
$error = $e->getMessage();
$stmt = null;
}
if($error) {
$this->log($error);
throw new PageFinderException($error);
}
if($options['loadPages']) {
$softCnt = 0; // for startAfterID when combined with 'limit'
/** @noinspection PhpAssignmentInConditionInspection */
while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
if($startAfterID > 0) {
if($row['id'] != $startAfterID) continue;
$startAfterID = -1; // -1 indicates that recording may start
continue;
}
if($stopBeforeID && $row['id'] == $stopBeforeID) {
if($options['findOne']) {
$matches = count($matches) ? array(end($matches)) : array();
} else if($options['softLimit']) {
$matches = array_slice($matches, -1 * $options['softLimit']);
}
break;
}
if($options['returnVerbose']) {
// determine score for this row
$score = 0.0;
foreach($row as $k => $v) if(strpos($k, '_score_') === 0) {
$v = (float) $v;
if($v === 111.1 || $v === 222.2 || $v === 333.3) continue; // signal scores of non-match
$score += $v;
unset($row[$k]);
}
$row['score'] = $score;
$matches[] = $row;
} else if($options['returnAllCols']) {
$matches[(int) $row['id']] = $row;
} else if($options['returnTemplateIDs']) {
$matches[(int) $row['id']] = (int) $row['templates_id'];
} else {
$matches[] = (int) $row['id'];
}
if($startAfterID === -1) {
// -1 indicates that recording may start
if($options['findOne']) {
break;
} else if($options['softLimit'] && ++$softCnt >= $options['softLimit']) {
break;
}
}
}
}
$stmt->closeCursor();
}
if($this->getTotal) {
if($this->getTotalType === 'count') {
$query->set('select', array('COUNT(*)'));
$query->set('orderby', array());
$query->set('groupby', array());
$query->set('limit', array());
$stmt = $query->execute();
$errorInfo = $stmt->errorInfo();
if($stmt->errorCode() > 0) throw new PageFinderException($errorInfo[2]);
list($this->total) = $stmt->fetch(\PDO::FETCH_NUM);
$stmt->closeCursor();
} else {
$this->total = (int) $database->query("SELECT FOUND_ROWS()")->fetchColumn();
}
} else {
$this->total = count($matches);
}
if(!$this->total && $this->numAltOperators) {
// check if any selectors provided alternate operators to try
$matches = $this->findAlt($selectors, $options, $matches);
}
$this->lastOptions = $options;
if($this->reverseAfter) $matches = array_reverse($matches);
return $matches;
}
/**
* Perform an alternate/fallback find when first fails to match and alternate operators available
*
* @param Selectors $selectors
* @param array $options
* @param array $matches
* @return array
*
*/
protected function findAlt($selectors, $options, $matches) {
// check if any selectors provided alternate operators to try
$numAlts = 0;
foreach($selectors as $key => $selector) {
$altOperators = $selector->altOperators;
if(!count($altOperators)) continue;
$altOperator = array_shift($altOperators);
$sel = Selectors::getSelectorByOperator($altOperator);
if(!$sel) continue;
$selector->copyTo($sel);
$selectors[$key] = $sel;
$numAlts++;
}
if(!$numAlts) return $matches;
$this->numAltOperators = 0;
return $this->___find($selectors, $options);
}
/**
* Same as find() but returns just a simple array of page IDs without any other info
*
* @param Selectors|string|array $selectors Selectors object, selector string or selector array
* @param array $options
* @return array of page IDs
*
*/
public function findIDs($selectors, $options = array()) {
$options['returnVerbose'] = false;
return $this->find($selectors, $options);
}
/**
* Returns array of arrays with all columns in pages table indexed by page ID
*
* @param Selectors|string|array $selectors Selectors object, selector string or selector array
* @param array $options
* - `joinFields` (array): Names of additional fields to join (default=[]) 3.0.172+
* - `joinSortfield` (bool): Include 'sortfield' in returned columns? Joined from pages_sortfields table. (default=false) 3.0.172+
* - `getNumChildren` (bool): Include 'numChildren' in returned columns? Calculated in query. (default=false) 3.0.172+
* - `unixTimestamps` (bool): Return created/modified/published dates as unix timestamps rather than ISO-8601? (default=false) 3.0.172+
* @return array|DatabaseQuerySelect
* @since 3.0.153
*
*/
public function findVerboseIDs($selectors, $options = array()) {
$hasCustomOptions = count($options) > 0;
$options['returnVerbose'] = false;
$options['returnAllCols'] = true;
$options['returnAllColsOptions'] = $this->defaultOptions['returnAllColsOptions'];
if($hasCustomOptions) {
// move some from $options into $options['returnAllColsOptions']
foreach($options['returnAllColsOptions'] as $name => $default) {
if(!isset($options[$name])) continue;
$options['returnAllColsOptions'][$name] = $options[$name];
unset($options[$name]);
}
}
return $this->find($selectors, $options);
}
/**
* Same as findIDs() but returns the parent IDs of the pages that matched
*
* @param Selectors|string|array $selectors Selectors object, selector string or selector array
* @param array $options
* @return array of page parent IDs
*
*/
public function findParentIDs($selectors, $options = array()) {
$options['returnVerbose'] = false;
$options['returnParentIDs'] = true;
return $this->find($selectors, $options);
}
/**
* Find template ID for each page  returns array of template IDs indexed by page ID
*
* @param Selectors|string|array $selectors Selectors object, selector string or selector array
* @param array $options
* @return array
* @since 3.0.152
*
*/
public function findTemplateIDs($selectors, $options = array()) {
$options['returnVerbose'] = false;
$options['returnParentIDs'] = false;
$options['returnTemplateIDs'] = true;
return $this->find($selectors, $options);
}
/**
* Return a count of pages that match
*
* @param Selectors|string|array $selectors Selectors object, selector string or selector array
* @param array $options
* @return int
* @since 3.0.121
*
*/
public function count($selectors, $options = array()) {
$defaults = array(
'getTotal' => true,
'getTotalType' => 'count',
'loadPages' => false,
'returnVerbose' => false
);
$options = array_merge($defaults, $options);
if(!empty($options['startBeforeID']) || !empty($options['stopAfterID'])) {
$options['loadPages'] = true;
$options['getTotalType'] = 'calc';
$count = count($this->find($selectors, $options));
} else {
$this->find($selectors, $options);
$count = $this->total;
}
return $count;
}
/**
* Pre-process given Selectors object
*
* @param Selectors $selectors
* @param array $options
*
*/
protected function preProcessSelectors(Selectors $selectors, $options = array()) {
$sortAfterSelectors = array();
$sortSelectors = array();
$start = null;
$limit = null;
$eq = null;
foreach($selectors as $selector) {
$field = $selector->field();
if($field === '_custom') {
$selectors->remove($selector);
if(!empty($options['allowCustom'])) {
$_selectors = $this->wire(new Selectors($selector->value()));
$this->preProcessSelectors($_selectors, $options);
/** @var Selectors $_selectors */
foreach($_selectors as $s) $selectors->add($s);
} else {
// use of _custom has not been specifically allowed
}
} else if($field === 'sort') {
$sortSelectors[] = $selector;
if(!empty($options['useSortsAfter']) && $selector->operator == '=' && strpos($selector->value, '.') === false) {
$sortAfterSelectors[] = $selector;
}
} else if($field === 'limit') {
$limit = (int) $selector->value;
} else if($field === 'start') {
$start = (int) $selector->value;
} else if($field === 'eq' || $field === 'index') {
if($this->fields->get($field)) continue;
$value = $selector->value;
if($value === 'first') {
$eq = 0;
} else if($value === 'last') {
$eq = -1;
} else {
$eq = (int) $value;
}
$selectors->remove($selector);
} else if(strpos($field, '.owner.') && !$this->fields->get('owner')) {
$selector->field = str_replace('.owner.', '__owner.', $selector->field());
} else if(stripos($field, 'Fieldtype') === 0) {
$this->preProcessFieldtypeSelector($selectors, $selector);
}
}
if(!is_null($eq)) {
if($eq === -1) {
$limit = -1;
$start = null;
} else if($eq === 0) {
$start = 0;
$limit = 1;
} else {
$start = $eq;
$limit = 1;
}
}
if(!$limit && !$start && count($sortAfterSelectors)
&& $options['returnVerbose'] && !empty($options['useSortsAfter'])
&& empty($options['startAfterID']) && empty($options['stopBeforeID'])) {
// the `useSortsAfter` option is enabled and potentially applicable
$sortsAfter = array();
foreach($sortAfterSelectors as $n => $selector) {
if(!$n && $this->pages->loader()->isNativeColumn($selector->value)) {
// first iteration only, see if it's a native column and prevent sortsAfter if so
break;
}
if(strpos($selector->value(), '.') !== false) {
// we don't supports sortsAfter for subfields, so abandon entirely
$sortsAfter = array();
break;
}
if($selector->operator != '=') {
// sort property being used for something else that we don't recognize
continue;
}
$sortsAfter[] = $selector->value;
$selectors->remove($selector);
}
$this->sortsAfter = $sortsAfter;
}
if($limit !== null && $limit < 0) {
// negative limit value means we pull results from end rather than start
if($start !== null && $start < 0) {
// we don't support a double negative, so double negative makes a positive
$start = abs($start);
$limit = abs($limit);
} else if($start > 0) {
$start = $start - abs($limit);
$limit = abs($limit);
} else {
$this->reverseAfter = true;
$limit = abs($limit);
}
}
if($start !== null && $start < 0) {
// negative start value means we start from a value from the end rather than the start
if($limit) {
// determine how many pages total and subtract from that to get start
$o = $options;
$o['getTotal'] = true;
$o['loadPages'] = false;
$o['returnVerbose'] = false;
$sel = clone $selectors;
foreach($sel as $s) {
if($s->field == 'limit' || $s->field == 'start') $sel->remove($s);
}
$sel->add(new SelectorEqual('limit', 1));
$finder = new PageFinder();
$this->wire($finder);
$finder->find($sel);
$total = $finder->getTotal();
$start = abs($start);
$start = $total - $start;
if($start < 0) $start = 0;
} else {
// same as negative limit
$this->reverseAfter = true;
$limit = abs($start);
$start = null;
}
}
if($this->reverseAfter) {
// reverse the sorts
foreach($sortSelectors as $s) {
if($s->operator != '=' || ctype_digit($s->value)) continue;
if(strpos($s->value, '-') === 0) {
$s->value = ltrim($s->value, '-');
} else {
$s->value = '-' . $s->value;
}
}
}
$this->limit = $limit;
$this->start = $start;
}
/**
* Pre-process a selector having field name that begins with "Fieldtype"
*
* @param Selectors $selectors
* @param Selector $selector
*
*/
protected function preProcessFieldtypeSelector(Selectors $selectors, Selector $selector) {
$foundFields = null;
$foundTypes = null;
$replaceFields = array();
$failFields = array();
$languages = $this->languages;
$fieldtypes = $this->wire()->fieldtypes;
$selectorCopy = null;
foreach($selector->fields() as $fieldName) {
$subfield = '';
$findPerField = false;
$findExtends = false;
if(strpos($fieldName, '.')) {
$parts = explode('.', $fieldName);
$fieldName = array_shift($parts);
foreach($parts as $k => $part) {
if($part === 'fields') {
$findPerField = true;
unset($parts[$k]);
} else if($part === 'extends') {
$findExtends = true;
unset($parts[$k]);
}
}
if(count($parts)) $subfield = implode('.', $parts);
}
$fieldtype = $fieldtypes->get($fieldName);
if(!$fieldtype) continue;
$fieldtypeLang = $languages ? $fieldtypes->get("{$fieldName}Language") : null;
foreach($this->fields as $f) {
/** @var Field $f */
if($findExtends) {
// allow any Fieldtype that is an instance of given one, or extends it
if(!wireInstanceOf($f->type, $fieldtype)
&& ($fieldtypeLang === null || !wireInstanceOf($f->type, $fieldtypeLang))) continue;
/** potential replacement for the above 2 lines
if($f->type->className() === $fieldName) {
// always allowed
} else if(!wireInstanceOf($f->type, $fieldtype) && ($fieldtypeLang === null || !wireInstanceOf($f->type, $fieldtypeLang))) {
// this fields type does not extend the one we are looking for
continue;
} else {
// looks good, but now check operators
$selectorInfo = $f->type->getSelectorInfo($f);
// if operator used in selector is not an allowed one, then skip over this field
if(!in_array($selector->operator(), $selectorInfo['operators'])) continue;
}
*/
} else {
// only allow given Fieldtype
if($f->type !== $fieldtype && ($fieldtypeLang === null || $f->type !== $fieldtypeLang)) continue;
}
$fName = $subfield ? "$f->name.$subfield" : $f->name;
if($findPerField) {
if($selectorCopy === null) $selectorCopy = clone $selector;
$selectorCopy->field = $fName;
$selectors->replace($selector, $selectorCopy);
$count = $this->pages->count($selectors);
$selectors->replace($selectorCopy, $selector);
if($count) {
if($foundFields === null) {
$foundFields = isset($this->pageArrayData['fields']) ? $this->pageArrayData['fields'] : array();
}
// include only fields that we know will match
$replaceFields[$fName] = $fName;
if(isset($foundFields[$fName])) {
$foundFields[$fName] += $count;
} else {
$foundFields[$fName] = $count;
}
} else {
$failFields[$fName] = $fName;
}
} else {
// include all fields (faster)
$replaceFields[$fName] = $fName;
}
if($findExtends) {
if($foundTypes === null) {
$foundTypes = isset($this->pageArrayData['extends']) ? $this->pageArrayData['extends'] : array();
}
$fType = $f->type->className();
if(isset($foundTypes[$fType])) {
$foundTypes[$fType][] = $fName;
} else {
$foundTypes[$fType] = array($fName);
}
}
}
}
if(count($replaceFields)) {
$selector->fields = array_values($replaceFields);
} else if(count($failFields)) {
// forced non-match and prevent field-not-found error after this method
$selector->field = reset($failFields);
}
if(is_array($foundFields)) {
arsort($foundFields);
$this->pageArrayData['fields'] = $foundFields;
}
if(is_array($foundTypes)) {
$this->pageArrayData['extends'] = $foundTypes;
}
}
/**
* Pre-process the given selector to perform any necessary replacements
*
* This is primarily used to handle sub-selections, i.e. "bar=foo, id=[this=that, foo=bar]"
* and OR-groups, i.e. "(bar=foo), (foo=bar)"
*
* @param Selector $selector
* @param Selectors $selectors
* @param array $options
* @param int $level
* @return bool|Selector Returns false if selector should be skipped over by getQuery(), returns Selector otherwise
* @throws PageFinderSyntaxException
*
*/
protected function preProcessSelector(Selector $selector, Selectors $selectors, array $options, $level = 0) {
$quote = $selector->quote;
$fieldsArray = $selector->fields;
$hasDoubleDot = false;
$tags = null;
foreach($fieldsArray as $key => $fn) {
$dot = strpos($fn, '.');
$parts = $dot ? explode('.', $fn) : array($fn);
// determine if it is a double-dot field (a.b.c)
if($dot && strrpos($fn, '.') !== $dot) {
if(strpos($fn, '__owner.') !== false) continue;
$hasDoubleDot = true;
}
// determine if it is referencing any tags that should be coverted to field1|field2|field3
foreach($parts as $partKey => $part) {
if($tags !== null && empty($tags)) continue;
if($this->fields->get($part)) continue; // maps to Field object
if($this->fields->isNative($part)) continue; // maps to native property
if($tags === null) $tags = $this->fields->getTags(true); // determine tags
if(!isset($tags[$part])) continue; // not a tag
$tagFields = $tags[$part];
foreach($tagFields as $k => $fieldName) {
$_parts = $parts;
$_parts[$partKey] = $fieldName;
$tagFields[$k] = implode('.', $_parts);
}
if(count($tagFields)) {
unset($fieldsArray[$key]);
$selector->fields = array_merge($fieldsArray, $tagFields);
}
}
}
if($quote == '[') {
// selector contains another embedded selector that we need to convert to page IDs
// i.e. field=[id>0, name=something, this=that]
$this->preProcessSubSelector($selector, $selectors);
} else if($quote == '(') {
// selector contains an OR group (quoted selector)
// at least one (quoted selector) must match for each field specified in front of it
$groupName = $selector->group ? $selector->group : $selector->getField('string');
$groupName = $this->sanitizer->fieldName($groupName);
if(!$groupName) $groupName = 'none';
if(!isset($this->extraOrSelectors[$groupName])) $this->extraOrSelectors[$groupName] = array();
if($selector->value instanceof Selectors) {
$this->extraOrSelectors[$groupName][] = $selector->value;
} else {
if($selector->group) {
// group is pre-identified, indicating Selector field=value is the OR-group condition
$s = clone $selector;
$s->quote = '';
$s->group = null;
$groupSelectors = new Selectors();
$groupSelectors->add($s);
} else {
// selector field is group name and selector value is another selector containing OR-group condition
$groupSelectors = new Selectors($selector->value);
}
$this->wire($groupSelectors);
$this->extraOrSelectors[$groupName][] = $groupSelectors;
}
return false;
} else if($hasDoubleDot) {
// has an "a.b.c" type string in the field, convert to a sub-selector
if(count($fieldsArray) > 1) {
$this->syntaxError("Multi-dot 'a.b.c' type selectors may not be used with OR '|' fields");
}
$fn = reset($fieldsArray);
$parts = explode('.', $fn);
$fieldName = array_shift($parts);
$field = $this->isPageField($fieldName);
if($field) {
// we have a workable page field
/** @var Selectors $_selectors */
if($options['findAll']) {
$s = "include=all";
} else if($options['findHidden']) {
$s = "include=hidden";
} else if($options['findUnpublished']) {
$s = "include=unpublished";
} else {
$s = '';
}
/** @var Selectors $_selectors */
$_selectors = $this->wire(new Selectors($s));
$_selector = $_selectors->create(implode('.', $parts), $selector->operator, $selector->values);
$_selectors->add($_selector);
$sel = new SelectorEqual("$fieldName", $_selectors);
$sel->quote = '[';
if(!$level) $selectors->replace($selector, $sel);
$selector = $sel;
$sel = $this->preProcessSelector($sel, $selectors, $options, $level + 1);
if($sel) $selector = $sel;
} else {
// not a page field
}
}
return $selector;
}
/*
* This turns out to be a lot slower than preProcessSubSelector(), but kept here for additional experiments
*
protected function preProcessSubquery(Selector $selector) {
$finder = $this->wire(new PageFinder());
$selectors = $selector->getValue();
if(!$selectors instanceof Selectors) return true; // not a sub-selector
$subfield = '';
$fieldName = $selector->field;
if(is_array($fieldName)) return true; // we don't allow OR conditions for field here
if(strpos($fieldName, '.')) list($fieldName, $subfield) = explode('.', $fieldName);
$field = $this->wire('fields')->get($fieldName);
if(!$field) return true; // does not resolve to a known field
$query = $finder->find($selectors, array(
'returnQuery' => true,
'returnVerbose' => false
));
$database = $this->wire('database');
$table = $database->escapeTable($field->getTable());
if($subfield == 'id' || !$subfield) {
$subfield = 'data';
} else {
$subfield = $database->escapeCol($this->wire('sanitizer')->fieldName($subfield));
}
if(!$table || !$subfield) return true;
static $n = 0;
$n++;
$tableAlias = "_subquery_{$n}_$table";
$join = "$table AS $tableAlias ON $tableAlias.pages_id=pages.id AND $tableAlias.$subfield IN (" . $query->getQuery() . ")";
echo $join . "<br />";
$this->extraJoins[] = $join;
}
*/
/**
* Pre-process a Selector that has a [quoted selector] embedded within its value
*
* @param Selector $selector
* @param Selectors $parentSelectors
*
*/
protected function preProcessSubSelector(Selector $selector, Selectors $parentSelectors) {
// Selector contains another embedded selector that we need to convert to page IDs.
// Example: "field=[id>0, name=something, this=that]" converts to "field.id=123|456|789"
$selectors = $selector->getValue();
if(!$selectors instanceof Selectors) return;
$hasTemplate = false;
$hasParent = false;
$hasInclude = false;
foreach($selectors as $s) {
if(is_array($s->field)) continue;
if($s->field == 'template') $hasTemplate = true;
if($s->field == 'parent' || $s->field == 'parent_id' || $s->field == 'parent.id') $hasParent = true;
if($s->field == 'include' || $s->field == 'status') $hasInclude = true;
}
if(!$hasInclude) {
// see if parent selector has an include mode, and copy it over to this one
foreach($parentSelectors as $s) {
if($s->field == 'include' || $s->field == 'status' || $s->field == 'check_access') {
$selectors->add(clone $s);
}
}
}
// special handling for page references, detect if parent or template is defined,
// and add it to the selector if available. This makes it faster.
if(!$hasTemplate || !$hasParent) {
$fields = is_array($selector->field) ? $selector->field : array($selector->field);
$templates = array();
$parents = array();
$findSelector = '';
foreach($fields as $fieldName) {
if(strpos($fieldName, '.') !== false) {
/** @noinspection PhpUnusedLocalVariableInspection */
list($unused, $fieldName) = explode('.', $fieldName);
}
$field = $this->fields->get($fieldName);
if(!$field) continue;
if(!$hasTemplate && ($field->get('template_id') || $field->get('template_ids'))) {
$templateIds = FieldtypePage::getTemplateIDs($field);
if(count($templateIds)) {
$templates = array_merge($templates, $templateIds);
}
}
if(!$hasParent) {
/** @var int|null $parentId */
$parentId = $field->get('parent_id');
if($parentId) {
if($this->isRepeaterFieldtype($field->type)) {
// repeater items not stored directly under parent_id, but as another parent under parent_id.
// so we use has_parent instead here
$selectors->prepend(new SelectorEqual('has_parent', $parentId));
} else {
// direct parent: FieldtypePage or similar
$parents[] = (int) $parentId;
}
}
}
if($field->get('findPagesSelector') && count($fields) == 1) {
$findSelector = $field->get('findPagesSelector');
}
}
if(count($templates)) $selectors->prepend(new SelectorEqual('template', $templates));
if(count($parents)) $selectors->prepend(new SelectorEqual('parent_id', $parents));
if($findSelector) {
foreach(new Selectors($findSelector) as $s) {
// add everything from findSelector, except for dynamic/runtime 'page.[something]' vars
if(strpos($s->getField('string'), 'page.') === 0 || strpos($s->getValue('string'), 'page.') === 0) continue;
$selectors->append($s);
}
}
}
/** @var PageFinder $pageFinder */
$pageFinder = $this->wire(new PageFinder());
$ids = $pageFinder->findIDs($selectors);
$fieldNames = $selector->fields;
$fieldName = reset($fieldNames);
$natives = array('parent', 'parent.id', 'parent_id', 'children', 'children.id', 'child', 'child.id');
// populate selector value with array of page IDs
if(count($ids) == 0) {
// subselector resulted in 0 matches
// force non-match for this subselector by populating 'id' subfield to field name(s)
$fieldNames = array();
foreach($selector->fields as $key => $fieldName) {
if(strpos($fieldName, '.') !== false) {
// reduce fieldName to just field name without subfield name
/** @noinspection PhpUnusedLocalVariableInspection */
list($fieldName, $subname) = explode('.', $fieldName); // subname intentionally unused
}
$field = $this->isPageField($fieldName);
if(is_string($field) && in_array($field, $natives)) {
// prevent matching something like parent_id=0, as that would match homepage
$fieldName = 'id';
} else if($field) {
$fieldName .= '.id';
} else {
// non-Page value field
$selector->forceMatch = false;
}
$fieldNames[$key] = $fieldName;
}
$selector->fields = $fieldNames;
$selector->value = 0;
} else if(in_array($fieldName, $natives)) {
// i.e. parent, parent_id, children, etc
$selector->value = count($ids) > 1 ? $ids : reset($ids);
} else {
$isPageField = $this->isPageField($fieldName, true);
if($isPageField) {
// FieldtypePage fields can use the "," separation syntax for speed optimization
$selector->value = count($ids) > 1 ? implode(',', $ids) : reset($ids);
} else {
// otherwise use array
$selector->value = count($ids) > 1 ? $ids : reset($ids);
}
}
$selector->quote = '';
}
/**
* Given one or more selectors, create the SQL query for finding pages.
*
* @TODO split this method up into more parts, it's too long
*
* @param Selectors $selectors Array of selectors.
* @param array $options
* @return DatabaseQuerySelect
* @throws PageFinderSyntaxException
*
*/
protected function ___getQuery($selectors, array $options) {
$where = '';
$fieldCnt = array(); // counts number of instances for each field to ensure unique table aliases for ANDs on the same field
$lastSelector = null;
$sortSelectors = array(); // selector containing 'sort=', which gets added last
$subqueries = array();
$joins = array();
$database = $this->database;
$autojoinTables = array();
$this->preProcessSelectors($selectors, $options);
$this->numAltOperators = 0;
/** @var DatabaseQuerySelect $query */
$query = $this->wire(new DatabaseQuerySelect());
if(!empty($options['bindOptions'])) {
foreach($options['bindOptions'] as $k => $v) $query->bindOption($k, $v);
}
if($options['returnAllCols']) {
$opts = $this->defaultOptions['returnAllColsOptions'];
if(!empty($options['returnAllColsOptions'])) $opts = array_merge($opts, $options['returnAllColsOptions']);
$columns = array('pages.*');
if($opts['unixTimestamps']) {
$columns[] = 'UNIX_TIMESTAMP(pages.created) AS created';
$columns[] = 'UNIX_TIMESTAMP(pages.modified) AS modified';
$columns[] = 'UNIX_TIMESTAMP(pages.published) AS published';
}
if($opts['joinSortfield']) {
$columns[] = 'pages_sortfields.sortfield AS sortfield';
$query->leftjoin('pages_sortfields ON pages_sortfields.pages_id=pages.id');
}
if($opts['getNumChildren']) {
$query->select('(SELECT COUNT(*) FROM pages AS children WHERE children.parent_id=pages.id) AS numChildren');
}
if($opts['joinPath']) {
if(!$this->wire()->modules->isInstalled('PagePaths')) {
throw new PageFinderException('Requested option for URL or path (joinPath) requires the PagePaths module be installed');
}
$columns[] = 'pages_paths.path AS path';
$query->leftjoin('pages_paths ON pages_paths.pages_id=pages.id');
}
if(!empty($opts['joinFields'])) {
$this->pageArrayData['joinFields'] = array(); // identify whether each field supported autojoin
foreach($opts['joinFields'] as $joinField) {
$joinField = $this->fields->get($joinField);
if(!$joinField instanceof Field) continue;
$joinTable = $database->escapeTable($joinField->getTable());
if(!$joinTable || !$joinField->type) continue;
if($joinField->type->getLoadQueryAutojoin($joinField, $query)) {
$autojoinTables[$joinTable] = $joinTable; // added at end if not already joined
$this->pageArrayData['joinFields'][$joinField->name] = true;
} else {
// fieldtype does not support autojoin
$this->pageArrayData['joinFields'][$joinField->name] = false;
}
}
}
} else if($options['returnVerbose']) {
$columns = array('pages.id', 'pages.parent_id', 'pages.templates_id');
} else if($options['returnParentIDs']) {
$columns = array('pages.parent_id AS id');
} else if($options['returnTemplateIDs']) {
$columns = array('pages.id', 'pages.templates_id');
} else {
$columns = array('pages.id');
}
$query->select($columns);
$query->from("pages");
$query->groupby($options['returnParentIDs'] ? 'pages.parent_id' : 'pages.id');
$this->getQueryStartLimit($query);
foreach($selectors as $selector) {
/** @var Selector $selector */
if(is_null($lastSelector)) $lastSelector = $selector;
$selector = $this->preProcessSelector($selector, $selectors, $options);
if(!$selector || $selector->forceMatch === true) continue;
if($selector->forceMatch === false) {
$query->where("1>2"); // force non match
continue;
}
$fields = $selector->fields();
$group = $selector->group; // i.e. @field
if(count($fields) > 1) $fields = $this->arrangeFields($fields);
$field1 = reset($fields); // first field including optional subfield
$this->numAltOperators += count($selector->altOperators);
// TODO Make native fields and path/url multi-field and multi-value aware
if($field1 === 'sort' && $selector->operator === '=') {
$sortSelectors[] = $selector;
continue;
} else if($field1 === 'sort' || $field1 === 'page.sort') {
if(!in_array($selector->operator, array('=', '!=', '<', '>', '>=', '<='))) {
$this->syntaxError("Property '$field1' may not use operator: $selector->operator");
}
$selector->field = 'sort';
$selector->value = (int) $selector->value();
$this->getQueryNativeField($query, $selector, array('sort'), $options, $selectors);
continue;
} else if($field1 === 'limit' || $field1 === 'start') {
continue;
} else if($field1 === 'path' || $field1 === 'url') {
$this->getQueryJoinPath($query, $selector);
continue;
} else if($field1 === 'has_parent' || $field1 === 'hasParent') {
$this->getQueryHasParent($query, $selector);
continue;
} else if($field1 === 'num_children' || $field1 === 'numChildren' || $field1 === 'children.count') {
$this->getQueryNumChildren($query, $selector);
continue;
} else if($this->hasNativeFieldName($fields)) {
$this->getQueryNativeField($query, $selector, $fields, $options, $selectors);
continue;
}
// where SQL specific to the foreach() of fields below, if needed.
// in this case only used by internally generated shortcuts like the blank value condition
$whereFields = '';
$whereFieldsType = 'AND';
foreach($fields as $fieldName) {
// if a specific DB field from the table has been specified, then get it, otherwise assume 'data'
if(strpos($fieldName, '.')) {
// if fieldName is "a.b.c" $subfields (plural) retains "b.c" while $subfield is just "b"
list($fieldName, $subfields) = explode('.', $fieldName, 2);
if(strpos($subfields, '.')) {
list($subfield) = explode('.', $subfields); // just the first
} else {
$subfield = $subfields;
}
} else {
$subfields = 'data';
$subfield = 'data';
}
$field = $this->fields->get($fieldName);
if(!$field) {
// field does not exist, see if it can be processed in some other way
$field = $this->getQueryUnknownField($fieldName, array(
'subfield' => $subfield,
'subfields' => $subfields,
'fields' => $fields,
'query' => $query,
'selector' => $selector,
'selectors' => $selectors
));
if($field === true) {
// true indicates the hook modified query to handle this (or ignore it), and should move to next field
continue;
} else if($field instanceof Field) {
// hook has mapped it to a field and processing of field should proceed
} else if($field) {
// mapped it to an API var or something else where we need not continue processing $field or $fields
break;
} else {
$this->syntaxError("Field does not exist: $fieldName");
}
}
// keep track of number of times this table name has appeared in the query
if(isset($fieldCnt[$field->table])) {
$fieldCnt[$field->table]++;
} else {
$fieldCnt[$field->table] = 0;
}
// use actual table name if first instance, if second instance of table then add a number at the end
$tableAlias = $field->table . ($fieldCnt[$field->table] ? $fieldCnt[$field->table] : '');
$tableAlias = $database->escapeTable($tableAlias);
$join = '';
$numEmptyValues = 0;
$valueArray = $selector->values(true);
$fieldtype = $field->type;
$operator = $selector->operator;
if($operator === '<>') $operator = '!=';
foreach($valueArray as $value) {
// shortcut for blank value condition: this ensures that NULL/non-existence is considered blank
// without this section the query would still work, but a blank value must actually be present in the field
$isEmptyValue = $fieldtype->isEmptyValue($field, $value);
$useEmpty = $isEmptyValue || $operator[0] === '<' || ((int) $value < 0 && $operator[0] === '>')
|| ($operator === '!=' && $isEmptyValue === false);
if($useEmpty && strpos($subfield, 'data') === 0) { // && !$fieldtype instanceof FieldtypeMulti) {
if($isEmptyValue) $numEmptyValues++;
if(in_array($operator, array('=', '!=', '<', '<=', '>', '>='))) {
// we only accommodate this optimization for single-value selectors...
if($this->whereEmptyValuePossible($field, $subfield, $selector, $query, $value, $whereFields)) {
if(count($valueArray) > 1 && $operator == '=') $whereFieldsType = 'OR';
continue;
}
}
}
/** @var DatabaseQuerySelect $q */
if(isset($subqueries[$tableAlias])) {
$q = $subqueries[$tableAlias];
} else {
$q = $this->wire(new DatabaseQuerySelect());
}
/** @var PageFinderDatabaseQuerySelect $q */
$q->set('field', $field); // original field if required by the fieldtype
$q->set('group', $group); // original group of the field, if required by the fieldtype
$q->set('selector', $selector); // original selector if required by the fieldtype
$q->set('selectors', $selectors); // original selectors (all) if required by the fieldtype
$q->set('parentQuery', $query);
$q->set('pageFinder', $this);
$q->bindOption('global', true); // ensures bound value key are globally unique
$q->bindOption('prefix', 'pf'); // pf=PageFinder
$q = $fieldtype->getMatchQuery($q, $tableAlias, $subfield, $selector->operator, $value);
$q->copyTo($query, array('select', 'join', 'leftjoin', 'orderby', 'groupby'));
$q->copyBindValuesTo($query);
if(count($q->where)) {
// $and = $selector->not ? "AND NOT" : "AND";
$and = "AND"; /// moved NOT condition to entire generated $sql
$sql = '';
foreach($q->where as $w) $sql .= $sql ? "$and $w " : "$w ";
$sql = "($sql) ";
if($selector->operator == '!=') {
$join .= ($join ? "\n\t\tAND $sql " : $sql);
} else if($selector->not) {
$sql = "((NOT $sql) OR ($tableAlias.pages_id IS NULL))";
$join .= ($join ? "\n\t\tAND $sql " : $sql);
} else {
$join .= ($join ? "\n\t\tOR $sql " : $sql);
}
}
}
if($join) {
$joinType = 'join';
if(count($fields) > 1
|| !empty($options['startAfterID']) || !empty($options['stopBeforeID'])
|| (count($valueArray) > 1 && $numEmptyValues > 0)
|| ($subfield == 'count' && !$this->isRepeaterFieldtype($field->type))
|| ($selector->not && $selector->operator != '!=')
|| $selector->operator == '!=') {
// join should instead be a leftjoin
$joinType = "leftjoin";
if($where) {
$whereType = $lastSelector->str == $selector->str ? "OR" : ") AND (";
$where .= "\n\t$whereType ($join) ";
} else {
$where .= "($join) ";
}
if($selector->not) {
// removes condition from join, but ensures we still have a $join
$join = '1=1';
}
}
// we compile the joins after going through all the selectors, so that we can
// match up conditions to the same tables
if(isset($joins[$tableAlias])) {
$joins[$tableAlias]['join'] .= " AND ($join) ";
} else {
$joins[$tableAlias] = array(
'joinType' => $joinType,
'table' => $field->table,
'tableAlias' => $tableAlias,
'join' => "($join)",
);
}
}
$lastSelector = $selector;
} // fields
if(strlen($whereFields)) {
if(strlen($where)) {
$where = "($where) $whereFieldsType ($whereFields)";
} else {
$where .= "($whereFields)";
}
}
} // selectors
if($where) $query->where("($where)");
$this->getQueryAllowedTemplates($query, $options);
// complete the joins, matching up any conditions for the same table
foreach($joins as $j) {
$joinType = $j['joinType'];
$query->$joinType("$j[table] AS $j[tableAlias] ON $j[tableAlias].pages_id=pages.id AND ($j[join])");
}
foreach($autojoinTables as $table) {
if(isset($fieldCnt[$table])) continue; // already joined
$query->leftjoin("$table ON $table.pages_id=pages.id");
}
if(count($sortSelectors)) {
foreach(array_reverse($sortSelectors) as $s) {
$this->getQuerySortSelector($query, $s);
}
}
if((!empty($options['startAfterID']) || !empty($options['stopBeforeID'])) && count($query->where)) {
$wheres = array('(' . implode(' AND ', $query->where) . ')');
$query->set('where', array());
foreach(array('startAfterID', 'stopBeforeID') as $key) {
if(empty($options[$key])) continue;
$bindKey = $query->bindValueGetKey($options[$key], \PDO::PARAM_INT);
array_unshift($wheres, "pages.id=$bindKey");
}
$query->where(implode("\n OR ", $wheres));
}
$this->postProcessQuery($query);
$this->finalSelectors = $selectors;
return $query;
}
/**
* Post process a DatabaseQuerySelect for page finder
*
* @param DatabaseQuerySelect $parentQuery
* @throws WireException
*
*/
protected function postProcessQuery($parentQuery) {
if(count($this->extraOrSelectors)) {
// there were embedded OR selectors where one of them must match
// i.e. id>0, field=(selector string), field=(selector string)
// in the above example at least one 'field' must match
// the 'field' portion is used only as a group name and isn't
// actually used as part of the resulting query or than to know
// what groups should be OR'd together
$sqls = array();
foreach($this->extraOrSelectors as /* $groupName => */ $selectorGroup) {
$n = 0;
$sql = "\tpages.id IN (\n";
foreach($selectorGroup as $selectors) {
$pageFinder = $this->wire(new PageFinder());
/** @var DatabaseQuerySelect $query */
$query = $pageFinder->find($selectors, array(
'returnQuery' => true,
'returnVerbose' => false,
'findAll' => true,
'bindOptions' => array(
'prefix' => 'pfor',
'global' => true,
)
));
if($n > 0) $sql .= " \n\tOR pages.id IN (\n";
$query->set('groupby', array());
$query->set('select', array('pages.id'));
$query->set('orderby', array());
$sql .= tabIndent("\t\t" . $query->getQuery() . "\n)", 2);
$query->copyBindValuesTo($parentQuery, array('inSQL' => $sql));
$n++;
}
$sqls[] = $sql;
}
if(count($sqls)) {
$sql = implode(" \n) AND (\n ", $sqls);
$parentQuery->where("(\n$sql\n)");
}
}
/* Possibly move existing subselectors to work like this rather than how they currently are
if(count($this->extraSubSelectors)) {
$sqls = array();
foreach($this->extraSubSelectors as $fieldName => $selectorGroup) {
$fieldName = $this->wire('database')->escapeCol($fieldName);
$n = 0;
$sql = "\tpages.id IN (\n";
foreach($selectorGroup as $selectors) {
$pageFinder = new PageFinder();
$query = $pageFinder->find($selectors, array('returnQuery' => true, 'returnVerbose' => false));
if($n > 0) $sql .= " \n\tAND pages.id IN (\n";
$query->set('groupby', array());
$query->set('select', array('pages.id'));
$query->set('orderby', array());
// foreach($this->nativeWheres as $where) $query->where($where);
$sql .= tabIndent("\t\t" . $query->getQuery() . "\n)", 2);
$n++;
}
$sqls[] = $sql;
}
if(count($sqls)) {
$sql = implode(" \n) AND (\n ", $sqls);
$parentQuery->where("(\n$sql\n)");
}
}
*/
}
/**
* Generate SQL and modify $query for situations where it should be possible to match empty values
*
* This can include equals/not-equals with blank or 0, as well as greater/less-than searches that
* can potentially match blank or 0.
*
* @param Field $field
* @param string $col
* @param Selector $selector
* @param DatabaseQuerySelect $query
* @param string $value The value presumed to be blank (passed the empty() test)
* @param string $where SQL where string that will be modified/appended
* @return bool Whether or not the query was handled and modified
*
*/
protected function whereEmptyValuePossible(Field $field, $col, $selector, $query, $value, &$where) {
// look in table that has no pages_id relation back to pages, using the LEFT JOIN / IS NULL trick
// OR check for blank value as defined by the fieldtype
static $tableCnt = 0;
$ft = $field->type;
$operator = $selector->operator;
$database = $this->database;
$table = $database->escapeTable($field->table);
$tableAlias = $table . "__blank" . (++$tableCnt);
$blankValue = $ft->getBlankValue(new NullPage(), $field);
$blankIsObject = is_object($blankValue);
$whereType = 'OR';
$sql = '';
$operators = array(
'=' => '!=',
'!=' => '=',
'<' => '>=',
'<=' => '>',
'>' => '<=',
'>=' => '<'
);
if($blankIsObject) $blankValue = '';
if(!isset($operators[$operator])) return false;
if($selector->not) $operator = $operators[$operator]; // reverse
if($col !== 'data' && !ctype_alnum($col)) {
// check for unsupported column
if(!ctype_alnum(str_replace('_', '', $col))) return false;
}
// ask Fieldtype if it would prefer to handle matching this empty value selector
if($ft->isEmptyValue($field, $selector)) {
// fieldtype will handle matching the selector in its getMatchQuery
return false;
} else if(($operator === '=' || $operator === '!=') && $ft->isEmptyValue($field, $value) && $ft->isEmptyValue($field, '0000-00-00')) {
// matching empty in date, datetime, timestamp column with equals or not-equals condition
// non-presence of row is required in order to match empty/blank (in MySQL 8.x)
$is = $operator === '=' ? 'IS' : 'IS NOT';
$sql = "$tableAlias.pages_id $is NULL ";
} else if($operator === '=') {
// equals
// non-presence of row is equal to value being blank
$bindKey = $query->bindValueGetKey($blankValue);
if($ft->isEmptyValue($field, $value)) {
// matching an empty value: null or literal empty value
$sql = "$tableAlias.$col IS NULL OR ($tableAlias.$col=$bindKey";
if($value === '' && !$ft->isEmptyValue($field, '0') && $field->get('zeroNotEmpty')) {
// MySQL blank string will also match zero (0) in some cases, so we prevent that here
// @todo remove the 'zeroNotEmpty' condition for test on dev as it limits to specific fieldtypes is likely unnecessary
$sql .= " AND $tableAlias.$col!='0'";
}
} else {
// matching a non-empty value
$sql = "($tableAlias.$col=$bindKey";
}
/*
if($value !== "0" && $blankValue !== "0" && !$ft->isEmptyValue($field, "0")) {
// if zero is not considered an empty value, exclude it from matching
// if the search isn't specifically for a "0"
$sql .= " AND $tableAlias.$col!='0'";
}
*/
$sql .= ")";
} else if($operator === '!=' || $operator === '<>') {
// not equals
$whereType = 'AND';
$zeroIsEmpty = $ft->isEmptyValue($field, "0");
$zeroIsNotEmpty = !$zeroIsEmpty;
$value = (string) $value;
$blankValue = (string) $blankValue;
if($value === '') {
// match present rows that do not contain a blank string (or 0, when applicable)
$sql = "$tableAlias.$col IS NOT NULL AND ($tableAlias.$col!=''";
if($zeroIsEmpty) {
$sql .= " AND $tableAlias.$col!='0'";
} else {
$sql .= " OR $tableAlias.$col='0'";
}
$sql .= ')';
} else if($value === "0" && $zeroIsNotEmpty) {
// may match non-rows (no value present) or row with value=0
$sql = "$tableAlias.$col IS NULL OR $tableAlias.$col!='0'";
} else if($value !== "0" && $zeroIsEmpty) {
// match all rows except empty and those having specific non-empty value
$bindKey = $query->bindValueGetKey($value);
$sql = "$tableAlias.$col IS NULL OR $tableAlias.$col!=$bindKey";
} else if($blankIsObject) {
// match all present rows
$sql = "$tableAlias.$col IS NOT NULL";
} else {
// match all present rows that are not blankValue and not given blank value...
$bindKeyBlank = $query->bindValueGetKey($blankValue);
$bindKeyValue = $query->bindValueGetKey($value);
$sql = "$tableAlias.$col IS NOT NULL AND $tableAlias.$col!=$bindKeyValue AND ($tableAlias.$col!=$bindKeyBlank";
if($zeroIsNotEmpty && $blankValue !== "0" && $value !== "0") {
// ...allow for 0 to match also if 0 is not considered empty value
$sql .= " OR $tableAlias.$col='0'";
}
$sql .= ")";
}
if($ft instanceof FieldtypeMulti && !$ft->isEmptyValue($field, $value)) {
// when a multi-row field is in use, exclude match when any of the rows contain $value
$tableMulti = $table . "__multi$tableCnt";
$bindKey = $query->bindValueGetKey($value);
$query->leftjoin("$table AS $tableMulti ON $tableMulti.pages_id=pages.id AND $tableMulti.$col=$bindKey");
$query->where("$tableMulti.$col IS NULL");
}
} else if($operator == '<' || $operator == '<=') {
// less than
if($value > 0 && $ft->isEmptyValue($field, "0")) {
// non-rows can be included as counting for 0
$bindKey = $query->bindValueGetKey($value);
$sql = "$tableAlias.$col IS NULL OR $tableAlias.$col$operator$bindKey";
} else {
// we won't handle it here
return false;
}
} else if($operator == '>' || $operator == '>=') {
if($value < 0 && $ft->isEmptyValue($field, "0")) {
// non-rows can be included as counting for 0
$bindKey = $query->bindValueGetKey($value);
$sql = "$tableAlias.$col IS NULL OR $tableAlias.$col$operator$bindKey";
} else {
// we won't handle it here
return false;
}
}
$query->leftjoin("$table AS $tableAlias ON $tableAlias.pages_id=pages.id");
$where .= strlen($where) ? " $whereType ($sql)" : "($sql)";
return true;
}
/**
* Determine which templates the user is allowed to view
*
* @param DatabaseQuerySelect $query
* @param array $options
*
*/
protected function getQueryAllowedTemplates(DatabaseQuerySelect $query, $options) {
if($options) {}
// if access checking is disabled then skip this
if(!$this->checkAccess) return;
// no need to perform this checking if the user is superuser
$user = $this->wire()->user;
if($user->isSuperuser()) return;
static $where = null;
static $where2 = null;
static $leftjoin = null;
static $cacheUserID = null;
if($cacheUserID !== $user->id) {
// clear cached values
$where = null;
$where2 = null;
$leftjoin = null;
$cacheUserID = $user->id;
}
$hasWhereHook = $this->wire()->hooks->isHooked('PageFinder::getQueryAllowedTemplatesWhere()');
// if a template was specified in the search, then we won't attempt to verify access
// if($this->templates_id) return;
// if findOne optimization is set, we don't check template access
// if($options['findOne']) return;
// if we've already figured out this part from a previous query, then use it
if(!is_null($where)) {
if($hasWhereHook) {
$where = $this->getQueryAllowedTemplatesWhere($query, $where);
$where2 = $this->getQueryAllowedTemplatesWhere($query, $where2);
}
$query->where($where);
$query->where($where2);
$query->leftjoin($leftjoin);
return;
}
// array of templates they ARE allowed to access
$yesTemplates = array();
// array of templates they are NOT allowed to access
$noTemplates = array();
$guestRoleID = $this->config->guestUserRolePageID;
$cacheUserID = $user->id;
if($user->isGuest()) {
// guest
foreach($this->templates as $template) {
/** @var Template $template */
if($template->guestSearchable || !$template->useRoles) {
$yesTemplates[$template->id] = $template;
continue;
}
foreach($template->roles as $role) {
if($role->id != $guestRoleID) continue;
$yesTemplates[$template->id] = $template;
break;
}
}
} else {
// other logged-in user
$userRoleIDs = array();
foreach($user->roles as $role) {
$userRoleIDs[] = $role->id;
}
foreach($this->templates as $template) {
/** @var Template $template */
if($template->guestSearchable || !$template->useRoles) {
$yesTemplates[$template->id] = $template;
continue;
}
foreach($template->roles as $role) {
if($role->id != $guestRoleID && !in_array($role->id, $userRoleIDs)) continue;
$yesTemplates[$template->id] = $template;
break;
}
}
}
// determine which templates the user is not allowed to access
foreach($this->templates as $template) {
/** @var Template $template */
if(!isset($yesTemplates[$template->id])) $noTemplates[$template->id] = $template;
}
$in = '';
$yesCnt = count($yesTemplates);
$noCnt = count($noTemplates);
if($noCnt) {
// pages_access lists pages that are inheriting access from others.
// join in any pages that are using any of the noTemplates to get their access.
// we want pages_access.pages_id to be NULL, which indicates that none of the
// noTemplates was joined, and the page is accessible to the user.
$leftjoin = "pages_access ON (pages_access.pages_id=pages.id AND pages_access.templates_id IN(";
foreach($noTemplates as $template) $leftjoin .= ((int) $template->id) . ",";
$leftjoin = rtrim($leftjoin, ",") . "))";
$query->leftjoin($leftjoin);
$where2 = "pages_access.pages_id IS NULL";
if($hasWhereHook) $where2 = $this->getQueryAllowedTemplatesWhere($query, $where2);
$query->where($where2);
}
if($noCnt > 0 && $noCnt < $yesCnt) {
$templates = $noTemplates;
$yes = false;
} else {
$templates = $yesTemplates;
$yes = true;
}
foreach($templates as $template) {
$in .= ((int) $template->id) . ",";
}
$in = rtrim($in, ",");
$where = "pages.templates_id ";
if($in && $yes) {
$where .= "IN($in)";
} else if($in) {
$where .= "NOT IN($in)";
} else {
$where = "<0"; // no match possible
}
// allow for hooks to modify or add to the WHERE conditions
if($hasWhereHook) $where = $this->getQueryAllowedTemplatesWhere($query, $where);
$query->where($where);
}
/**
* Method that allows external hooks to add to or modify the access control WHERE conditions
*
* Called only if it's hooked. To utilize it, modify the $where argument in a BEFORE hook
* or the $event->return in an AFTER hook.
*
* @param DatabaseQuerySelect $query
* @param string $where SQL string for WHERE statement, not including the actual "WHERE"
* @return string
*/
protected function ___getQueryAllowedTemplatesWhere(DatabaseQuerySelect $query, $where) {
return $where;
}
protected function getQuerySortSelector(DatabaseQuerySelect $query, Selector $selector) {
// $field = is_array($selector->field) ? reset($selector->field) : $selector->field;
$values = is_array($selector->value) ? $selector->value : array($selector->value);
$fields = $this->fields;
$pages = $this->pages;
$database = $this->database;
$user = $this->wire()->user;
$language = $this->languages && $user && $user->language ? $user->language : null;
// support `sort=a|b|c` in correct order (because orderby prepend used below)
if(count($values) > 1) $values = array_reverse($values);
foreach($values as $value) {
$fc = substr($value, 0, 1);
$lc = substr($value, -1);
$descending = $fc == '-' || $lc == '-';
$value = trim($value, "-+");
$subValue = '';
// $terValue = ''; // not currently used, here for future use
if($this->lastOptions['reverseSort']) $descending = !$descending;
if(strpos($value, ".")) {
list($value, $subValue) = explode(".", $value, 2); // i.e. some_field.title
if(strpos($subValue, ".")) {
list($subValue, $terValue) = explode(".", $subValue, 2);
$terValue = $this->sanitizer->fieldName($terValue);
if(strpos($terValue, ".")) $this->syntaxError("$value.$subValue.$terValue not supported");
}
$subValue = $this->sanitizer->fieldName($subValue);
}
$value = $this->sanitizer->fieldName($value);
if($value == 'parent' && $subValue == 'path') $subValue = 'name'; // path not supported, substitute name
if($value == 'random') {
$value = 'RAND()';
} else if($value == 'num_children' || $value == 'numChildren' || ($value == 'children' && $subValue == 'count')) {
// sort by quantity of children
$value = $this->getQueryNumChildren($query, $this->wire(new SelectorGreaterThan('num_children', "-1")));
} else if($value == 'parent' && ($subValue == 'num_children' || $subValue == 'numChildren' || $subValue == 'children')) {
throw new WireException("Sort by parent.num_children is not currently supported");
} else if($value == 'parent' && (empty($subValue) || $pages->loader()->isNativeColumn($subValue))) {
// sort by parent native field only
if(empty($subValue)) $subValue = 'name';
$subValue = $database->escapeCol($subValue);
$tableAlias = "_sort_parent_$subValue";
$query->join("pages AS $tableAlias ON $tableAlias.id=pages.parent_id");
$value = "$tableAlias.$subValue";
} else if($value == 'template') {
// sort by template
$tableAlias = $database->escapeTable("_sort_templates" . ($subValue ? "_$subValue" : ''));
$query->join("templates AS $tableAlias ON $tableAlias.id=pages.templates_id");
$value = "$tableAlias." . ($subValue ? $database->escapeCol($subValue) : "name");
} else if($fields->isNative($value) && !$subValue && $pages->loader()->isNativeColumn($value)) {
// sort by a native field (with no subfield)
if($value == 'name' && $language && !$language->isDefault() && $this->supportsLanguagePageNames()) {
// substitute language-specific name field when LanguageSupportPageNames is active and language is not default
$value = "if(pages.name$language!='', pages.name$language, pages.name)";
} else {
$value = "pages." . $database->escapeCol($value);
}
} else if(($value === 'path' || $value === 'url') && $this->wire()->modules->isInstalled('PagePaths')) {
static $pathN = 0;
$pathN++;
$pathsTable = "_sort_pages_paths$pathN";
if($language && !$language->isDefault() && $this->supportsLanguagePageNames()) {
$query->leftjoin("pages_paths AS $pathsTable ON $pathsTable.pages_id=pages.id AND $pathsTable.language_id=0");
$lid = (int) $language->id;
$asc = $descending ? 'DESC' : 'ASC';
$pathsLangTable = $pathsTable . "_$lid";
$s = "pages_paths AS $pathsLangTable ON $pathsLangTable.pages_id=pages.id AND $pathsLangTable.language_id=$lid";
$query->leftjoin($s);
$query->orderby("if($pathsLangTable.pages_id IS NULL, $pathsTable.path, $pathsLangTable.path) $asc");
$value = false;
} else {
$query->leftjoin("pages_paths AS $pathsTable ON $pathsTable.pages_id=pages.id");
$value = "$pathsTable.path";
}
} else {
// sort by custom field, or parent w/custom field
if($value == 'parent') {
$useParent = true;
$value = $subValue ? $subValue : 'title'; // needs a custom field, not "name"
$subValue = 'data';
$idColumn = 'parent_id';
} else {
$useParent = false;
$idColumn = 'id';
}
$field = $fields->get($value);
if(!$field) {
// unknown field
continue;
}
$fieldName = $database->escapeCol($field->name);
$subValue = $database->escapeCol($subValue);
$tableAlias = $useParent ? "_sort_parent_$fieldName" : "_sort_$fieldName";
if($subValue) $tableAlias .= "_$subValue";
$table = $database->escapeTable($field->table);
if($field->type instanceof FieldtypePage) {
$blankValue = new PageArray();
} else {
$blankValue = $field->type->getBlankValue($this->pages->newNullPage(), $field);
}
$query->leftjoin("$table AS $tableAlias ON $tableAlias.pages_id=pages.$idColumn");
$customValue = $field->type->getMatchQuerySort($field, $query, $tableAlias, $subValue, $descending);
if(!empty($customValue)) {
// Fieldtype handled it: boolean true (handled by Fieldtype) or string to add to orderby
if(is_string($customValue)) $query->orderby($customValue, true);
$value = false;
} else if($subValue === 'count') {
if($this->isRepeaterFieldtype($field->type)) {
// repeaters have a native count column that can be used for sorting
$value = "$tableAlias.count";
} else {
// sort by quantity of items
$value = "COUNT($tableAlias.data)";
}
} else if($blankValue instanceof PageArray || $blankValue instanceof Page) {
// If it's a FieldtypePage, then data isn't worth sorting on because it just contains an ID to the page
// so we also join the page and sort on it's name instead of the field's "data" field.
if(!$subValue) $subValue = 'name';
$tableAlias2 = "_sort_" . ($useParent ? 'parent' : 'page') . "_$fieldName" . ($subValue ? "_$subValue" : '');
if($this->fields->isNative($subValue) && $pages->loader()->isNativeColumn($subValue)) {
$query->leftjoin("pages AS $tableAlias2 ON $tableAlias.data=$tableAlias2.$idColumn");
$value = "$tableAlias2.$subValue";
if($subValue == 'name' && $language && !$language->isDefault() && $this->supportsLanguagePageNames()) {
// append language ID to 'name' when performing sorts within another language and LanguageSupportPageNames in place
$value = "if($value$language!='', $value$language, $value)";
}
} else if($subValue == 'parent') {
$query->leftjoin("pages AS $tableAlias2 ON $tableAlias.data=$tableAlias2.$idColumn");
$value = "$tableAlias2.name";
} else {
$subValueField = $this->fields->get($subValue);
if($subValueField) {
$subValueTable = $database->escapeTable($subValueField->getTable());
$query->leftjoin("$subValueTable AS $tableAlias2 ON $tableAlias.data=$tableAlias2.pages_id");
$value = "$tableAlias2.data";
if($language && !$language->isDefault() && $subValueField->type instanceof FieldtypeLanguageInterface) {
// append language id to data, i.e. "data1234"
$value .= $language;
}
} else {
// error: unknown field
}
}
} else if(!$subValue && $language && !$language->isDefault() && $field->type instanceof FieldtypeLanguageInterface) {
// multi-language field, sort by the language version
$value = "if($tableAlias.data$language != '', $tableAlias.data$language, $tableAlias.data)";
} else {
// regular field, just sort by data column
$value = "$tableAlias." . ($subValue ? $subValue : "data");
}
}
if(is_string($value) && strlen($value)) {
if($descending) {
$query->orderby("$value DESC", true);
} else {
$query->orderby("$value", true);
}
}
}
}
protected function getQueryStartLimit(DatabaseQuerySelect $query) {
$start = $this->start;
$limit = $this->limit;
if($limit) {
$limit = (int) $limit;
$input = $this->wire()->input;
$sql = '';
if(is_null($start) && $input) {
// if not specified in the selector, assume the 'start' property from the default page's pageNum
$pageNum = $input->pageNum - 1; // make it zero based for calculation
$start = $pageNum * $limit;
}
if(!is_null($start)) {
$start = (int) $start;
$this->start = $start;
$sql .= "$start,";
}
$sql .= "$limit";
if($this->getTotal && $this->getTotalType != 'count') $query->select("SQL_CALC_FOUND_ROWS");
if($sql) $query->limit($sql);
}
}
/**
* Special case when requested value is path or URL
*
* @param DatabaseQuerySelect $query
* @param Selector $selector
* @throws PageFinderSyntaxException
*
*/
protected function ___getQueryJoinPath(DatabaseQuerySelect $query, $selector) {
$database = $this->database;
$modules = $this->wire()->modules;
$sanitizer = $this->sanitizer;
// determine whether we will include use of multi-language page names
if($this->supportsLanguagePageNames()) {
$langNames = array();
foreach($this->languages as $language) {
/** @var Language $language */
if(!$language->isDefault()) $langNames[$language->id] = "name" . (int) $language->id;
}
if(!count($langNames)) $langNames = null;
} else {
$langNames = null;
}
if($modules->isInstalled('PagePaths')) {
$pagePaths = $modules->get('PagePaths');
/** @var PagePaths $pagePaths */
$pagePaths->getMatchQuery($query, $selector);
return;
}
if($selector->operator !== '=') {
$this->syntaxError("Operator '$selector->operator' is not supported for path or url unless: 1) non-multi-language; 2) you install the PagePaths module.");
}
$selectorValue = $selector->value;
if($selectorValue === '/') {
$parts = array();
$query->where("pages.id=1");
} else {
if(is_array($selectorValue)) {
// only the PagePaths module can perform OR value searches on path/url
if($langNames) {
$this->syntaxError("OR values not supported for multi-language 'path' or 'url'");
} else {
$this->syntaxError("OR value support of 'path' or 'url' requires core PagePaths module");
}
}
if($langNames) {
$module = $this->languages->pageNames();
if($module) $selectorValue = $module->removeLanguageSegment($selectorValue);
}
$parts = explode('/', rtrim($selectorValue, '/'));
$part = $sanitizer->pageName(array_pop($parts), Sanitizer::toAscii);
$bindKey = $query->bindValueGetKey($part);
$sql = "pages.name=$bindKey";
if($langNames) {
foreach($langNames as $langName) {
$bindKey = $query->bindValueGetKey($part);
$langName = $database->escapeCol($langName);
$sql .= " OR pages.$langName=$bindKey";
}
}
$query->where("($sql)");
if(!count($parts)) $query->where("pages.parent_id=1");
}
$alias = 'pages';
$lastAlias = 'pages';
/** @noinspection PhpAssignmentInConditionInspection */
while($n = count($parts)) {
$n = (int) $n;
$part = $sanitizer->pageName(array_pop($parts), Sanitizer::toAscii);
if(strlen($part)) {
$alias = "parent$n";
//$query->join("pages AS $alias ON ($lastAlias.parent_id=$alias.id AND $alias.name='$part')");
$bindKey = $query->bindValueGetKey($part);
$sql = "pages AS $alias ON ($lastAlias.parent_id=$alias.id AND ($alias.name=$bindKey";
if($langNames) foreach($langNames as /* $id => */ $name) {
// $status = "status" . (int) $id;
// $sql .= " OR ($alias.$name='$part' AND $alias.$status>0) ";
$bindKey = $query->bindValueGetKey($part);
$sql .= " OR $alias.$name=$bindKey";
}
$sql .= '))';
$query->join($sql);
} else {
$query->join("pages AS rootparent$n ON ($alias.parent_id=rootparent$n.id AND rootparent$n.id=1)");
}
$lastAlias = $alias;
}
}
/**
* Special case when field is native to the pages table
*
* TODO not all operators will work here, so may want to add some translation or filtering
*
* @param DatabaseQuerySelect $query
* @param Selector $selector
* @param array $fields
* @param array $options
* @param Selectors $selectors
* @throws PageFinderSyntaxException
*
*/
protected function getQueryNativeField(DatabaseQuerySelect $query, $selector, $fields, array $options, $selectors) {
$values = $selector->values(true);
$SQL = '';
$database = $this->database;
$sanitizer = $this->sanitizer;
$datetime = $this->wire()->datetime;
foreach($fields as $field) {
// the following fields are defined in each iteration here because they may be modified in the loop
$table = "pages";
$operator = $selector->operator;
$not = $selector->not;
$compareType = $selectors::getSelectorByOperator($operator, 'compareType');
$isPartialOperator = ($compareType & Selector::compareTypeFind);
$subfield = '';
$IDs = array(); // populated in special cases where we can just match parent IDs
$sql = '';
if(strpos($field, '.')) {
list($field, $subfield) = explode('.', $field);
$subfield = $sanitizer->fieldName($subfield);
}
$field = $sanitizer->fieldName($field);
if($field == 'sort' && $subfield) $subfield = '';
if($field == 'child') $field = 'children';
if($field != 'children' && !$this->fields->isNative($field)) {
$subfield = $field;
$field = '_pages';
}
$isParent = $field === 'parent' || $field === 'parent_id';
$isChildren = $field === 'children';
$isPages = $field === '_pages';
if($isParent || $isChildren || $isPages) {
// parent, children, pages
if(($isPages || $isParent) && !$isPartialOperator && (!$subfield || in_array($subfield, array('id', 'path', 'url')))) {
// match by location (id or path)
// convert parent fields like '/about/company/history' to the equivalent ID
foreach($values as $k => $v) {
if(ctype_digit("$v")) continue;
$v = $sanitizer->pagePathName($v, Sanitizer::toAscii);
if(strpos($v, '/') === false) $v = "/$v"; // prevent a plain string with no slashes
// convert path to id
$parent = $this->pages->get($v);
$values[$k] = $parent instanceof NullPage ? null : $parent->id;
}
$this->parent_id = null;
if($isParent) {
if($operator === '=') $IDs = $values;
$field = 'parent_id';
if(count($values) == 1 && count($fields) == 1 && $operator === '=') {
$this->parent_id = reset($values);
}
}
} else {
// matching by a parent's native or custom field (subfield)
if(!$this->fields->isNative($subfield)) {
$finder = $this->wire(new PageFinder());
$finderMethod = 'findIDs';
$includeSelector = 'include=all';
if($field === 'children' || $field === '_pages') {
if($subfield) {
$s = '';
if($field === 'children') $finderMethod = 'findParentIDs';
// inherit include mode from main selector
$includeSelector = $this->getIncludeSelector($selectors);
} else if($field === 'children') {
$s = 'children.id';
} else {
$s = 'id';
}
} else {
$s = 'children.count>0, ';
}
$IDs = $finder->$finderMethod(new Selectors(ltrim(
"$includeSelector," .
"$s$subfield$operator" . $sanitizer->selectorValue($values),
','
)));
if(!count($IDs)) $IDs[] = -1; // forced non match
} else {
// native
static $n = 0;
if($field === 'children') {
$table = "_children_native" . (++$n);
$query->join("pages AS $table ON $table.parent_id=pages.id");
} else if($field === '_pages') {
$table = 'pages';
} else {
$table = "_parent_native" . (++$n);
$query->join("pages AS $table ON pages.parent_id=$table.id");
}
$field = $subfield;
}
}
} else if($field === 'id' && count($values) > 1) {
if($operator === '=') {
$IDs = $values;
} else if($operator === '!=' && !$not) {
$not = true;
$operator = '=';
$IDs = $values;
}
} else {
// primary field is not 'parent', 'children' or 'pages'
}
if(count($IDs)) {
// parentIDs or IDs found via another query, and we don't need to match anything other than the parent ID
$in = $not ? "NOT IN" : "IN";
$sql .= in_array($field, array('parent', 'parent_id')) ? "$table.parent_id " : "$table.id ";
$IDs = $sanitizer->intArray($IDs, array('strict' => true));
$strIDs = count($IDs) ? implode(',', $IDs) : '-1';
$sql .= "$in($strIDs)";
if($subfield === 'sort') $query->orderby("FIELD($table.id, $strIDs)");
unset($strIDs);
} else foreach($values as $value) {
if(is_null($value)) {
// an invalid/unknown walue was specified, so make sure it fails
$sql .= "1>2";
continue;
}
if(in_array($field, array('templates_id', 'template'))) {
// convert templates specified as a name to the numeric template ID
// allows selectors like 'template=my_template_name'
$field = 'templates_id';
if(count($values) == 1 && $operator === '=') $this->templates_id = reset($values);
if(!ctype_digit("$value")) $value = (($template = $this->templates->get($value)) ? $template->id : 0);
} else if(in_array($field, array('created', 'modified', 'published'))) {
// prepare value for created, modified or published date fields
if(!ctype_digit("$value")) {
$value = $datetime->strtotime($value);
}
if(empty($value)) {
$value = null;
if($operator === '>' || $operator === '=>') {
$value = $field === 'published' ? '1000-01-01 00:00:00' : '1970-01-01 00:00:01';
}
} else {
$value = date('Y-m-d H:i:s', $value);
}
} else if(in_array($field, array('id', 'parent_id', 'templates_id', 'sort'))) {
$value = (int) $value;
}
$isName = $field === 'name' || strpos($field, 'name') === 0;
$isPath = $field === 'path' || $field === 'url';
$isNumChildren = $field === 'num_children' || $field === 'numChildren';
if($isName && $operator == '~=') {
// handle one or more space-separated full words match to 'name' field in any order
$s = '';
foreach(explode(' ', $value) as $n => $word) {
$word = $sanitizer->pageName($word, Sanitizer::toAscii);
if($database->getRegexEngine() === 'ICU') {
// MySQL 8.0.4+ uses ICU regex engine where "\\b" is used for word boundary
$bindKey = $query->bindValueGetKey("\\b$word\\b");
} else {
// this Henry Spencer regex engine syntax works only in MySQL 8.0.3 and prior
$bindKey = $query->bindValueGetKey('[[:<:]]' . $word . '[[:>:]]');
}
$s .= ($s ? ' AND ' : '') . "$table.$field RLIKE $bindKey";
}
} else if($isName && $isPartialOperator) {
// handle partial match to 'name' field
$value = $sanitizer->pageName($value, Sanitizer::toAscii);
if($operator == '^=' || $operator == '%^=') {
$value = "$value%";
} else if($operator == '$=' || $operator == '%$=') {
$value = "%$value";
} else {
$value = "%$value%";
}
$bindKey = $query->bindValueGetKey($value);
$s = "$table.$field LIKE $bindKey";
} else if(($isPath && $isPartialOperator) || $isNumChildren) {
// match some other property that we need to launch a separate find to determine the IDs
// used for partial match of path (used when original selector is parent.path%=...), parent.property, etc.
$tempSelector = trim($this->getIncludeSelector($selectors) . ", $field$operator" . $sanitizer->selectorValue($value), ',');
$tempIDs = $this->pages->findIDs($tempSelector);
if(count($tempIDs)) {
$s = "$table.id IN(" . implode(',', $sanitizer->intArray($tempIDs)) . ')';
} else {
$s = "$table.id=-1"; // force non-match
}
} else if(!$database->isOperator($operator)) {
$s = '';
$this->syntaxError("Operator '$operator' is not supported for '$field'.");
} else if($this->isModifierField($field)) {
$s = '';
$this->syntaxError("Modifier '$field' is not allowed here");
} else if(!$this->pagesColumnExists($field)) {
$s = '';
$this->syntaxError("Field '$field' is not a known field, column or selector modifier");
} else {
$not = false;
if($isName) $value = $sanitizer->pageName($value, Sanitizer::toAscii);
if($field === 'status' && !ctype_digit("$value")) {
// named status
$statuses = Page::getStatuses();
if(!isset($statuses[$value])) $this->syntaxError("Unknown Page status: '$value'");
$value = (int) $statuses[$value];
if($operator === '=' || $operator === '!=') $operator = '&'; // bitwise
if($operator === '!=') $not = true;
}
if($value === null) {
$s = "$table.$field " . ($not ? 'IS NOT NULL' : 'IS NULL');
} else {
if(ctype_digit("$value") && $field != 'name') $value = (int) $value;
$bindKey = $query->bindValueGetKey($value);
$s = "$table.$field" . $operator . $bindKey;
if($not) $s = "NOT ($s)";
}
if($field === 'status' && strpos($operator, '<') === 0 && $value >= Page::statusHidden && count($options['alwaysAllowIDs'])) {
// support the 'alwaysAllowIDs' option for specific page IDs when requested but would
// not otherwise appear in the results due to hidden or unpublished status
$allowIDs = array();
foreach($options['alwaysAllowIDs'] as $id) $allowIDs[] = (int) $id;
$s = "($s OR $table.id IN(" . implode(',', $allowIDs) . '))';
}
}
if($selector->not) $s = "NOT ($s)";
if($operator == '!=' || $selector->not) {
$sql .= $sql ? " AND $s": "$s";
} else {
$sql .= $sql ? " OR $s": "$s";
}
}
if($sql) {
if($SQL) {
$SQL .= " OR ($sql)";
} else {
$SQL .= "($sql)";
}
}
}
if(count($fields) > 1) {
$SQL = "($SQL)";
}
$query->where($SQL);
//$this->nativeWheres[] = $SQL;
}
/**
* Get the include|status|check_access portions from given Selectors and return selector string for them
*
* If given $selectors lacks an include or check_access selector, then it will pull from the
* equivalent PageFinder setting if present in the original initiating selector.
*
* @param Selectors|string $selectors
* @return string
*
*/
protected function getIncludeSelector($selectors) {
if(!$selectors instanceof Selectors) $selectors = new Selectors($selectors);
$a = array();
$include = $selectors->getSelectorByField('include');
if(empty($include) && $this->includeMode) $include = "include=$this->includeMode";
if($include) $a[] = $include;
$status = $selectors->getSelectorByField('status');
if(!empty($status)) $a[] = $status;
$checkAccess = $selectors->getSelectorByField('check_access');
if(empty($checkAccess) && $this->checkAccess === false && $this->includeMode !== 'all') $checkAccess = "check_access=0";
if($checkAccess) $a[] = $checkAccess;
return implode(', ', $a);
}
/**
* Make the query specific to all pages below a certain parent (children, grandchildren, great grandchildren, etc.)
*
* @param DatabaseQuerySelect $query
* @param Selector $selector
*
*/
protected function getQueryHasParent(DatabaseQuerySelect $query, $selector) {
static $cnt = 0;
$wheres = array();
$parent_ids = $selector->value;
if(!is_array($parent_ids)) $parent_ids = array($parent_ids);
foreach($parent_ids as $parent_id) {
if(!ctype_digit("$parent_id")) {
// parent_id is a path, convert a path to a parent
$parent = $this->pages->newNullPage();
$path = $this->sanitizer->path($parent_id);
if($path) $parent = $this->pages->get('/' . trim($path, '/') . '/');
$parent_id = $parent->id;
if(!$parent_id) {
$query->where("1>2"); // force the query to fail
return;
}
}
$parent_id = (int) $parent_id;
$cnt++;
if($parent_id == 1) {
// homepage
if($selector->operator == '!=') {
// homepage is only page that can match not having a has_parent of 1
$query->where("pages.id=1");
} else {
// no different from not having a has_parent, so we ignore it
}
return;
}
// the subquery performs faster than the old method (further below) on sites with tens of thousands of pages
if($selector->operator == '!=') {
$in = 'NOT IN';
$op = '!=';
$andor = 'AND';
} else {
$in = 'IN';
$op = '=';
$andor = 'OR';
}
$wheres[] = "(" .
"pages.parent_id$op$parent_id " .
"$andor pages.parent_id $in (" .
"SELECT pages_id FROM pages_parents WHERE parents_id=$parent_id OR pages_id=$parent_id" .
")" .
")";
}
$andor = $selector->operator == '!=' ? ' AND ' : ' OR ';
$query->where('(' . implode($andor, $wheres) . ')');
/*
// OLD method kept for reference
$joinType = 'join';
$table = "pages_has_parent$cnt";
if($selector->operator == '!=') {
$joinType = 'leftjoin';
$query->where("$table.pages_id IS NULL");
}
$query->$joinType(
"pages_parents AS $table ON (" .
"($table.pages_id=pages.id OR $table.pages_id=pages.parent_id) " .
"AND ($table.parents_id=$parent_id OR $table.pages_id=$parent_id) " .
")"
);
*/
}
/**
* Match a number of children count
*
* @param DatabaseQuerySelect $query
* @param Selector $selector
* @return string
* @throws WireException
*
*/
protected function getQueryNumChildren(DatabaseQuerySelect $query, $selector) {
if(!in_array($selector->operator, array('=', '<', '>', '<=', '>=', '!='))) {
$this->syntaxError("Operator '$selector->operator' not allowed for 'num_children' selector.");
}
$value = (int) $selector->value;
$this->getQueryNumChildren++;
$n = (int) $this->getQueryNumChildren;
$a = "pages_num_children$n";
$b = "num_children$n";
if( (in_array($selector->operator, array('<', '<=', '!=')) && $value) ||
(in_array($selector->operator, array('>', '>=', '!=')) && $value < 0) ||
(($selector->operator == '=' || $selector->operator == '>=') && !$value)) {
// allow for zero values
$query->select("COUNT($a.id) AS $b");
$query->leftjoin("pages AS $a ON ($a.parent_id=pages.id)");
$query->groupby("HAVING COUNT($a.id){$selector->operator}$value");
/* FOR REFERENCE
$query->select("count(pages_num_children$n.id) AS num_children$n");
$query->leftjoin("pages AS pages_num_children$n ON (pages_num_children$n.parent_id=pages.id)");
$query->groupby("HAVING count(pages_num_children$n.id){$selector->operator}$value");
*/
return $b;
} else {
// non zero values
$query->select("$a.$b AS $b");
$query->leftjoin(
"(" .
"SELECT p$n.parent_id, COUNT(p$n.id) AS $b " .
"FROM pages AS p$n " .
"GROUP BY p$n.parent_id " .
"HAVING $b{$selector->operator}$value " .
") $a ON $a.parent_id=pages.id");
$where = "$a.$b{$selector->operator}$value";
$query->where($where);
/* FOR REFERENCE
$query->select("pages_num_children$n.num_children$n AS num_children$n");
$query->leftjoin(
"(" .
"SELECT p$n.parent_id, count(p$n.id) AS num_children$n " .
"FROM pages AS p$n " .
"GROUP BY p$n.parent_id " .
"HAVING num_children$n{$selector->operator}$value" .
") pages_num_children$n ON pages_num_children$n.parent_id=pages.id");
$query->where("pages_num_children$n.num_children$n{$selector->operator}$value");
*/
return "$a.$b";
}
}
/**
* Arrange the order of field names where necessary
*
* @param array $fields
* @return array
*
*/
protected function arrangeFields(array $fields) {
$custom = array();
$native = array();
$singles = array();
foreach($fields as $name) {
if($this->fields->isNative($name)) {
$native[] = $name;
} else {
$custom[] = $name;
}
if(in_array($name, $this->singlesFields)) {
$singles[] = $name;
}
}
if(count($singles) && count($fields) > 1) {
// field in use that may no be combined with others
if($this->config->debug || $this->config->installed > 1549299319) {
// debug mode or anything installed after February 4th, 2019
$f = reset($singles);
$fs = implode('|', $fields);
$this->syntaxError("Field '$f' cannot OR with other fields in '$fs'");
}
}
return array_merge($native, $custom);
}
/**
* Returns the total number of results returned from the last find() operation
*
* If the last find() included limit, then this returns the total without the limit
*
* @return int
*
*/
public function getTotal() {
return $this->total;
}
/**
* Returns the limit placed upon the last find() operation, or 0 if no limit was specified
*
* @return int
*
*/
public function getLimit() {
return $this->limit === null ? 0 : $this->limit;
}
/**
* Returns the start placed upon the last find() operation
*
* @return int
*
*/
public function getStart() {
return $this->start === null ? 0 : $this->start;
}
/**
* Returns the parent ID, if it was part of the selector
*
* @return int
*
*/
public function getParentID() {
return $this->parent_id;
}
/**
* Returns the templates ID, if it was part of the selector
*
* @return int|null
*
*/
public function getTemplatesID() {
return $this->templates_id;
}
/**
* Return array of the options provided to PageFinder, as well as those determined at runtime
*
* @return array
*
*/
public function getOptions() {
return $this->lastOptions;
}
/**
* Returns array of sortfields that should be applied to resulting PageArray after loaded
*
* See the `useSortsAfter` option which must be enabled to use this.
*
* #pw-internal
*
* @return array
*
*/
public function getSortsAfter() {
return $this->sortsAfter;
}
/**
* Does the given field or fieldName resolve to a field that uses Page or PageArray values?
*
* @param string|Field $fieldName Field name or object
* @param bool $literal Specify true to only allow types that literally use FieldtypePage::getMatchQuery()
* @return Field|bool|string Returns Field object or boolean true (children|parent) if valid Page field, or boolean false if not
*
*/
protected function isPageField($fieldName, $literal = false) {
$is = false;
if($fieldName === 'parent' || $fieldName === 'children') {
return $fieldName; // early exit
} else if($fieldName instanceof Field) {
$field = $fieldName;
} else if(is_string($fieldName) && strpos($fieldName, '.')) {
// check if this is a multi-part field name
list($fieldName, $subfieldName) = explode('.', $fieldName, 2);
if($subfieldName === 'id') {
// id property is fine and can be ignored
} else {
// some other property, see if it resolves to a literal Page field
$f = $this->isPageField($subfieldName, true);
if($f) {
// subfield resolves to literal Page field, so we can pass this one through
} else {
// some other property, that doesn't resolve to a Page field, we can early-exit now
return false;
}
}
$field = $this->fields->get($fieldName);
} else {
$field = $this->fields->get($fieldName);
}
if($field) {
if($field->type instanceof FieldtypePage) {
$is = true;
} else if(strpos($field->type->className(), 'FieldtypePageTable') !== false) {
$is = true;
} else if($this->isRepeaterFieldtype($field->type)) {
$is = $literal ? false : true;
} else {
$test = $field->type->getBlankValue(new NullPage(), $field);
if($test instanceof Page || $test instanceof PageArray) {
$is = $literal ? false : true;
}
}
}
if($is && $field) $is = $field;
return $is;
}
/**
* Is the given Fieldtype for a repeater?
*
* @param Fieldtype $fieldtype
* @return bool
*
*/
protected function isRepeaterFieldtype(Fieldtype $fieldtype) {
return wireInstanceOf($fieldtype, 'FieldtypeRepeater');
}
/**
* Is given field name a modifier that does not directly refer to a field or column name?
*
* @param string $name
* @return string Returns normalized modifier name if a modifier or boolean false if not
*
*/
protected function isModifierField($name) {
$alternates = array(
'checkAccess' => 'check_access',
'getTotal' => 'get_total',
'hasParent' => 'has_parent',
);
$modifiers = array(
'include',
'_custom',
'limit',
'start',
'check_access',
'get_total',
'count',
'has_parent',
);
if(isset($alternates[$name])) return $alternates[$name];
$key = array_search($name, $modifiers);
if($key === false) return false;
return $modifiers[$key];
}
/**
* Does the given column name exist in the 'pages' table?
*
* @param string $name
* @return bool
*
*/
protected function pagesColumnExists($name) {
if(isset(self::$pagesColumns['all'][$name])) {
return self::$pagesColumns['all'][$name];
}
$instanceID = $this->wire()->getProcessWireInstanceID();
if(!isset(self::$pagesColumns[$instanceID])) {
self::$pagesColumns[$instanceID] = array();
if($this->supportsLanguagePageNames()) {
foreach($this->languages as $language) {
/** @var Language $language */
if($language->isDefault()) continue;
self::$pagesColumns[$instanceID]["name$language->id"] = true;
self::$pagesColumns[$instanceID]["status$language->id"] = true;
}
}
}
if(isset(self::$pagesColumns[$instanceID][$name])) {
return self::$pagesColumns[$instanceID][$name];
}
self::$pagesColumns[$instanceID][$name] = $this->database->columnExists('pages', $name);
return self::$pagesColumns[$instanceID][$name];
}
/**
* Data and cache used by the pagesColumnExists method
*
* @var array
*
*/
static private $pagesColumns = array(
// 'instance ID' => [ ... ]
'all' => array( // available in all instances
'id' => true,
'parent_id' => true,
'templates_id' => true,
'name' => true,
'status' => true,
'modified' => true,
'modified_users_id' => true,
'created' => true,
'created_users_id' => true,
'published' => true,
'sort' => true,
),
);
/**
* Are multi-language page names supported?
*
* @return bool
* @since 3.0.165
*
*/
protected function supportsLanguagePageNames() {
if($this->supportsLanguagePageNames === null) {
$languages = $this->languages;
$this->supportsLanguagePageNames = $languages && $languages->hasPageNames();
}
return $this->supportsLanguagePageNames;
}
/**
* Hook called when an unknown field is found in the selector
*
* By default, PW will throw a PageFinderSyntaxException but that behavior can be overridden by
* hooking this method and making it return true rather than false. It may also choose to
* map it to a Field by returning a Field object. If it returns integer 1 then it indicates the
* fieldName mapped to an API variable. If this method returns false, then it signals the getQuery()
* method that it was unable to map it to anything and should be considered a fail.
*
* @param string $fieldName
* @param array $data Array of data containing the following in it:
* - `subfield` (string): First subfield
* - `subfields` (string): All subfields separated by period (i.e. subfield.tertiaryfield)
* - `fields` (array): Array of all other field names being processed in this selector.
* - `query` (DatabaseQuerySelect): Database query select object
* - `selector` (Selector): Selector that contains this field
* - `selectors` (Selectors): All the selectors
* @return bool|Field|int
* @throws PageFinderSyntaxException
*
*/
protected function ___getQueryUnknownField($fieldName, array $data) {
$_data = array(
'subfield ' => 'data',
'subfields' => 'data',
'fields' => array(),
'query' => null,
'selector' => null,
'selectors' => null,
);
$data = array_merge($_data, $data);
$fields = $data['fields']; /** @var array $fields */
$subfields = $data['subfields']; /** @var string $subfields */
$selector = $data['selector']; /** @var Selector $selector */
$query = $data['query']; /** @var DatabaseQuerySelect $query */
$value = $this->wire($fieldName); /** @var Wire|null $value */
if($value) {
// found an API var
if(count($fields) > 1) {
$this->syntaxError("You may only match 1 API variable at a time");
}
if(is_object($value)) {
if($subfields == 'data') $subfields = 'id';
$selector->field = $subfields;
}
if(!$selector->matches($value)) {
$query->where("1>2"); // force non match
}
return 1; // indicate no further fields need processing
}
// not an API var
if($this->getQueryOwnerField($fieldName, $data)) return true;
/** @var bool|int|Field $value Hooks can modify return value to be Field */
$value = false;
return $value;
}
/**
* Process an owner back reference selector for PageTable, Page and Repeater fields
*
* @param string $fieldName Field name in "fieldName__owner" format
* @param array $data Data as provided to getQueryUnknownField method
* @return bool True if $fieldName was processed, false if not
* @throws PageFinderSyntaxException
*
*/
protected function getQueryOwnerField($fieldName, array $data) {
if(substr($fieldName, -7) !== '__owner') return false;
$fields = $data['fields']; /** @var array $fields */
$subfields = $data['subfields']; /** @var string $subfields */
$selectors = $data['selectors']; /** @var Selectors $selectors */
$selector = $data['selector']; /** @var Selector $selector */
$query = $data['query']; /** @var DatabaseQuerySelect $query */
if(empty($subfields)) $this->syntaxError("When using owner a subfield is required");
list($ownerFieldName,) = explode('__owner', $fieldName);
$ownerField = $this->fields->get($ownerFieldName);
if(!$ownerField) return false;
$ownerTypes = array('FieldtypeRepeater', 'FieldtypePageTable', 'FieldtypePage');
if(!wireInstanceOf($ownerField->type, $ownerTypes)) return false;
if($selector->get('owner_processed')) return true;
static $ownerNum = 0;
$ownerNum++;
// determine which templates are using $ownerFieldName
$templateIDs = array();
foreach($this->templates as $template) {
/** @var Template $template */
if($template->hasField($ownerFieldName)) {
$templateIDs[$template->id] = $template->id;
}
}
if(!count($templateIDs)) $templateIDs[] = 0;
$templateIDs = implode('|', $templateIDs);
// determine include=mode
$include = $selectors->getSelectorByField('include');
$include = $include ? $include->value : '';
if(!$include) $include = $this->includeMode ? $this->includeMode : 'hidden';
$selectorString = "templates_id=$templateIDs, include=$include, get_total=0";
if($include !== 'all') {
$checkAccess = $selectors->getSelectorByField('check_access');
if($checkAccess && ctype_digit($checkAccess->value)) {
$selectorString .= ", check_access=$checkAccess->value";
} else if($this->checkAccess === false) {
$selectorString .= ", check_access=0";
}
}
/** @var Selectors $ownerSelectors Build selectors */
$ownerSelectors = $this->wire(new Selectors($selectorString));
$ownerSelector = clone $selector;
if(count($fields) > 1) {
// OR fields present
array_shift($fields);
$subfields = array($subfields);
foreach($fields as $name) {
if(strpos($name, "$fieldName.") === 0) {
list(,$name) = explode('__owner.', $name);
$subfields[] = $name;
} else {
$this->syntaxError(
"When owner is present, group of OR fields must all be '$ownerFieldName.owner.subfield' format"
);
}
}
}
$ownerSelector->field = $subfields;
$ownerSelectors->add($ownerSelector);
// use field.count>0 as an optimization?
$useCount = true;
// find any other selectors referring to this same owner, bundle them in, and remove from source
foreach($selectors as $sel) {
if(strpos($sel->field(), "$fieldName.") !== 0) continue;
$sel->set('owner_processed', true);
$op = $sel->operator();
if($useCount && ($sel->not || strpos($op, '!') !== false || strpos($op, '<') !== false)) {
$useCount = false;
}
if($sel === $selector) {
continue; // skip main
}
$s = clone $sel;
$s->field = str_replace("$fieldName.", '', $sel->field());
$ownerSelectors->add($s);
$selectors->remove($sel);
}
if($useCount) {
$sel = new SelectorGreaterThan("$ownerFieldName.count", 0);
$ownerSelectors->add($sel);
}
/** @var PageFinder $finder */
$finder = $this->wire(new PageFinder());
$ids = array();
foreach($finder->findIDs($ownerSelectors) as $id) {
$ids[] = (int) $id;
}
if($this->isRepeaterFieldtype($ownerField->type)) {
// Repeater
$alias = "owner_parent$ownerNum";
$names = array();
foreach($ids as $id) {
$names[] = "'for-page-$id'";
}
$names = empty($names) ? "'force no match'" : implode(",", $names);
$query->join("pages AS $alias ON $alias.id=pages.parent_id AND $alias.name IN($names)");
} else {
// Page or PageTable
$table = $ownerField->getTable();
$alias = "owner{$ownerNum}_$table";
$ids = empty($ids) ? "0" : implode(',', $ids);
$query->join("$table AS $alias ON $alias.data=pages.id AND $alias.pages_id IN($ids)");
}
return true;
}
/**
* Get data that should be populated back to any resulting PageArrays data() method
*
* @param PageArray|null $pageArray Optionally populate given PageArray
* @return array
*
*/
public function getPageArrayData(PageArray $pageArray = null) {
if($pageArray !== null && count($this->pageArrayData)) {
$pageArray->data($this->pageArrayData);
}
return $this->pageArrayData;
}
/**
* Are any of the given field name(s) native to PW system?
*
* This is primarily used to determine whether the getQueryNativeField() method should be called.
*
* @param string|array|Selector $fieldNames Single field name, array of field names or pipe-separated string of field names
* @return bool
*
*/
protected function hasNativeFieldName($fieldNames) {
$fieldName = null;
if(is_object($fieldNames)) {
if($fieldNames instanceof Selector) {
$fieldNames = $fieldNames->fields();
} else {
return false;
}
}
if(is_string($fieldNames)) {
if(strpos($fieldNames, '|')) {
$fieldNames = explode('|', $fieldNames);
$fieldName = reset($fieldNames);
} else {
$fieldName = $fieldNames;
$fieldNames = array($fieldName);
}
} else if(is_array($fieldNames)) {
$fieldName = reset($fieldNames);
}
if($fieldName !== null) {
if(strpos($fieldName, '.')) list($fieldName,) = explode('.', $fieldName, 2);
if($this->fields->isNative($fieldName)) return true;
}
if(count($fieldNames)) {
$fieldsStr = ':' . implode(':', $fieldNames) . ':';
if(strpos($fieldsStr, ':parent.') !== false) return true;
if(strpos($fieldsStr, ':children.') !== false) return true;
if(strpos($fieldsStr, ':child.') !== false) return true;
}
return false;
}
/**
* Get the fully parsed/final selectors used in the last find() operation
*
* Should only be called after a find() or findIDs() operation, otherwise returns null.
*
* #pw-internal
*
* @return Selectors|null
* @since 3.0.146
*
*/
public function getSelectors() {
return $this->finalSelectors;
}
/**
* Throw a fatal syntax error
*
* @param string $message
* @throws PageFinderSyntaxException
*
*/
public function syntaxError($message) {
throw new PageFinderSyntaxException($message);
}
}
/**
* Typehinting class for DatabaseQuerySelect object passed to Fieldtype::getMatchQuery()
*
* @property Field $field Original field
* @property string $group Original group of the field
* @property Selector $selector Original Selector object
* @property Selectors $selectors Original Selectors object
* @property DatabaseQuerySelect $parentQuery Parent database query
* @property PageFinder $pageFinder PageFinder instance that initiated the query
*/
abstract class PageFinderDatabaseQuerySelect extends DatabaseQuerySelect { }