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 populated back to any resulting PageArray’s data() method * * @var array * */ protected $pageArrayData = 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($key) { if($key === 'includeMode') return $this->includeMode; if($key === 'checkAccess') return $this->checkAccess; return parent::__get($key); } /** * 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->initStatusChecks($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 initStatusChecks(Selectors $selectors, array $options) { $maxStatus = null; $limit = 0; // for getTotal auto detection $start = 0; $limitSelector = null; $checkAccessSpecified = false; $hasParents = array(); // requests for parent(s) in the selector $hasSort = false; // whether or not a sort is requested $noArrayFields = array_flip(array( // field names that do not accept array values 'status', 'include', 'check_access', 'checkAccess', 'limit', 'start', 'getTotal', 'get_total', )); foreach($selectors as $key => $selector) { /** @var Selector $selector */ $fieldName = $selector->field(); if(isset($noArrayFields[$fieldName])) { if(is_array($selector->field) || is_array($selector->value)) { throw new PageFinderException("OR-condition not supported in '$selector'"); } } if($fieldName === 'status') { // @todo add support for array value,i.e. `status=hidden|unpublished` $value = $selector->value(); if(!ctype_digit("$value")) { // allow use of some predefined labels for Page statuses $statuses = Page::getStatuses(); $selector->value = isset($statuses[$value]) ? $statuses[$value] : 1; } $not = false; if(($selector->operator == '!=' && !$selector->not) || ($selector->not && $selector->operator == '=')) { $s = $this->wire(new SelectorBitwiseAnd('status', $selector->value)); $s->not = true; $not = true; $selectors[$key] = $s; } else if($selector->operator == '=' || ($selector->operator == '!=' && $selector->not)) { $selectors[$key] = $this->wire(new SelectorBitwiseAnd('status', $selector->value)); } else { // some other operator like: >, <, >=, <= $not = $selector->not; } if(!$not && (is_null($maxStatus) || $selector->value > $maxStatus)) $maxStatus = (int) $selector->value; } else if($fieldName == 'include' && $selector->operator == '=' && in_array($selector->value, array('hidden', 'all', 'unpublished', 'trash'))) { $this->includeMode = $selector->value; if($selector->value == 'hidden') $options['findHidden'] = true; else if($selector->value == 'unpublished') $options['findUnpublished'] = true; else if($selector->value == 'trash') $options['findTrash'] = true; else if($selector->value == 'all') $options['findAll'] = true; $selectors->remove($key); } else if($fieldName == 'check_access' || $fieldName == 'checkAccess') { $this->checkAccess = ((int) $selector->value) > 0 ? true : false; $checkAccessSpecified = true; $selectors->remove($key); } else if($fieldName == 'limit') { // for getTotal auto detect $limit = (int) $selector->value; $limitSelector = $selector; // @todo allow for array value that specifies start and limit, i.e. '10|25' } else if($fieldName == 'start') { // for getTotal auto detect $start = (int) $selector->value; } 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[] = $selector->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)) { if(ctype_digit("$selector->value")) { $options['getTotal'] = (bool) $selector->value; } else if(in_array($selector->value, array('calc', 'count'))) { $options['getTotal'] = true; $options['getTotalType'] = $selector->value; } $selectors->remove($selector); } } } // foreach($selectors) if(!is_null($maxStatus) && empty($options['findAll']) && empty($options['findUnpublished'])) { // if a status was already present in the selector, without a findAll/findUnpublished, then just make sure the page isn't unpublished if($maxStatus < Page::statusUnpublished) { $selectors->add(new SelectorLessThan('status', Page::statusUnpublished)); } } else if($options['findAll']) { // findAll option means that unpublished, hidden, trash, system may be included if(!$checkAccessSpecified) $this->checkAccess = false; } else if($options['findHidden']) { // findHidden option, apply optimizations enabling hidden pages to be loaded $selectors->add(new SelectorLessThan('status', Page::statusUnpublished)); } else if($options['findUnpublished']) { $selectors->add(new SelectorLessThan('status', Page::statusTrash)); } else if($options['findTrash']) { $selectors->add(new SelectorLessThan('status', Page::statusDeleted)); } else { // no status is present, so exclude everything hidden and above $selectors->add(new SelectorLessThan('status', Page::statusHidden)); } if($options['findOne']) { // findOne option is never paginated, always starts at 0 $selectors->add(new SelectorEqual('start', 0)); if(empty($options['startAfterID']) && empty($options['stopBeforeID'])) { $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'] = $limitSelector->value; $selectors->remove($limitSelector); } return $options; } /** * 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)) { $selectors = new Selectors($selectors); } 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(); //if($this->config->debug) $error .= " - " . $query->getQuery() . ' ' . print_r($query->bindValues, true); $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 = array(end($matches)); } 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; /** @var Selectors $sel */ $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) { 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 field’s 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 = ''; } $_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 . "
"; $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); } } } $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'])) { foreach($opts['joinFields'] as $joinField) { $joinField = $this->wire()->fields->get($joinField); if(!$joinField || !$joinField instanceof Field) continue; $joinTable = $database->escapeTable($joinField->getTable()); if(!$joinTable || !$joinField->type) continue; if(!$joinField->type->getLoadQueryAutojoin($joinField, $query)) continue; $autojoinTables[$joinTable] = $joinTable; // added at end if not already joined } } } 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->field; $group = $selector->group; // i.e. @field $fields = is_array($fields) ? $fields : array($fields); 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] === '>'); if($useEmpty && $fieldtype && 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)) { $sql = "$tableAlias.$col IS NULL OR ($tableAlias.$col=$bindKey"; } else { $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'; if($value === "0" && !$ft->isEmptyValue($field, "0")) { // may match rows with no value present $sql = "$tableAlias.$col IS NULL OR $tableAlias.$col!='0'"; } else if($blankIsObject) { $sql = "$tableAlias.$col IS NOT NULL"; } else { $bindKey = $query->bindValueGetKey($blankValue); $sql = "$tableAlias.$col IS NOT NULL AND ($tableAlias.$col!=$bindKey"; if($blankValue !== "0" && !$ft->isEmptyValue($field, "0")) { $sql .= " OR $tableAlias.$col='0'"; } $sql .= ")"; } } 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) { 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) { 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) { 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) { if($query) {} 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->language ? $user->language : null; // todo 3.0.190: uncomment the line below to support `sort=a|b|c` in correct order // if(count($values) > 1) $values = array_reverse($values); // because orderby prepend used below 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 { // 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(is_object($blankValue) && ($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) { if(!$language->isDefault()) $langNames[$language->id] = "name" . (int) $language->id; } if(!count($langNames)) $langNames = null; } else { $langNames = null; } if($modules->isInstalled('PagePaths') && !$langNames) { // @todo add support to PagePaths module for LanguageSupportPageNames $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) { /** @var LanguageSupportPageNames $module */ $module = $modules->getModule('LanguageSupportPageNames'); if($module) $selectorValue = $module->updatePath($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; 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; $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) { $field = 'parent_id'; if(count($values) == 1 && count($fields) == 1 && $selector->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 { // 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 = $selector->not ? "NOT IN" : "IN"; $sql .= in_array($field, array('parent', 'parent_id')) ? "$table.parent_id " : "$table.id "; $IDs = $sanitizer->intArray($IDs); $sql .= "$in(" . implode(',', $IDs) . ")"; } 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 && $selector->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 = $this->wire()->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)) { $this->syntaxError("Operator '$operator' is not supported for '$field'."); $s = ''; } else if($this->isModifierField($field)) { $this->syntaxError("Modifier '$field' is not allowed here"); $s = ''; } else if(!$this->pagesColumnExists($field)) { $this->syntaxError("Field '$field' is not a known field, column or selector modifier"); $s = ''; } 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 * */ 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; $field = null; if($fieldName === 'parent' || $fieldName === 'children') { return $fieldName; // early exit } else if(is_object($fieldName) && $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(is_object($test) && ($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) { 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) { $modules = $this->wire()->modules; $this->supportsLanguagePageNames = $this->languages && $modules->isInstalled('LanguageSupportPageNames'); } 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); /** @var array $fields */ $fields = $data['fields']; /** @var string $subfields */ $subfields = $data['subfields']; /** @var Selector $selector */ $selector = $data['selector']; /** @var DatabaseQuerySelect $query */ $query = $data['query']; /** @var Wire|null $value */ $value = $this->wire($fieldName); 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; return false; } /** * 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; /** @var array $fields */ $fields = $data['fields']; /** @var string $subfields */ $subfields = $data['subfields']; /** @var Selectors $selectors */ $selectors = $data['selectors']; /** @var Selector $selector */ $selector = $data['selector']; /** @var DatabaseQuerySelect $query */ $query = $data['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) { 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 PageArray’s 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 { }