<NME> Storage.php <BEF> <?php namespace Bolt\Legacy; use Bolt; use Bolt\Collection\Arr; use Bolt\Controller\Zone; use Bolt\Events\StorageEvent; use Bolt\Events\StorageEvents; use Bolt\Exception\StorageException; use Bolt\Helpers\Html; use Bolt\Helpers\Str; use Bolt\Pager; use Bolt\Storage\Entity\FieldValue; use Bolt\Storage\Field\Collection\RepeatingFieldCollection; use Bolt\Translation\Translator as Trans; use Doctrine\DBAL\Connection as DoctrineConn; use Doctrine\DBAL\Platforms\PostgreSqlPlatform; use Exception; use Silex\Application; /** * Legacy Storage class. * * @deprecated Deprecated since 3.0, to be removed in 4.0. */ class Storage { /** @var array */ public $images; /** @var Application */ private $app; /** @var array */ private $tables = []; /** @var string */ private $prefix; /** @var array */ private $checkedfortimed = []; /** @var bool Test to indicate if we're inside a dispatcher. */ private $inDispatcher = false; /** @var array */ protected static $pager = []; } /** * Get an object for the content of a specific contenttype. This will be * \Bolt\Legacy\Content, unless the contenttype defined another class to be * used. * * @param array|string $contenttype * @param array $values * @param bool $isRootType * * @throws \Exception * * @return \Bolt\Legacy\Content */ public function getContentObject($contenttype, $values = [], $isRootType = true) { // Make sure $contenttype is an array, and not just the slug. if (!is_array($contenttype)) { $contenttype = $this->getContentType($contenttype); } // If the contenttype has a 'class' specified, and the class exists, // Initialize the content as an object of that class. if (!empty($contenttype['class']) && class_exists($contenttype['class'])) { $content = new $contenttype['class']($this->app, $contenttype, $values, $isRootType); // Check if the class actually extends \Bolt\Legacy\Content. if (!($content instanceof Content)) { throw new \Exception($contenttype['class'] . ' does not extend \\Bolt\\Legacy\\Content.'); } } else { $content = new Content($this->app, $contenttype, $values, $isRootType); } return $content; } /** * Add some records with dummy content. * * Only fill the contenttypes passed as parameters * If the parameters is empty, only fill empty tables * * @see \Bolt\Storage\Database\\Prefill * * @param array $contentTypes * * @return string */ public function preFill($contentTypes = []) { if (empty($contentTypes)) { $contentTypes = $this->app['config']->get('contenttypes'); $contentTypeNames = array_keys($contentTypes); } else { $contentTypeNames = (array) $contentTypes; } /** @var \Bolt\Storage\Database\Prefill\Builder $builder */ $builder = $this->app['prefill.builder']; $results = $builder->build($contentTypeNames, 5); $output = ''; foreach ($results->get('created') as $contentTypeName => $titles) { foreach ($titles as $title) { $output .= Trans::__( "Added to <tt>%key%</tt> '%title%'", ['%key%' => $contentTypeName, '%title%' => $title['title']] ); $output .= "<br>\n"; } } foreach ($results->get('warnings') as $warnings) { $output .= $warnings; } foreach ($results->get('errors') as $errors) { $output .= $errors; } return $output; } /** * Writes a content-changelog entry for a newly-created entry. * * @param string $contenttype Slug of the record contenttype * @param integer $contentid ID of the record * @param array $content Record values * @param string $comment Editor's comment */ private function logInsert($contenttype, $contentid, $content, $comment = null) { $this->app['logger.change']->info( 'Insert record', [ 'action' => 'INSERT', 'contenttype' => $contenttype, 'id' => $contentid, 'new' => $content, 'old' => null, 'comment' => $comment, ] ); } /** * Writes a content-changelog entry for an updated entry. * This function must be called *before* the actual update, because it * fetches the old content from the database. * * @param string $contenttype Slug of the record contenttype * @param integer $contentid ID of the record * @param array $newContent New record values * @param array $oldContent Old record values * @param string $comment Editor's comment */ private function logUpdate($contenttype, $contentid, $newContent, $oldContent = null, $comment = null) { $this->app['logger.change']->info( 'Update record', [ 'action' => 'UPDATE', 'contenttype' => $contenttype, 'id' => $contentid, 'new' => $newContent, 'old' => $oldContent, 'comment' => $comment, ] ); } /** * Writes a content-changelog entry for a deleted entry. * This function must be called *before* the actual update, because it. * * @param string $contenttype Slug of the record contenttype * @param integer $contentid ID of the record * @param array $content Record values * @param string $comment Editor's comment */ private function logDelete($contenttype, $contentid, $content, $comment = null) { $this->app['logger.change']->info( 'Delete record', [ 'action' => 'DELETE', 'contenttype' => $contenttype, 'id' => $contentid, 'new' => null, 'old' => $content, 'comment' => $comment, ] ); } /** * Save a record. * * @param Content $content * @param string $comment * * @throws \Bolt\Exception\StorageException * * @return int */ public function saveContent(Content $content, $comment = null) { $contenttype = $content->contenttype; $fieldvalues = $content->values; if (empty($contenttype)) { $this->app['logger.system']->error('Contenttype is required for ' . __FUNCTION__, ['event' => 'exception']); throw new StorageException('Contenttype is required for ' . __FUNCTION__); } // Test to see if this is a new record, or an update if (empty($fieldvalues['id'])) { $create = true; } else { $create = false; } // We need to verify if the slug is unique. If not, we update it. $getId = $create ? null : $fieldvalues['id']; $fieldvalues['slug'] = $this->getUri($fieldvalues['slug'], $getId, $contenttype['slug'], false, false); // Update the content object $content->setValues($fieldvalues); // Dispatch pre-save event if (! $this->inDispatcher && $this->app['dispatcher']->hasListeners(StorageEvents::PRE_SAVE)) { $event = new StorageEvent($content, ['contenttype' => $contenttype, 'create' => $create]); $this->app['dispatcher']->dispatch(StorageEvents::PRE_SAVE, $event); } // Decide whether to insert a new record, or update an existing one. if ($create) { $this->insertContent($content, $comment); } else { $this->updateContent($content, $comment); } // Update taxonomy and record relationships $this->updateTaxonomy($contenttype, $content->values['id'], $content->taxonomy); $this->updateRelation($contenttype, $content->values['id'], $content->relation); // Dispatch post-save event if (!$this->inDispatcher && $this->app['dispatcher']->hasListeners(StorageEvents::POST_SAVE)) { // Block loops $this->inDispatcher = true; $event = new StorageEvent($content, ['contenttype' => $contenttype, 'create' => $create]); $this->app['dispatcher']->dispatch(StorageEvents::POST_SAVE, $event); // Re-enable the dispatcher $this->inDispatcher = false; } return $content->values['id']; } /** * Delete a record. * * @param string $contenttype * @param integer $id * * @throws \Bolt\Exception\StorageException * * @return integer The number of affected rows. */ public function deleteContent($contenttype, $id) { if (empty($contenttype)) { $this->app['logger.system']->error('Contenttype is required for' . __FUNCTION__, ['event' => 'exception']); throw new StorageException('Contenttype is required for ' . __FUNCTION__); } // Make sure $contenttype is a 'slug' if (is_array($contenttype)) { $contenttype = $contenttype['slug']; } // Get the old content record $tablename = $this->getContenttypeTablename($contenttype); $oldContent = $this->findContent($tablename, $id); // Dispatch pre-delete event if ($this->app['dispatcher']->hasListeners(StorageEvents::PRE_DELETE)) { $event = new StorageEvent($oldContent, ['contenttype' => $contenttype]); $this->app['dispatcher']->dispatch(StorageEvents::PRE_DELETE, $event); } $this->logDelete($contenttype, $id, $oldContent); $res = $this->app['db']->delete($tablename, ['id' => $id]); // Make sure relations and taxonomies are deleted as well. if ($res) { $this->app['db']->delete($this->getTablename('relations'), ['from_contenttype' => $contenttype, 'from_id' => $id]); $this->app['db']->delete($this->getTablename('relations'), ['to_contenttype' => $contenttype, 'to_id' => $id]); $this->app['db']->delete($this->getTablename('taxonomy'), ['contenttype' => $contenttype, 'content_id' => $id]); } // Dispatch post-delete event if ($this->app['dispatcher']->hasListeners(StorageEvents::POST_DELETE)) { $event = new StorageEvent($oldContent, ['contenttype' => $contenttype]); $this->app['dispatcher']->dispatch(StorageEvents::POST_DELETE, $event); } return $res; } /** * Insert a new contenttype record in the database. * * @param \Bolt\Legacy\Content $content Record content to insert * @param string $comment Editor's comment * * @return boolean */ protected function insertContent(Content $content, $comment = null) { $tablename = $this->getContenttypeTablename($content->contenttype); // Set creation and update dates $content->setValue('datecreated', date('Y-m-d H:i:s')); $content->setValue('datechanged', date('Y-m-d H:i:s')); // id is set to autoincrement, so let the DB handle it unset($content->values['id']); // Get the JSON database prepared values and make sure it's valid $fieldvalues = $this->getValidSaveData($content->getValues(true), $content->contenttype); $this->app['db']->insert($tablename, $fieldvalues); $seq = null; if ($this->app['db']->getDatabasePlatform() instanceof PostgreSqlPlatform) { $seq = $tablename . '_id_seq'; } $id = $this->app['db']->lastInsertId($seq); if ($id > 0) { $content->setValue('id', $id); $this->logInsert($content->contenttype['slug'], $id, $fieldvalues, $comment); return true; } } /** * Update a Bolt contenttype record. * * @param \Bolt\Legacy\Content $content The content object to be updated * @param string $comment Add a comment to save with change. * * @throws \Bolt\Exception\StorageException * * @return bool */ private function updateContent(Content $content, $comment = null) { $tablename = $this->getContenttypeTablename($content->contenttype); // Set the date the record was changed $content->setValue('datechanged', date('Y-m-d H:i:s')); // Test that the record exists in the database $oldContent = $this->findContent($tablename, $content['id']); if (empty($oldContent)) { throw new StorageException('Attempted to update a non-existent record'); } // Get the JSON database prepared values and make sure it's valid $fieldvalues = $this->getValidSaveData($content->getValues(true), $content->contenttype); // Do the actual update, and log it. $res = $this->app['db']->update($tablename, $fieldvalues, ['id' => $content['id']]); if ($res > 0) { $this->logUpdate($content->contenttype['slug'], $content['id'], $fieldvalues, $oldContent, $comment); return true; } } /** * Get a valid array to commit. * * @param array $fieldvalues * @param array $contenttype * * @return array */ private function getValidSaveData(array $fieldvalues, array $contenttype) { // Clean up fields, check unneeded columns. foreach (array_keys($fieldvalues) as $key) { if ($this->isValidColumn($key, $contenttype)) { if (is_string($fieldvalues[$key])) { // Trim strings $fieldvalues[$key] = trim($fieldvalues[$key]); } elseif (is_bool($fieldvalues[$key])) { // Convert literal booleans to 0/1 to ensure cross-db consistency $fieldvalues[$key] = (int) $fieldvalues[$key]; } } else { // unset columns we don't need to store. unset($fieldvalues[$key]); } } return $fieldvalues; } /** * Update a single value from content. * * It is called in list of contents. * * @param string $contenttype Content Type to be edited. * @param int $id Id of content to be updated. * @param string $field Field name of content to be changed. * @param mixed $value New value to be defined on field. * * @return bool Returns true when update is done or false if not. */ public function updateSingleValue($contenttype, $id, $field, $value) { $id = intval($id); if (!$this->isValidColumn($field, $contenttype)) { $error = Trans::__('contenttypes.generic.invalid-field', ['%field%' => $field, '%contenttype%' => $contenttype]); $this->app['logger.flash']->danger($error); return false; } $content = $this->getContent("$contenttype/$id"); $content->setValue($field, $value); $comment = Trans::__( 'The field %field% has been changed to "%newValue%"', [ '%field%' => $field, '%newValue%' => $value, ] ); $result = $this->saveContent($content, $comment); return $result; } public function getEmptyContent($contenttypeslug) { $content = $this->getContentObject($contenttypeslug); // don't use 'undefined contenttype' as title/name $content->setValues(['name' => '', 'title' => '']); return $content; } /** * Decode search query into searchable parts. * * @param string $q * * @return array */ private function decodeSearchQuery($q) { $words = preg_split('|[\r\n\t ]+|', trim($q)); $words = array_map( function ($word) { return mb_strtolower($word); }, $words ); $words = array_filter( $words, function ($word) { return strlen($word) >= 2; } ); return [ 'valid' => count($words) > 0, 'in_q' => $q, 'use_q' => implode(' ', $words), 'sanitized_q' => strip_tags($q), 'words' => $words, ]; } /** * Search through a single contenttype. * * Search, weigh and return the results. * * @param $query * @param $contenttype * @param $fields * @param array $filter * @param bool $implode * * @return Content */ private function searchSingleContentType($query, $contenttype, $fields, array $filter = null, $implode = false) { // This could be even more configurable // (see also Content->getFieldWeights) $searchableTypes = ['text', 'textarea', 'html', 'markdown']; $table = $this->getContenttypeTablename($contenttype); if ($implode) { $query['words'] = [ implode(' ', $query['words']) ]; } // Build fields 'WHERE' $fieldsWhere = []; foreach ($fields as $field => $fieldconfig) { if (in_array($fieldconfig['type'], $searchableTypes)) { foreach ($query['words'] as $word) { // Build the LIKE, lowering the searched field to cover case-sensitive database systems $fieldsWhere[] = sprintf('LOWER(%s.%s) LIKE LOWER(%s)', $table, $field, $this->app['db']->quote('%' . $word . '%')); } } } // make taxonomies work $taxonomytable = $this->getTablename('taxonomy'); $taxonomies = $this->getContentTypeTaxonomy($contenttype); $tagsWhere = []; $tagsQuery = ''; foreach ($taxonomies as $taxonomy) { if ($taxonomy['behaves_like'] == 'tags') { foreach ($query['words'] as $word) { $tagsWhere[] = sprintf('%s.slug LIKE %s', $taxonomytable, $this->app['db']->quote('%' . $word . '%')); } } } // only add taxonomies if they exist if (!empty($taxonomies) && !empty($tagsWhere)) { $tagsQueryA = sprintf("%s.contenttype = '%s'", $taxonomytable, $contenttype); $tagsQueryB = implode(' OR ', $tagsWhere); $tagsQuery = sprintf(' OR (%s AND (%s))', $tagsQueryA, $tagsQueryB); } // Build filter 'WHERE" // @todo make relations work as well $filterWhere = []; if (!is_null($filter)) { foreach ($fields as $field => $fieldconfig) { if (isset($filter[$field])) { $filterWhere[] = $this->parseWhereParameter($table . '.' . $field, $filter[$field]); } } } // Build actual where $where = []; $where[] = sprintf("%s.status = 'published'", $table); $where[] = '(( ' . implode(' OR ', $fieldsWhere) . ' ) ' . $tagsQuery . ' )'; $where = array_merge($where, $filterWhere); // Build SQL query $select = sprintf( 'SELECT %s.id FROM %s LEFT JOIN %s ON %s.id = %s.content_id WHERE %s GROUP BY %s.id', $table, $table, $taxonomytable, $table, $taxonomytable, implode(' AND ', $where), $table ); // Run Query $results = $this->app['db']->fetchAll($select); if (!empty($results)) { $ids = implode(' || ', Arr::column($results, 'id')); $results = $this->getContent($contenttype, ['id' => $ids, 'returnsingle' => false]); // Convert and weight foreach ($results as $result) { $result->weighSearchResult($query); } } return $results; } /** * Compare by search weights. * * Or fallback to dates or title * * @param \Bolt\Legacy\Content $a * @param \Bolt\Legacy\Content $b * * @return int */ private function compareSearchWeights(Content $a, Content $b) { if ($a->getSearchResultWeight() > $b->getSearchResultWeight()) { return -1; } if ($a->getSearchResultWeight() < $b->getSearchResultWeight()) { return 1; } if ($a['datepublish'] > $b['datepublish']) { // later is more important return -1; } if ($a['datepublish'] < $b['datepublish']) { // earlier is less important return 1; } return strcasecmp($a['title'], $b['title']); } /** * Search through actual content. * * Unless the query is invalid it will always return a 'result array'. It may * complain in the log but it won't abort. * * @param string $q Search string * @param array $contenttypes Contenttype names to search for: * - string: Specific contenttype * - null: Every searchable contenttype * @param array $filters Additional filters for contenttypes * - key is contenttype * - value is filter * @param integer $limit limit the number of results * @param integer $offset skip this number of results * * @return mixed false if query is invalid, an array with results if query was executed */ public function searchContent($q, array $contenttypes = null, array $filters = null, $limit = 9999, $offset = 0) { $query = $this->decodeSearchQuery($q); if (!$query['valid']) { return false; } $appCt = $this->app['config']->get('contenttypes'); // By default we only search through searchable contenttypes if (is_null($contenttypes)) { $contenttypes = array_keys($appCt); $contenttypes = array_filter( $contenttypes, function ($ct) use ($appCt) { if ((isset($appCt[$ct]['searchable']) && $appCt[$ct]['searchable'] === false) || (isset($appCt[$ct]['viewless']) && $appCt[$ct]['viewless'] === true) ) { return false; } return true; } ); $contenttypes = array_map( function ($ct) use ($appCt) { return $appCt[$ct]['slug']; }, $contenttypes ); } // Build our search results array $results = []; // First, attempt to search for the literal string, eg. "Lorum Ipsum" foreach ($contenttypes as $contenttype) { $ctconfig = $this->getContentType($contenttype); $fields = $ctconfig['fields']; $filter = null; if (is_array($filters) && isset($filters[$contenttype])) { $filter = $filters[$contenttype]; } $subResults = $this->searchSingleContentType($query, $contenttype, $fields, $filter, true); $results = array_merge($results, $subResults); } // If that didn't produce results, search for "Lorum" or "Ipsum" if (empty($results)) { foreach ($contenttypes as $contenttype) { $ctconfig = $this->getContentType($contenttype); $fields = $ctconfig['fields']; $filter = null; if (is_array($filters) && isset($filters[$contenttype])) { $filter = $filters[$contenttype]; } $subResults = $this->searchSingleContentType($query, $contenttype, $fields, $filter, false); $results = array_merge($results, $subResults); } } // Sort the results usort($results, [$this, 'compareSearchWeights']); $noOfResults = count($results); $pageResults = []; if ($offset < $noOfResults) { $pageResults = array_slice($results, $offset, $limit); } return [ 'query' => $query, 'no_of_results' => $noOfResults, 'results' => $pageResults, ]; } public function searchAllContentTypes(array $parameters = [], &$pager = []) { // Note: we can only apply this kind of results aggregating when we don't // use LIMIT and OFFSET! If we'd want to use it, this should be rewritten. // Results aggregator $result = []; foreach ($this->getContentTypes() as $contenttype) { $contentTypeSearchResults = $this->searchContentType($contenttype, $parameters, $pager); foreach ($contentTypeSearchResults as $searchresult) { $result[] = $searchresult; } } return $result; } public function searchContentType($contenttypename, array $parameters = [], &$pager = []) { $where = []; $tablename = $this->getContenttypeTablename($contenttypename); $contenttype = $this->app['config']->get('contenttypes/' . $contenttypename); // If this contenttype has 'searchable: false', we skip it. if (isset($contenttype['searchable']) && $contenttype['searchable'] === false) { return []; } // for all the non-reserved parameters that are fields, we assume people want to do a 'where' foreach ($parameters as $key => $value) { if (in_array($key, ['order', 'where', 'limit', 'offset'])) { continue; // Skip this one. } if (!$this->isValidColumn($key, $contenttype)) { continue; // Also skip if 'key' isn't a field in the contenttype. } $where[] = $this->parseWhereParameter($key, $value); } // @todo update with nice search string // If we need to filter, add the WHERE for that. // Meh, InnoDB doesn't support full text search. if (!empty($parameters['filter'])) { $filter = $this->app['db']->quote($parameters['filter']); $filterWhere = []; foreach ($contenttype['fields'] as $key => $value) { if (in_array($value['type'], ['text', 'textarea', 'html', 'markdown'])) { $filterWhere[] = sprintf("%s LIKE '%%%s%%'", $key, $filter); } } if (!empty($filterWhere)) { $where[] = '(' . implode(' OR ', $filterWhere) . ')'; } } $limit = isset($parameters['limit']) && $parameters['limit'] ? $parameters['limit'] : 9999; $page = isset($parameters['page']) && $parameters['page'] ? $parameters['page'] : 1; // If we're allowed to use pagination, use the 'page' parameter. if (!empty($parameters['paging']) && $this->app['request_stack']->getCurrentRequest() !== null) { $page = $this->app['pager']->getPager(); } $queryparams = ""; // implode 'where' if (!empty($where)) { $queryparams .= sprintf(' WHERE (%s)', implode(" AND ", $where)); } // Order, with a special case for 'RANDOM'. if (!empty($parameters['order'])) { if ($parameters['order'] == "RANDOM") { $dboptions = $this->app['db']->getParams(); $queryparams .= sprintf(' ORDER BY %s', $dboptions['randomfunction']); } else { $order = $this->getEscapedSortorder($parameters['order'], false); if (!empty($order)) { $queryparams .= sprintf(' ORDER BY %s', $order); } } } // Make the query for the pager. $pagerquery = sprintf('SELECT COUNT(*) AS count FROM %s %s', $tablename, $queryparams); // Add the limit $queryparams = $this->app['db']->getDatabasePlatform()->modifyLimitQuery($queryparams, $limit, ($page - 1) * $limit); // Make the query to get the results. $query = "SELECT * FROM $tablename" . $queryparams; $rows = $this->app['db']->fetchAll($query); // Make sure content is set, and all content has information about its contenttype $content = []; foreach ($rows as $row) { $content[$row['id']] = $this->getContentObject($contenttype, $row); } // TODO: Check if we need to hydrate here! // Make sure all content has their taxonomies and relations $this->getTaxonomy($content); $this->getRelation($content); $this->getRepeaters($content); // Set up the $pager array with relevant values. $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch(); /** @var \Bolt\Pager\PagerManager $manager */ $manager = $this->app['pager']; $manager->createPager('search') ->setCount($rowcount['count']) ->setTotalpages(ceil($rowcount['count'] / $limit)) ->setCurrent($page) ->setShowingFrom(($page - 1) * $limit + 1) ->setShowingTo(($page - 1) * $limit + count($content)); return $content; } /** * Retrieve content from the database, filtered on taxonomy. * * Note: we can NOT sort on anything meaningful. Records are fetched from multiple ]; } public function searchAllContentTypes(array $parameters = [], &$pager = []) { // Note: we can only apply this kind of results aggregating when we don't { $tablename = $this->getTablename('taxonomy'); $slug = $this->app['slugify']->slugify($name); $limit = isset($parameters['limit']) && $parameters['limit'] ? $parameters['limit'] : 9999; $page = isset($parameters['page']) && $parameters['page'] ? $parameters['page'] : 1; $taxonomytype = $this->getTaxonomyType($taxonomyslug); // No taxonomytype, no possible content. return $result; } public function searchContentType($contenttypename, array $parameters = [], &$pager = []) { $where = []; $orderby = (array_key_exists('has_sortorder', $taxonomytype) && $taxonomytype['has_sortorder'] === true) ? 'sortorder' : 'id'; // Sort on either 'ascending' or 'descending' // Make sure we set the order. $order = 'ASC'; $taxonomysort = strtoupper($this->app['config']->get('theme/taxonomy_sort') ?: $this->app['config']->get('general/taxonomy_sort')); if ($taxonomysort == 'DESC') { $order = 'DESC'; } // Add the limit $query = sprintf('SELECT * FROM %s %s ORDER BY %s %s', $tablename, $where, $orderby, $order); $query = $this->app['db']->getDatabasePlatform()->modifyLimitQuery($query, $limit, ($page - 1) * $limit); $taxorows = $this->app['db']->fetchAll($query); if (!empty($parameters['printquery'])) { // @todo formalize this echo nl2br(htmlentities($query)); } $content = []; if (is_array($taxorows)) { foreach ($taxorows as $row) { $record = $this->getContent($row['contenttype'] . '/' . $row['content_id']); if ($record instanceof Content && !empty($record->id)) { $content[] = $record; } } } // Set up the $pager array with relevant values. $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch(); $pagefor = $taxonomytype['singular_slug'].'_'.$slug; /** @var \Bolt\Pager\PagerManager $manager */ $manager = $this->app['pager']; $manager->createPager($pagefor) ->setCount($rowcount['count']) ->setTotalpages(ceil($rowcount['count'] / $limit)) ->setCurrent($page) ->setShowingFrom(($page - 1) * $limit + 1) ->setShowingTo(($page - 1) * $limit + count($taxorows)); return $content; } /** * @deprecated Deprecated since 3.0, to be removed in 4.0. * @see \Bolt\Storage\EventProcessor\TimedRecord::publishTimedRecords() */ public function publishTimedRecords() { } /** * @deprecated Deprecated since 3.0, to be removed in 4.0. * @see \Bolt\Storage\EventProcessor\TimedRecord::holdExpiredRecords() */ public function depublishExpiredRecords() { } /** * Split into meta-parameters and contenttype parameters. * * This is tightly coupled to $this->getContent() * * @param array|string|null $inParameters * * @see $this->decodeContentQuery() * * @return array */ private function organizeQueryParameters($inParameters = null) { $ctypeParameters = []; $metaParameters = []; if (is_array($inParameters)) { foreach ($inParameters as $key => $value) { if (in_array($key, ['page', 'limit', 'offset', 'returnsingle', 'printquery', 'paging', 'order'])) { $metaParameters[$key] = $value; } else { $ctypeParameters[$key] = $value; } } } // Set up the $pager array with relevant values. $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch(); $pager = [ 'for' => 'search', 'count' => $rowcount['count'], 'totalpages' => ceil($rowcount['count'] / $limit), * (entry,page) -> ['entry', 'page'] * event -> ['event'] * * @param string $text text with contenttypes * * @return array array with contenttype(slug)s */ private function decodeContentTypesFromText($text) { $contenttypes = []; if ((substr($text, 0, 1) == '(') && (substr($text, -1) == ')') ) { $contenttypes = explode(',', substr($text, 1, -1)); } else { $contenttypes[] = $text; } $instance = $this; $contenttypes = array_map( function ($name) use ($instance) { $ct = $instance->getContentType($name); return $ct['slug'] ?? null; }, $contenttypes ); return $contenttypes; } /** * Parse textquery into useable arguments. * * This is tightly coupled to $this->getContent() * * @see $this->decodeContentQuery() * * @param $textquery * @param array $decoded a pre-set decoded array to fill * @param array $metaParameters meta parameters * @param array $ctypeParameters contenttype parameters */ private function parseTextQuery($textquery, array &$decoded, array &$metaParameters, array &$ctypeParameters) { // Our default callback $decoded['queries_callback'] = [$this, 'executeGetContentQueries']; // Some special cases, like 'entry/1' or 'page/about' need to be caught before further processing. if (preg_match('#^/?([a-z0-9_-]+)/([0-9]+)$#i', $textquery, $match)) { // like 'entry/12' or '/page/12345' $decoded['contenttypes'] = $this->decodeContentTypesFromText($match[1]); $decoded['return_single'] = true; // if allow_numeric_slug option is set on contenttype, interpret number as slug instead of id $contenttype = $this->getContentType($decoded['contenttypes'][0]); $field = !empty($contenttype) ? ($contenttype['allow_numeric_slugs'] === true ? 'slug' : 'id') : 'id'; $ctypeParameters[$field] = $match[2]; } elseif (preg_match('#^/?([a-z0-9_(\),-]+)/search(/([0-9]+))?$#i', $textquery, $match)) { // like 'page/search or '(entry,page)/search' $decoded['contenttypes'] = $this->decodeContentTypesFromText($match[1]); $metaParameters['order'] = [$this, 'compareSearchWeights']; if (count($match) >= 3) { $metaParameters['limit'] = $match[3]; } $decoded['queries_callback'] = [$this, 'executeGetContentSearch']; } elseif (preg_match('#^/?([a-z0-9_-]+)/([a-z0-9_-]+)$#i', $textquery, $match)) { // like 'page/lorem-ipsum-dolor' or '/page/home' $decoded['contenttypes'] = $this->decodeContentTypesFromText($match[1]); $decoded['return_single'] = true; $ctypeParameters['slug'] = $match[2]; } elseif (preg_match('#^/?([a-z0-9_-]+)/(latest|first)/([0-9]+)$#i', $textquery, $match)) { // like 'page/latest/5' $decoded['contenttypes'] = $this->decodeContentTypesFromText($match[1]); if (!isset($metaParameters['order']) || $metaParameters['order'] === false) { $metaParameters['order'] = 'datepublish ' . ($match[2] == 'latest' ? 'DESC' : 'ASC'); } if (!isset($metaParameters['limit'])) { $metaParameters['limit'] = $match[3]; } // Set up the $pager array with relevant values. $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch(); $pager = [ 'for' => $taxonomytype['singular_slug'] . '_' . $slug, 'count' => $rowcount['count'], 'totalpages' => ceil($rowcount['count'] / $limit), 'current' => $page, 'showing_to' => ($page - 1) * $limit + count($taxorows), ]; $this->app['storage']->setPager($taxonomytype['singular_slug'] . '_' . $slug, $pager); return $content; } // unless something else is specified explicitly $request = $this->app['request_stack']->getCurrentRequest(); $isFrontend = $request ? Zone::isFrontend($request) : true; if ($isFrontend && empty($ctypeParameters['status'])) { $ctypeParameters['status'] = 'published'; } if (isset($metaParameters['returnsingle'])) { $decoded['return_single'] = $metaParameters['returnsingle']; unset($metaParameters['returnsingle']); } } /** * Prepare decoded for actual use. * * This is tightly coupled to $this->getContent() * * @see $this->decodeContentQuery() * * @param $decoded * @param $metaParameters * @param $ctypeParameters */ private function prepareDecodedQueryForUse(&$decoded, &$metaParameters, &$ctypeParameters) { // If there is only 1 contenttype we assume the where is NOT nested if (count($decoded['contenttypes']) == 1) { // So we need to add this nesting $ctypeParameters = [$decoded['contenttypes'][0] => $ctypeParameters]; } else { // We need to set every non-contenttypeslug parameters to each individual contenttypes $globalParameters = []; foreach ($ctypeParameters as $key => $parameter) { if (!in_array($key, $decoded['contenttypes'])) { $globalParameters[$key] = $parameter; } } foreach ($globalParameters as $key => $parameter) { unset($ctypeParameters[$key]); foreach ($decoded['contenttypes'] as $contenttype) { if (!isset($ctypeParameters[$contenttype])) { $ctypeParameters[$contenttype] = []; } if (!isset($ctypeParameters[$contenttype][$key])) { $ctypeParameters[$contenttype][$key] = $parameter; } } } // In this case query pagination never makes sense! $decoded['self_paginated'] = false; } if (($decoded['order_callback'] !== false) || ($decoded['return_single'] === true)) { // Callback sorting disables pagination $decoded['self_paginated'] = false; } if (!isset($metaParameters['order']) || $metaParameters['order'] === false) { if (count($decoded['contenttypes']) == 1) { if ($this->getContentTypeGrouping($decoded['contenttypes'][0])) { $decoded['order_callback'] = [$this, 'groupingSort']; } } } if ($decoded['return_single']) { $metaParameters['limit'] = 1; } elseif (!isset($metaParameters['limit'])) { $metaParameters['limit'] = 9999; } } /** * Get the parameter for the 'order by' part of a query. * * This is tightly coupled to $this->getContent() * * @param array $contenttype * @param string $orderValue * * @return string */ private function decodeQueryOrder($contenttype, $orderValue) { $order = false; if (($orderValue === false) || ($orderValue === '')) { if ($this->isValidColumn($contenttype['sort'], $contenttype, true)) { $order = $this->getEscapedSortorder($contenttype['sort'], false); } } else { $parOrder = Str::makeSafe($orderValue); if ($parOrder == 'RANDOM') { $dboptions = $this->app['db']->getParams(); $order = $dboptions['randomfunction']; } elseif ($this->isValidColumn($parOrder, $contenttype, true)) { $order = $this->getEscapedSortorder($parOrder, false); } } return $order; } /** * Decode a content textquery * * This is tightly coupled to $this->getContent() * * @param string $textquery * @param array $inParameters * * @internal param string $query the query (eg. page/about, entries/latest/5) * @internal param array $parameters parameters to the query * * @return array decoded query, keys: * contenttypes - array, contenttypeslugs that will be returned * return_single - boolean, true if only 1 result should be returned * self_paginated - boolean, true if already be paginated * order_callback - callback, sort results post-hydration after everything is merged * queries - array of SQL query parts: * * tablename - tablename * * contenttype - contenttype array * * from - from part * * where - where part * * order - order part * * params - bind-parameters * parameters - parameters to use after the queries */ private function decodeContentQuery($textquery, $inParameters = null) { $decoded = [ 'contenttypes' => [], 'return_single' => false, 'self_paginated' => true, 'order_callback' => false, 'queries' => [], 'parameters' => [], 'hydrate' => true, ]; list($metaParameters, $ctypeParameters) = $this->organizeQueryParameters($inParameters); $this->parseTextQuery($textquery, $decoded, $metaParameters, $ctypeParameters); // $decoded['contettypes'] gotten here // get page nr. from url if has $metaParameters['page'] = $this->app['pager']->getCurrentPage(implode('_', $decoded['contenttypes'])); $this->prepareDecodedQueryForUse($decoded, $metaParameters, $ctypeParameters); $decoded['parameters'] = $metaParameters; // for all the non-reserved parameters that are fields or taxonomies, we assume people want to do a 'where' foreach ($ctypeParameters as $contenttypeslug => $actualParameters) { $contenttype = $this->getContentType($contenttypeslug); $tablename = $this->getContenttypeTablename($contenttype); $where = []; $order = []; // Set the 'order', if specified in the meta_parameters. if (!empty($metaParameters['order'])) { $order[] = $this->getEscapedSortorder($metaParameters['order'], false); } $query = [ 'tablename' => $tablename, 'contenttype' => $contenttype, 'from' => sprintf('FROM %s', $tablename), 'where' => '', 'order' => '', 'params' => [], ]; if ($contenttype === false) { /* * We were logging here, but a couple of places like * TwigExtension::menuHelper() would trigger lots of hits, * filling logs and impacting performance as a result. * @see #1799 https://github.com/bolt/bolt/issues/1799 * * When we refactor we need to address the callers, as this is a * valid error state. */ continue; } if (is_array($actualParameters)) { // Set the 'FROM' part of the query, without the LEFT JOIN (i.e. no taxonomies..) foreach ($actualParameters as $key => $value) { if ($key == 'order') { $orderValue = $this->decodeQueryOrder($contenttype, $value); if ($orderValue !== false) { $order[] = $orderValue; } continue; } if ($key == 'filter' && !empty($value)) { $filterWhere = []; foreach ($contenttype['fields'] as $name => $fieldconfig) { if (in_array($fieldconfig['type'], ['text', 'textarea', 'html', 'markdown'])) { $filterWhere[] = sprintf( '%s.%s LIKE %s', $tablename, $name, $this->app['db']->quote('%' . $value . '%') ); } } if (count($filterWhere) > 0) { $where[] = '(' . implode(' OR ', $filterWhere) . ')'; } continue; } // build OR parts if key contains "|||" if (strpos($key, " ||| ") !== false) { $keyParts = explode(" ||| ", $key); $valParts = explode(" ||| ", $value); $orPart = '( '; $countParts = count($keyParts); for ($i = 0; $i < $countParts; $i++) { if (in_array($keyParts[$i], $this->getContentTypeFields($contenttype['slug'])) || in_array($keyParts[$i], Content::getBaseColumns())) { $rkey = $tablename . '.' . $keyParts[$i]; $fieldtype = $this->getContentTypeFieldType($contenttype['slug'], $keyParts[$i]); $orPart .= ' (' . $this->parseWhereParameter($rkey, $valParts[$i], $fieldtype) . ') OR '; } } if (strlen($orPart) > 2) { $where[] = substr($orPart, 0, -4) . ') '; } } // for all the parameters that are fields if (in_array($key, $this->getContentTypeFields($contenttype['slug'])) || in_array($key, Content::getBaseColumns()) ) { $rkey = $tablename . '.' . $key; $fieldtype = $this->getContentTypeFieldType($contenttype['slug'], $key); $where[] = $this->parseWhereParameter($rkey, $value, $fieldtype); } // for all the parameters that are taxonomies if (array_key_exists($key, $this->getContentTypeTaxonomy($contenttype['slug']))) { // check if we're trying to use "!" as a way of 'not'. If so, we need to do a 'NOT IN', instead // of 'IN'. And, the parameter in the subselect needs to be without "!" as a consequence. if (strpos($value, "!") !== false) { $notin = "NOT "; $value = str_replace("!", "", $value); } else { $notin = ""; } // Set the extra '$where', with subselect for taxonomies. $where[] = sprintf( '%s %s IN (SELECT content_id AS id FROM %s where %s AND ( %s OR %s ) AND %s)', $this->app['db']->quoteIdentifier('id'), $notin, $this->getTablename('taxonomy'), $this->parseWhereParameter($this->getTablename('taxonomy') . '.taxonomytype', $key), $this->parseWhereParameter($this->getTablename('taxonomy') . '.slug', $value), $this->parseWhereParameter($this->getTablename('taxonomy') . '.name', $value), $this->parseWhereParameter($this->getTablename('taxonomy') . '.contenttype', $contenttype['slug'] . ' || ' . $contenttype['name']) ); } } } if (count($order) == 0) { $order[] = $this->decodeQueryOrder($contenttype, false) ?: 'datepublish DESC'; } if (count($where) > 0) { $query['where'] = sprintf('WHERE (%s)', implode(' AND ', $where)); } if (count($order) > 0) { $order = implode(', ', $order); if (!empty($order)) { $query['order'] = sprintf('ORDER BY %s', $order); } } $decoded['queries'][] = $query; if (isset($inParameters['hydrate'])) { $decoded['hydrate'] = $inParameters['hydrate']; } } return $decoded; } /** * Decodes contextual page number from current request url if found. * * @param string $context Pager id/name in url which value we find * @param array $inParameters * * @return mixed Page number in context * @deprecated Not used in core anymore */ protected function decodePageParameter($context = '', $inParameters = null) { if (isset($inParameters['page']) && $inParameters['page'] !== null) { return $inParameters['page']; } else { $param = Pager::makeParameterId($context); /* @var $query \Symfony\Component\HttpFoundation\ParameterBag */ $query = $this->app['request']->query; $page = ($query) ? $query->get($param, $query->get('page', 1)) : 1; } return $page; } /** * Hydrate database rows into objects. * * @param array|string $contenttype * @param array $rows * @param boolean $getTaxoAndRel * * @throws \Exception * * @return array */ private function hydrateRows($contenttype, $rows, $getTaxoAndRel = true) { // Make sure content is set, and all content has information about its contenttype $objects = []; foreach ($rows as $row) { $objects[$row['id']] = $this->getContentObject($contenttype, $row); } if ($getTaxoAndRel) { // Make sure all content has their taxonomies and relations $this->getTaxonomy($objects); $this->getRelation($objects); $this->getRepeaters($objects); } return $objects; } /** * Execute the content queries. * * This is tightly coupled to $this->getContent() * * @see $this->getContent() * * @param array $decoded * @param array $parameters * * @return array */ protected function executeGetContentSearch($decoded, $parameters) { $limit = 2000; $offset = 0; // set correct limit and offset if these are set. if (isset($decoded['parameters']['limit'])) { $limit = $decoded['parameters']['limit']; } if (isset($decoded['parameters']['paging']) && ($decoded['parameters']['paging'] === true) && isset($decoded['parameters']['page'])) { // Pagenumbers are one-based, not zero-based. $offset = $limit * ($decoded['parameters']['page'] - 1); } $results = $this->searchContent( $parameters['filter'], $decoded['contenttypes'], null, $limit, $offset ); return [ $results['results'], $results['no_of_results'], ]; } /** * Execute the content queries. * * This is tightly coupled to $this->getContent() * * @see $this->getContent() * * @param array $decoded * * @return array */ protected function executeGetContentQueries($decoded) { // Perform actual queries and hydrate $totalResults = false; $results = false; foreach ($decoded['queries'] as $query) { $statement = sprintf( 'SELECT %s.* %s %s %s', $query['tablename'], $query['from'], $query['where'], $query['order'] ); if ($decoded['self_paginated'] === true) { // self pagination requires an extra query to return the actual number of results if ($decoded['return_single'] === false) { $countStatement = sprintf( 'SELECT COUNT(*) as count %s %s', $query['from'], $query['where'] ); $countRow = $this->app['db']->executeQuery($countStatement)->fetch(); $totalResults = $countRow['count']; } if (isset($decoded['parameters']['paging']) && $decoded['parameters']['paging'] === true) { $offset = ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit']; } else { $offset = null; } $limit = $decoded['parameters']['limit']; // @todo this will fail when actually using params on certain databases $statement = $this->app['db']->getDatabasePlatform()->modifyLimitQuery($statement, $limit, $offset); } elseif (!empty($decoded['parameters']['limit'])) { // If we're not paging, but we _did_ provide a limit. $limit = $decoded['parameters']['limit']; $statement = $this->app['db']->getDatabasePlatform()->modifyLimitQuery($statement, $limit); } if (!empty($decoded['parameters']['printquery'])) { // @todo formalize this echo nl2br(htmlentities($statement)); } $rows = $this->app['db']->fetchAll($statement, $query['params']); // Convert the row 'arrays' into \Bolt\Legacy\Content objects. // Only get the Taxonomies and Relations if we have to. $rows = $this->hydrateRows($query['contenttype'], $rows, $decoded['hydrate']); if ($results === false) { $results = $rows; } else { // We can no longer maintain keys when merging subresults $results = array_merge($results, array_values($rows)); } } if ($totalResults === false) { $totalResults = count($results); } return [$results, $totalResults]; } /** * getContent based on a 'human readable query'. * * Used directly by {% setcontent %} but also in other parts. * * This code has been split into multiple methods in the spirit of * separation of concerns, but the situation is still far from ideal. * * Where applicable each 'concern' notes the coupling in the local * documentation. * * @param string $textquery * @param string $parameters * @param array $pager * @param array $whereparameters * * @return array */ public function getContent($textquery, $parameters = '', &$pager = [], $whereparameters = []) { // Start the 'stopwatch' for the profiler. $this->app['stopwatch']->start('bolt.getcontent', 'doctrine'); // $whereparameters is passed if called from a compiled template. If present, merge it with $parameters. if (!empty($whereparameters)) { $parameters = array_merge((array) $parameters, (array) $whereparameters); } $logNotFound = false; if (isset($parameters['log_not_found'])) { $logNotFound = $parameters['log_not_found']; unset($parameters['log_not_found']); } // Decode this textquery $decoded = $this->decodeContentQuery($textquery, $parameters); if ($decoded === false) { $this->app['logger.system']->error("Not a valid query: '$textquery'", ['event' => 'storage']); $this->app['stopwatch']->stop('bolt.getcontent'); return false; } // Run table checks if (!$this->runContentTypeTableChecks($decoded['contenttypes'])) { $this->app['stopwatch']->stop('bolt.getcontent'); return false; } // Run the actual queries list($results, $totalResults) = call_user_func( $decoded['queries_callback'], $decoded, $parameters ); // Perform post hydration ordering if ($decoded['order_callback'] !== false) { if (is_scalar($decoded['order_callback']) && ($decoded['order_callback'] == 'RANDOM')) { shuffle($results); } else { uasort($results, $decoded['order_callback']); } } // Perform pagination if necessary, but never paginate when 'returnsingle' is used. $offset = 0; $limit = false; if (($decoded['self_paginated'] === false) && (isset($decoded['parameters']['page'])) && (!$decoded['return_single'])) { $offset = ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit']; $limit = $decoded['parameters']['limit']; } if ($limit !== false) { $results = array_slice($results, $offset, $limit); } // Return content if ($decoded['return_single']) { if (count($results) > 0) { $this->app['stopwatch']->stop('bolt.getcontent'); return reset($results); } if ($logNotFound) { $msg = sprintf( "Requested specific query '%s', not found.", $textquery ); $this->app['logger.system']->error($msg, ['event' => 'storage']); } $this->app['stopwatch']->stop('bolt.getcontent'); return false; } // Set up the $pager array with relevant values, but only if we requested paging. if (isset($decoded['parameters']['paging'])) { $pagerName = implode('_', $decoded['contenttypes']); /** @var \Bolt\Pager\PagerManager $manager */ if (isset($inParameters['page']) && $inParameters['page'] !== null) { return $inParameters['page']; } else { $param = Pager::makeParameterId($context); /* @var $query \Symfony\Component\HttpFoundation\ParameterBag */ $query = $this->app['request']->query; $page = ($query) ? $query->get($param, $query->get('page', 1)) : 1; } return $page; $this->app['stopwatch']->stop('bolt.getcontent'); return $results; } /** * Check for the existence of ContentType tables. * * @param array $contenttypes ContentType slugs to check * * @return boolean */ private function runContentTypeTableChecks(array $contenttypes) { $checkedcontenttype = []; foreach ($contenttypes as $contenttypeslug) { // Make sure we do this only once per contenttype if (isset($checkedcontenttype[$contenttypeslug])) { continue; } $checkedcontenttype[$contenttypeslug] = true; // If the table doesn't exist (yet), return false. $contenttype = $this->getContentType($contenttypeslug); $tablename = $this->getContenttypeTablename($contenttype); if (!$this->tableExists($tablename)) { return false; } } return true; } /** * Check if a given name is a valid column, and if it can be used in queries. * * @param string $name * @param array $contenttype * @param boolean $allowVariants * * @return boolean */ private function isValidColumn($name, $contenttype, $allowVariants = false) { $isValidColumn = false; if ($this->isMultiOrderQuery($name)) { $separatedOrders = $this->getOrderBys($name); } else { $separatedOrders = [$name]; } //Loop through each term if multiple foreach ($separatedOrders as $index => $name) { $name = trim($name); // Strip the minus in '-title' if allowed. if ($allowVariants) { if ((strlen($name) > 0) && ($name[0] == "-")) { $name = substr($name, 1); } $name = $this->getFieldName($name); } // Check if the $name is in the contenttype's fields. if (isset($contenttype['fields'][$name])) { $isValidColumn = true; } if (in_array($name, Content::getBaseColumns())) { $isValidColumn = true; } // Repeating fields are handled differently so skip here if (isset($contenttype['fields'][$name]) && $contenttype['fields'][$name]['type'] == 'repeater') { $isValidColumn = false; } if (! $isValidColumn) { return false; } } return true; } /** * Get field name, stripping possible " DESC" " ASC" etc. * * @param string $name * * @return string */ private function getFieldName($name) { return preg_replace("/ (desc|asc)$/i", "", $name); } /** * Get an escaped sortorder for use in SQL, from a fieldname like 'title' or '-id'. * * for example, -id returns `r`.`id` DESC * * @param string $name * @param string $prefix * * @return string */ private function getEscapedSortorder($name, $prefix = 'r') { $order = ''; $separatedOrders = $this->getOrderBys($name); $totalOrderByElements = count($separatedOrders); foreach ($separatedOrders as $index => $name) { list($name, $asc) = $this->getSortOrder($name); // If we don't have a name, we can't determine a sortorder. if (empty($name)) { return false; } if (strpos($name, 'RAND') !== false) { $order .= $name; } elseif ($prefix !== false) { $order .= $this->app['db']->quoteIdentifier($prefix . '.' . $name); } else { $order .= $this->app['db']->quoteIdentifier($name); } if (! $asc) { $order .= ' DESC'; } if ($this->isNotLastItemInArray($totalOrderByElements, $index)) { $order .= ','; } } return $order; } /** * Check if this is the last element in the array. * * @param $totalOrderByElements * @param $index * * @return boolean */ protected function isNotLastItemInArray($totalOrderByElements, $index) { return $totalOrderByElements !== ($index + 1); } /** * Get the ORDER BY parameters for the query. * * @param $order * * @return array */ protected function getOrderBys($order) { $separatedOrders = [$order]; if ($this->isMultiOrderQuery($order)) { $separatedOrders = explode(',', $order); } return $separatedOrders; } /** * Check if this query has multiple ORDER BY parameters. * * @param $order * * @return boolean */ protected function isMultiOrderQuery($order) { return (is_string($order) && (strpos($order, ',') !== false)); } /** * Get sorting order of name, stripping possible " DESC" " ASC" etc., and * also return the sorting order. * * @param string $name * * @return string */ public function getSortOrder($name = '-datepublish') { // If we don't get a string, we can't determine a sortorder. if (!is_string($name)) { return false; } $parts = explode(' ', trim($name)); $fieldname = $parts[0]; $sort = 'ASC'; if (isset($parts[1])) { $sort = $parts[1]; } if ($fieldname[0] == '-') { $fieldname = substr($fieldname, 1); $sort = 'DESC'; } return [$fieldname, (strtoupper($sort) == 'ASC')]; } /** * Helper function for sorting Records of content that have a Grouping. * * @param Content $a * @param Content $b * * @return int */ private function groupingSort(Content $a, Content $b) { // Same group, sort within group. if ($a->group['slug'] == $b->group['slug']) { if (!empty($a->sortorder) || !empty($b->sortorder)) { if (!isset($a->sortorder)) { return 1; } elseif (!isset($b->sortorder)) { return -1; } else { return ($a->sortorder < $b->sortorder) ? -1 : 1; } } if (!empty($a->contenttype['sort'])) { // Same group, so we sort on contenttype['sort'] list($secondSort, $order) = $this->getSortOrder($a->contenttype['sort']); $vala = strtolower($a->values[$secondSort]); $valb = strtolower($b->values[$secondSort]); if ($vala == $valb) { // Set up the $pager array with relevant values, but only if we requested paging. if (isset($decoded['parameters']['paging'])) { $pagerName = implode('_', $decoded['contenttypes']); $pager = [ 'for' => $pagerName, 'count' => $totalResults, 'totalpages' => ceil($totalResults / $decoded['parameters']['limit']), } 'showing_from' => ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit'] + 1, 'showing_to' => ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit'] + count($results), ]; $this->setPager($pagerName, $pager); $this->app['twig']->addGlobal('pager', $this->getPager()); } $this->app['stopwatch']->stop('bolt.getcontent'); * Helper function to set the proper 'where' parameter, * when getting values like '<2012' or '!bob'. * * @param string $key * @param string $value * @param mixed $fieldtype * * @return string */ private function parseWhereParameter($key, $value, $fieldtype = false) { $value = trim($value); // check if we need to split. if (strpos($value, " || ") !== false) { $values = explode(" || ", $value); foreach ($values as $index => $value) { $values[$index] = $this->parseWhereParameter($key, $value, $fieldtype); } return "( " . implode(" OR ", $values) . " )"; } elseif (strpos($value, " && ") !== false) { $values = explode(" && ", $value); foreach ($values as $index => $value) { $values[$index] = $this->parseWhereParameter($key, $value, $fieldtype); } return "( " . implode(" AND ", $values) . " )"; } // Set the correct operator for the where clause $operator = "="; $first = substr($value, 0, 1); if ($first == "!") { $operator = "!="; $value = substr($value, 1); } elseif (substr($value, 0, 2) == "<=") { $operator = "<="; $value = substr($value, 2); } elseif (substr($value, 0, 2) == ">=") { $operator = ">="; $value = substr($value, 2); } elseif ($first == "<") { $operator = "<"; $value = substr($value, 1); } elseif ($first == ">") { $operator = ">"; $value = substr($value, 1); } elseif ($first == "%" || substr($value, -1) == "%") { $operator = "LIKE"; } // Use strtotime to allow selections like "< last monday" or "this year" if (in_array($fieldtype, ['date', 'datetime']) && ($timestamp = strtotime($value)) !== false) { $value = date('Y-m-d H:i:s', $timestamp); } $parameter = sprintf("%s %s %s", $this->app['db']->quoteIdentifier($key), $operator, $this->app['db']->quote($value)); return $parameter; } /** * Get the contenttype as an array, based on the given $contenttypeslug. * * @param string $contenttypeslug * * @return boolean|array */ public function getContentType($contenttypeslug) { // Return false if empty, can't find it. if (empty($contenttypeslug)) { return false; } $contenttype = $this->app['config']->get('contenttypes/' . $contenttypeslug); // See if we've either given the correct contenttype, or try to find it by name or singular_name. if (!$contenttype) { // Also check for the slugified version of the content type $slugifiedContentType = $this->app['slugify']->slugify($contenttypeslug); $contenttype = $this->app['config']->get('contenttypes/' . $slugifiedContentType); if (!$contenttype) { foreach ($this->app['config']->get('contenttypes') as $key => $ct) { if ( (isset($ct['slug']) && (($contenttypeslug === $ct['slug']) || ($slugifiedContentType === $ct['slug'])) ) || (isset($ct['singular_slug']) && (($contenttypeslug === $ct['singular_slug']) || ($slugifiedContentType === $ct['singular_slug'])) ) || $slugifiedContentType === $this->app['slugify']->slugify($ct['name']) || $slugifiedContentType === $this->app['slugify']->slugify($ct['singular_name']) ) { $contenttype = $ct; $contenttype['key'] = $key; break; } } } } if (!empty($contenttype)) { return $contenttype; } else { return false; } } /** * Get the taxonomy as an array, based on the given $taxonomyslug. * * @param string $taxonomyslug * * @return boolean|array */ public function getTaxonomyType($taxonomyslug) { $taxonomyslug = $this->app['slugify']->slugify($taxonomyslug); // Return false if empty, can't find it. if (empty($taxonomyslug)) { return false; } // See if we've either given the correct contenttype, or try to find it by name or singular_name. if ($this->app['config']->get('taxonomy/' . $taxonomyslug)) { $taxonomytype = $this->app['config']->get('taxonomy/' . $taxonomyslug); } else { foreach ($this->app['config']->get('taxonomy') as $key => $tt) { if (isset($tt['singular_slug']) && ($taxonomyslug == $tt['singular_slug'])) { $taxonomytype = $this->app['config']->get('taxonomy/' . $key); break; } } } if (!empty($taxonomytype)) { return $taxonomytype; } else { return false; } } /** * Get an array of the available contenttypes. * * @return array $contenttypes */ public function getContentTypes() { return array_keys($this->app['config']->get('contenttypes')); } /** * Get an array of the available fields for a given contenttype. * * @param string $contenttypeslug * * @return array $fields */ public function getContentTypeFields($contenttypeslug) { $contenttype = $this->getContentType($contenttypeslug); if (empty($contenttype['fields'])) { return []; } else { return array_keys($contenttype['fields']); } } /** * Get the fieldtype for a given contenttype and fieldname. * * @param string $contenttypeslug * @param string $fieldname * * @return array $fields */ public function getContentTypeFieldType($contenttypeslug, $fieldname) { $contenttype = $this->getContentType($contenttypeslug); if (in_array($fieldname, ['datecreated', 'datechanged', 'datepublish', 'datedepublish'])) { return "datetime"; } elseif (isset($contenttype['fields'][$fieldname]['type'])) { return $contenttype['fields'][$fieldname]['type']; } else { return false; } } /** * Check if a given contenttype has a grouping, and if it does, return it. * * @param string $contenttypeslug * * @return mixed $grouping */ public function getContentTypeGrouping($contenttypeslug) { $grouping = false; $taxonomy = $this->getContentTypeTaxonomy($contenttypeslug); foreach ($taxonomy as $tax) { if ($tax['behaves_like'] === 'grouping') { $grouping = $tax['slug']; break; } } return $grouping; } /** * Get an array of the available taxonomytypes for a given contenttype. * * @param string $contenttypeslug * * @return array $taxonomy */ public function getContentTypeTaxonomy($contenttypeslug) { $contenttype = $this->getContentType($contenttypeslug); if (empty($contenttype['taxonomy'])) { return []; } else { $taxokeys = $contenttype['taxonomy']; $taxonomy = []; foreach ($taxokeys as $key) { if ($this->app['config']->get('taxonomy/' . $key)) { $taxonomy[$key] = $this->app['config']->get('taxonomy/' . $key); } } return $taxonomy; } } /** * Get the taxonomy for one or more units of content, return the array with the taxonomy attached. * * @param \Bolt\Legacy\Content[] $content * * @return array $content */ protected function getTaxonomy($content) { $tablename = $this->getTablename("taxonomy"); $ids = Arr::column($content, 'id'); if (empty($ids)) { return; } // Get the contenttype from first $content $first = reset($content); $config = $first->contenttype; $contenttype = (isset($config['key'])) ? $config['key'] : $config['slug']; $taxonomytypes = $this->app['config']->get('taxonomy'); // If there are no taxonomytypes, there won't be any results, so we return. if (empty($taxonomytypes)) { return; } $query = sprintf( "SELECT * FROM %s WHERE content_id IN (?) AND contenttype=? AND taxonomytype IN (?)", $tablename ); $rows = $this->app['db']->executeQuery( $query, [$ids, $contenttype, array_keys($taxonomytypes)], [DoctrineConn::PARAM_INT_ARRAY, \PDO::PARAM_STR, DoctrineConn::PARAM_STR_ARRAY] )->fetchAll(); foreach ($rows as $row) { $content[$row['content_id']]->setTaxonomy($row['taxonomytype'], $row['slug'], $row['name'], $row['sortorder']); } foreach ($content as $key => $value) { $content[$key]->sortTaxonomy(); } } /** * Update / insert taxonomy for a given content-unit. * * @param string $contenttype * @param integer $contentId * @param array $taxonomy */ protected function updateTaxonomy($contenttype, $contentId, $taxonomy) { $tablename = $this->getTablename("taxonomy"); $configTaxonomies = $this->app['config']->get('taxonomy'); // Make sure $contenttypeslug is a 'slug' if (is_array($contenttype)) { $contenttypeslug = $contenttype['slug']; } else { $contenttypeslug = $contenttype; } // If our contenttype has no taxonomies, there's nothing for us to do here. if (!isset($contenttype['taxonomy'])) { return; } foreach ($contenttype['taxonomy'] as $taxonomytype) { // Set 'newvalues to 'empty array' if not defined if (!empty($taxonomy[$taxonomytype])) { $newslugs = $taxonomy[$taxonomytype]; } else { $newslugs = []; } // Get the current values from the DB. $query = sprintf( "SELECT id, slug, sortorder FROM %s WHERE content_id=? AND contenttype=? AND taxonomytype=?", $tablename ); $currentvalues = $this->app['db']->executeQuery( $query, [$contentId, $contenttypeslug, $taxonomytype], [\PDO::PARAM_INT, \PDO::PARAM_STR, \PDO::PARAM_STR] )->fetchAll(); if (!empty($currentvalues)) { $currentsortorder = $currentvalues[0]['sortorder']; $currentvalues = Arr::column($currentvalues, 'slug', 'id'); } else { $currentsortorder = 0; $currentvalues = []; } // Add the ones not yet present. foreach ($newslugs as $slug) { // If it's like 'desktop#10', split it into value and sortorder. list($slug, $sortorder) = explode('#', $slug . "#"); // @todo clean up and/or refactor // If you save this content via anything other than the Bolt // backend (see Content->setFromPost), then taxonomies that // behave like groupings, will have their sortorders reset to 0. if ($configTaxonomies[$taxonomytype]['behaves_like'] == 'grouping' && empty($sortorder) && $sortorder !== '0') { $sortorder = $currentsortorder; } if (empty($sortorder)) { $sortorder = 0; } // Make sure we have a 'name'. if (isset($configTaxonomies[$taxonomytype]['options'][$slug])) { $name = $configTaxonomies[$taxonomytype]['options'][$slug]; } else { $name = $slug; } // Make sure the slug is also set correctly if (!isset($configTaxonomies[$taxonomytype]['options'][$slug])) { // Assume we passed a value, instead of a slug. Turn it back into a proper slug if (isset($configTaxonomies[$taxonomytype]['options']) && is_array($configTaxonomies[$taxonomytype]['options']) && array_search($slug, $configTaxonomies[$taxonomytype]['options'])) { $slug = array_search($slug, $configTaxonomies[$taxonomytype]['options']); } else { // make sure it's at least a slug-like value. $slug = $this->app['slugify']->slugify($slug); } } if ((!in_array($slug, $currentvalues) || ($currentsortorder != $sortorder)) && (!empty($slug))) { // Insert it! $row = [ 'content_id' => $contentId, 'contenttype' => $contenttypeslug, 'taxonomytype' => $taxonomytype, 'slug' => $slug, 'name' => $name, 'sortorder' => (int) $sortorder, ]; $this->app['db']->insert($tablename, $row); } } // Convert new slugs to lowercase to compare in the delete process $newSlugsNormalised = []; foreach ($newslugs as $slug) { // If it's like 'desktop#10', split it into value and sortorder. list($slug, $sortorder) = explode('#', $slug . "#"); $slug = $this->app['slugify']->slugify($slug); if (!empty($sortorder)) { $slug = $slug . '#' . $sortorder; } $newSlugsNormalised[] = $slug; } // Delete the ones that have been removed. foreach ($currentvalues as $id => $slug) { // Make it look like 'desktop#10' $valuewithorder = $slug . "#" . $currentsortorder; $slugkey = '/' . $configTaxonomies[$taxonomytype]['slug'] . '/' . $slug; if (!in_array($slug, $newSlugsNormalised) && !in_array($valuewithorder, $newSlugsNormalised) && !array_key_exists($slugkey, $newSlugsNormalised)) { $this->app['db']->delete($tablename, ['id' => $id]); } } } } /** * Get the relations for one or more units of content, return the array with the taxonomy attached. * * @param array $content * * @return array $content */ protected function getRelation($content) { $tablename = $this->getTablename("relations"); $ids = Arr::column($content, 'id'); if (empty($ids)) { return; } // Get the contenttype from first $content $first = reset($content); $contenttype = isset($first->contenttype['key']) ? $first->contenttype['key'] : $first->contenttype['slug']; $query = sprintf( "SELECT * FROM %s WHERE from_contenttype=? AND from_id IN (?) ORDER BY id", $tablename ); $params = [$contenttype, $ids]; $paramTypes = [\PDO::PARAM_STR, DoctrineConn::PARAM_INT_ARRAY]; $rows = $this->app['db']->executeQuery($query, $params, $paramTypes)->fetchAll(); foreach ($rows as $row) { $content[$row['from_id']]->setRelation($row['to_contenttype'], $row['to_id']); } // switch it, flip it and reverse it. wop wop wop. $query = sprintf( "SELECT * FROM %s WHERE to_contenttype=? AND to_id IN (?) ORDER BY id", $tablename ); $params = [$contenttype, $ids]; $paramTypes = [\PDO::PARAM_STR, DoctrineConn::PARAM_INT_ARRAY]; $rows = $this->app['db']->executeQuery($query, $params, $paramTypes)->fetchAll(); foreach ($rows as $row) { $content[$row['to_id']]->setRelation($row['from_contenttype'], $row['from_id']); } } public function getRepeaters($content) { $ids = Arr::column($content, 'id'); if (empty($ids)) { return; } // Get the contenttype from first $content $first = reset($content); // Try with the key first, if this isn't available then fall back to using the slug $contenttypeslug = (isset($first->contenttype['key'])) ? $first->contenttype['key'] : $first->contenttype['slug'] ; $contenttype = $this->getContentType($contenttypeslug); $repo = $this->app['storage']->getRepository(FieldValue::class); foreach ($ids as $id) { foreach ($contenttype['fields'] as $fieldkey => $field) { if ($field['type'] == 'repeater' || $field['type'] == 'block') { $collection = new RepeatingFieldCollection($this->app['storage'], $field); try { $existingFields = $repo->getExistingFields($id, $contenttypeslug, $fieldkey) ?: []; foreach ($existingFields as $group => $ids) { $collection->addFromReferences($ids, $group); } } catch (Exception $e) { } $content[$id]->setValue($fieldkey, $collection); } } } } /** * Update / insert relation for a given content-unit. * * $relation looks like: * arr(2) * [ * "pages" => arr(1) * [ * 0 => str(2) "22" * ] * "showcases" => arr(3) * [ * 0 => str(2) "15" * 1 => str(1) "9" * 2 => str(2) "13" * ] * ] * * $currentvalues looks like * arr(2) * [ * 0 => arr(3) * [ * "id" => str(1) "5" * "to_contenttype" => str(12) "showcases" * "to_id" => str(2) "15" * ] * 1 => arr(3) * [ * "id" => str(1) "6" * "to_contenttype" => str(12) "showcases" * "to_id" => str(1) "9" * ] * ] * * @param string $contenttype * @param integer $contentId * @param array $relation */ protected function updateRelation($contenttype, $contentId, $relation) { $tablename = $this->getTablename("relations"); // Get the current
{'commit': "diff --git a/src/Legacy/Storage.php b/src/Legacy/Storage.php\nindex 98af64c12..935f74397 100644\n--- a/src/Legacy/Storage.php\n+++ b/src/Legacy/Storage.php\n@@ -43,7 +43,7 @@ class Storage\n \n     /** @var bool Test to indicate if we're inside a dispatcher. */\n     private $inDispatcher = false;\n-\n+// @todo Pager cleanup\n     /** @var array */\n     protected static $pager = [];\n \n@@ -875,6 +875,12 @@ class Storage\n         ];\n     }\n \n+    /**\n+     * @todo Pager\n+     * @param array $parameters\n+     * @param array $pager\n+     * @return array\n+     */\n     public function searchAllContentTypes(array $parameters = [], &$pager = [])\n     {\n         // Note: we can only apply this kind of results aggregating when we don't\n@@ -892,6 +898,14 @@ class Storage\n         return $result;\n     }\n \n+    /**\n+     * @todo Pager\n+     * @param $contenttypename\n+     * @param array $parameters\n+     * @param array $pager\n+     * @return array\n+     * @throws \\Exception\n+     */\n     public function searchContentType($contenttypename, array $parameters = [], &$pager = [])\n     {\n         $where = [];\n@@ -988,7 +1002,7 @@ class Storage\n \n         // Set up the $pager array with relevant values.\n         $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch();\n-        $pager = [\n+        $this->app['pager']['search'] = [\n             'for'          => 'search',\n             'count'        => $rowcount['count'],\n             'totalpages'   => ceil($rowcount['count'] / $limit),\n@@ -1076,8 +1090,10 @@ class Storage\n \n         // Set up the $pager array with relevant values.\n         $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch();\n-        $pager = [\n-            'for'          => $taxonomytype['singular_slug'] . '_' . $slug,\n+\n+        $pagefor = $taxonomytype['singular_slug'].'_'.$slug;\n+        $this->app['pager'][$pagefor] = [\n+            'for'          => $pagefor,\n             'count'        => $rowcount['count'],\n             'totalpages'   => ceil($rowcount['count'] / $limit),\n             'current'      => $page,\n@@ -1085,8 +1101,6 @@ class Storage\n             'showing_to'   => ($page - 1) * $limit + count($taxorows),\n         ];\n \n-        $this->app['storage']->setPager($taxonomytype['singular_slug'] . '_' . $slug, $pager);\n-\n         return $content;\n     }\n \n@@ -1662,10 +1676,12 @@ class Storage\n         if (isset($inParameters['page']) && $inParameters['page'] !== null) {\n             return $inParameters['page'];\n         } else {\n-            $param = Pager::makeParameterId($context);\n+//          @todo Pager cleanup\n+//            $param = Pager::makeParameterId($context);\n             /* @var $query \\Symfony\\Component\\HttpFoundation\\ParameterBag */\n-            $query = $this->app['request']->query;\n-            $page = ($query) ? $query->get($param, $query->get('page', 1)) : 1;\n+//            $query = $this->app['request']->query;\n+//            $page = ($query) ? $query->get($param, $query->get('page', 1)) : 1;\n+            $page = $this->app['pager'][$context]->current;\n         }\n \n         return $page;\n@@ -1918,7 +1934,7 @@ class Storage\n         // Set up the $pager array with relevant values, but only if we requested paging.\n         if (isset($decoded['parameters']['paging'])) {\n             $pagerName = implode('_', $decoded['contenttypes']);\n-            $pager = [\n+            $this->app['pager'][$pagerName] = [\n                 'for'          => $pagerName,\n                 'count'        => $totalResults,\n                 'totalpages'   => ceil($totalResults / $decoded['parameters']['limit']),\n@@ -1926,8 +1942,7 @@ class Storage\n                 'showing_from' => ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit'] + 1,\n                 'showing_to'   => ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit'] + count($results),\n             ];\n-            $this->setPager($pagerName, $pager);\n-            $this->app['twig']->addGlobal('pager', $this->getPager());\n+            $this->app['twig']->addGlobal('pager', $this->app['pager']);\n         }\n \n         $this->app['stopwatch']->stop('bolt.getcontent');\n@@ -2935,8 +2950,11 @@ class Storage\n     /**\n      * Setter for pager storage element.\n      *\n-     * @param string      $name\n+     * @param string $name\n      * @param array|Pager $pager\n+     * @todo Pager cleanup\n+     * @deprecated\n+     * @return $this\n      */\n     public function setPager($name, $pager)\n     {\n@@ -2951,6 +2969,7 @@ class Storage\n      * @param string $name Optional name of a pager element. Whole pager map returns if no name given.\n      *\n      * @return array\n+     * @todo Pager cleanup\n      */\n     public function &getPager($name = null)\n     {\n", 'commit_subject': 'Pager service in Storage', 'repo_name': 'bolt/bolt', 'committer': {'name': 'Rixbeck', 'email': 'd9176ad8974d4913c2ddd8be16e0b7273dbcef32@neologik.hu', 'time_sec': '1451647465', 'tz_offset': '60', 'date': {'seconds': '1451647465'}}, 'commit_tree': '1f12533cba7c567b0be2e3bb7785ed8721df0c77', 'commit_message': 'Pager service in Storage\n', 'language_name': 'JavaScript', 'license': 'mit', 'hunks': "@@ -43,7 +43,7 @@ class Storage\n \n     /** @var bool Test to indicate if we're inside a dispatcher. */\n     private $inDispatcher = false;\n-\n+// @todo Pager cleanup\n     /** @var array */\n     protected static $pager = [];\n \n@@ -875,6 +875,12 @@ class Storage\n         ];\n     }\n \n+    /**\n+     * @todo Pager\n+     * @param array $parameters\n+     * @param array $pager\n+     * @return array\n+     */\n     public function searchAllContentTypes(array $parameters = [], &$pager = [])\n     {\n         // Note: we can only apply this kind of results aggregating when we don't\n@@ -892,6 +898,14 @@ class Storage\n         return $result;\n     }\n \n+    /**\n+     * @todo Pager\n+     * @param $contenttypename\n+     * @param array $parameters\n+     * @param array $pager\n+     * @return array\n+     * @throws \\Exception\n+     */\n     public function searchContentType($contenttypename, array $parameters = [], &$pager = [])\n     {\n         $where = [];\n@@ -988,7 +1002,7 @@ class Storage\n \n         // Set up the $pager array with relevant values.\n         $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch();\n-        $pager = [\n+        $this->app['pager']['search'] = [\n             'for'          => 'search',\n             'count'        => $rowcount['count'],\n             'totalpages'   => ceil($rowcount['count'] / $limit),\n@@ -1076,8 +1090,10 @@ class Storage\n \n         // Set up the $pager array with relevant values.\n         $rowcount = $this->app['db']->executeQuery($pagerquery)->fetch();\n-        $pager = [\n-            'for'          => $taxonomytype['singular_slug'] . '_' . $slug,\n+\n+        $pagefor = $taxonomytype['singular_slug'].'_'.$slug;\n+        $this->app['pager'][$pagefor] = [\n+            'for'          => $pagefor,\n             'count'        => $rowcount['count'],\n             'totalpages'   => ceil($rowcount['count'] / $limit),\n             'current'      => $page,\n@@ -1085,8 +1101,6 @@ class Storage\n             'showing_to'   => ($page - 1) * $limit + count($taxorows),\n         ];\n \n-        $this->app['storage']->setPager($taxonomytype['singular_slug'] . '_' . $slug, $pager);\n-\n         return $content;\n     }\n \n@@ -1662,10 +1676,12 @@ class Storage\n         if (isset($inParameters['page']) && $inParameters['page'] !== null) {\n             return $inParameters['page'];\n         } else {\n-            $param = Pager::makeParameterId($context);\n+//          @todo Pager cleanup\n+//            $param = Pager::makeParameterId($context);\n             /* @var $query \\Symfony\\Component\\HttpFoundation\\ParameterBag */\n-            $query = $this->app['request']->query;\n-            $page = ($query) ? $query->get($param, $query->get('page', 1)) : 1;\n+//            $query = $this->app['request']->query;\n+//            $page = ($query) ? $query->get($param, $query->get('page', 1)) : 1;\n+            $page = $this->app['pager'][$context]->current;\n         }\n \n         return $page;\n@@ -1918,7 +1934,7 @@ class Storage\n         // Set up the $pager array with relevant values, but only if we requested paging.\n         if (isset($decoded['parameters']['paging'])) {\n             $pagerName = implode('_', $decoded['contenttypes']);\n-            $pager = [\n+            $this->app['pager'][$pagerName] = [\n                 'for'          => $pagerName,\n                 'count'        => $totalResults,\n                 'totalpages'   => ceil($totalResults / $decoded['parameters']['limit']),\n@@ -1926,8 +1942,7 @@ class Storage\n                 'showing_from' => ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit'] + 1,\n                 'showing_to'   => ($decoded['parameters']['page'] - 1) * $decoded['parameters']['limit'] + count($results),\n             ];\n-            $this->setPager($pagerName, $pager);\n-            $this->app['twig']->addGlobal('pager', $this->getPager());\n+            $this->app['twig']->addGlobal('pager', $this->app['pager']);\n         }\n \n         $this->app['stopwatch']->stop('bolt.getcontent');\n@@ -2935,8 +2950,11 @@ class Storage\n     /**\n      * Setter for pager storage element.\n      *\n-     * @param string      $name\n+     * @param string $name\n      * @param array|Pager $pager\n+     * @todo Pager cleanup\n+     * @deprecated\n+     * @return $this\n      */\n     public function setPager($name, $pager)\n     {\n@@ -2951,6 +2969,7 @@ class Storage\n      * @param string $name Optional name of a pager element. Whole pager map returns if no name given.\n      *\n      * @return array\n+     * @todo Pager cleanup\n      */\n     public function &getPager($name = null)\n     {\n", 'addition_count': 32, 'deletion_count': 13, 'src_file': 'a/src/Legacy/Storage.php', 'tgt_file': 'b/src/Legacy/Storage.php', 'file_extension': '.php', 'before_file': '<?php\n\nnamespace Bolt\\Legacy;\n\nuse Bolt;\nuse Bolt\\Collection\\Arr;\nuse Bolt\\Controller\\Zone;\nuse Bolt\\Events\\StorageEvent;\nuse Bolt\\Events\\StorageEvents;\nuse Bolt\\Exception\\StorageException;\nuse Bolt\\Helpers\\Html;\nuse Bolt\\Helpers\\Str;\nuse Bolt\\Pager;\nuse Bolt\\Storage\\Entity\\FieldValue;\nuse Bolt\\Storage\\Field\\Collection\\RepeatingFieldCollection;\nuse Bolt\\Translation\\Translator as Trans;\nuse Doctrine\\DBAL\\Connection as DoctrineConn;\nuse Doctrine\\DBAL\\Platforms\\PostgreSqlPlatform;\nuse Exception;\nuse Silex\\Application;\n\n/**\n * Legacy Storage class.\n *\n * @deprecated Deprecated since 3.0, to be removed in 4.0.\n */\nclass Storage\n{\n    /** @var array */\n    public $images;\n\n    /** @var Application */\n    private $app;\n\n    /** @var array */\n    private $tables = [];\n\n    /** @var string */\n    private $prefix;\n\n    /** @var array */\n    private $checkedfortimed = [];\n\n    /** @var bool Test to indicate if we\'re inside a dispatcher. */\n    private $inDispatcher = false;\n\n    /** @var array */\n    protected static $pager = [];\n\n    }\n\n    /**\n     * Get an object for the content of a specific contenttype. This will be\n     * \\Bolt\\Legacy\\Content, unless the contenttype defined another class to be\n     * used.\n     *\n     * @param array|string $contenttype\n     * @param array        $values\n     * @param bool         $isRootType\n     *\n     * @throws \\Exception\n     *\n     * @return \\Bolt\\Legacy\\Content\n     */\n    public function getContentObject($contenttype, $values = [], $isRootType = true)\n    {\n        // Make sure $contenttype is an array, and not just the slug.\n        if (!is_array($contenttype)) {\n            $contenttype = $this->getContentType($contenttype);\n        }\n\n        // If the contenttype has a \'class\' specified, and the class exists,\n        // Initialize the content as an object of that class.\n        if (!empty($contenttype[\'class\']) && class_exists($contenttype[\'class\'])) {\n            $content = new $contenttype[\'class\']($this->app, $contenttype, $values, $isRootType);\n\n            // Check if the class actually extends \\Bolt\\Legacy\\Content.\n            if (!($content instanceof Content)) {\n                throw new \\Exception($contenttype[\'class\'] . \' does not extend \\\\Bolt\\\\Legacy\\\\Content.\');\n            }\n        } else {\n            $content = new Content($this->app, $contenttype, $values, $isRootType);\n        }\n\n        return $content;\n    }\n\n    /**\n     * Add some records with dummy content.\n     *\n     * Only fill the contenttypes passed as parameters\n     * If the parameters is empty, only fill empty tables\n     *\n     * @see \\Bolt\\Storage\\Database\\\\Prefill\n     *\n     * @param array $contentTypes\n     *\n     * @return string\n     */\n    public function preFill($contentTypes = [])\n    {\n        if (empty($contentTypes)) {\n            $contentTypes = $this->app[\'config\']->get(\'contenttypes\');\n            $contentTypeNames = array_keys($contentTypes);\n        } else {\n            $contentTypeNames = (array) $contentTypes;\n        }\n\n        /** @var \\Bolt\\Storage\\Database\\Prefill\\Builder $builder */\n        $builder = $this->app[\'prefill.builder\'];\n        $results = $builder->build($contentTypeNames, 5);\n\n        $output = \'\';\n        foreach ($results->get(\'created\') as $contentTypeName => $titles) {\n            foreach ($titles as $title) {\n                $output .= Trans::__(\n                    "Added to <tt>%key%</tt> \'%title%\'",\n                    [\'%key%\' => $contentTypeName, \'%title%\' => $title[\'title\']]\n                );\n                $output .= "<br>\\n";\n            }\n        }\n        foreach ($results->get(\'warnings\') as $warnings) {\n            $output .= $warnings;\n        }\n        foreach ($results->get(\'errors\') as $errors) {\n            $output .= $errors;\n        }\n\n        return $output;\n    }\n\n    /**\n     * Writes a content-changelog entry for a newly-created entry.\n     *\n     * @param string  $contenttype Slug of the record contenttype\n     * @param integer $contentid   ID of the record\n     * @param array   $content     Record values\n     * @param string  $comment     Editor\'s comment\n     */\n    private function logInsert($contenttype, $contentid, $content, $comment = null)\n    {\n        $this->app[\'logger.change\']->info(\n            \'Insert record\',\n            [\n                \'action\'      => \'INSERT\',\n                \'contenttype\' => $contenttype,\n                \'id\'          => $contentid,\n                \'new\'         => $content,\n                \'old\'         => null,\n                \'comment\'     => $comment,\n            ]\n        );\n    }\n\n    /**\n     * Writes a content-changelog entry for an updated entry.\n     * This function must be called *before* the actual update, because it\n     * fetches the old content from the database.\n     *\n     * @param string  $contenttype Slug of the record contenttype\n     * @param integer $contentid   ID of the record\n     * @param array   $newContent  New record values\n     * @param array   $oldContent  Old record values\n     * @param string  $comment     Editor\'s comment\n     */\n    private function logUpdate($contenttype, $contentid, $newContent, $oldContent = null, $comment = null)\n    {\n        $this->app[\'logger.change\']->info(\n            \'Update record\',\n            [\n                \'action\'      => \'UPDATE\',\n                \'contenttype\' => $contenttype,\n                \'id\'          => $contentid,\n                \'new\'         => $newContent,\n                \'old\'         => $oldContent,\n                \'comment\'     => $comment,\n            ]\n        );\n    }\n\n    /**\n     * Writes a content-changelog entry for a deleted entry.\n     * This function must be called *before* the actual update, because it.\n     *\n     * @param string  $contenttype Slug of the record contenttype\n     * @param integer $contentid   ID of the record\n     * @param array   $content     Record values\n     * @param string  $comment     Editor\'s comment\n     */\n    private function logDelete($contenttype, $contentid, $content, $comment = null)\n    {\n        $this->app[\'logger.change\']->info(\n            \'Delete record\',\n            [\n                \'action\'      => \'DELETE\',\n                \'contenttype\' => $contenttype,\n                \'id\'          => $contentid,\n                \'new\'         => null,\n                \'old\'         => $content,\n                \'comment\'     => $comment,\n            ]\n        );\n    }\n\n    /**\n     * Save a record.\n     *\n     * @param Content $content\n     * @param string  $comment\n     *\n     * @throws \\Bolt\\Exception\\StorageException\n     *\n     * @return int\n     */\n    public function saveContent(Content $content, $comment = null)\n    {\n        $contenttype = $content->contenttype;\n        $fieldvalues = $content->values;\n\n        if (empty($contenttype)) {\n            $this->app[\'logger.system\']->error(\'Contenttype is required for \' . __FUNCTION__, [\'event\' => \'exception\']);\n            throw new StorageException(\'Contenttype is required for \' . __FUNCTION__);\n        }\n\n        // Test to see if this is a new record, or an update\n        if (empty($fieldvalues[\'id\'])) {\n            $create = true;\n        } else {\n            $create = false;\n        }\n\n        // We need to verify if the slug is unique. If not, we update it.\n        $getId = $create ? null : $fieldvalues[\'id\'];\n        $fieldvalues[\'slug\'] = $this->getUri($fieldvalues[\'slug\'], $getId, $contenttype[\'slug\'], false, false);\n\n        // Update the content object\n        $content->setValues($fieldvalues);\n\n        // Dispatch pre-save event\n        if (! $this->inDispatcher && $this->app[\'dispatcher\']->hasListeners(StorageEvents::PRE_SAVE)) {\n            $event = new StorageEvent($content, [\'contenttype\' => $contenttype, \'create\' => $create]);\n            $this->app[\'dispatcher\']->dispatch(StorageEvents::PRE_SAVE, $event);\n        }\n\n        // Decide whether to insert a new record, or update an existing one.\n        if ($create) {\n            $this->insertContent($content, $comment);\n        } else {\n            $this->updateContent($content, $comment);\n        }\n\n        // Update taxonomy and record relationships\n        $this->updateTaxonomy($contenttype, $content->values[\'id\'], $content->taxonomy);\n        $this->updateRelation($contenttype, $content->values[\'id\'], $content->relation);\n\n        // Dispatch post-save event\n        if (!$this->inDispatcher && $this->app[\'dispatcher\']->hasListeners(StorageEvents::POST_SAVE)) {\n            // Block loops\n            $this->inDispatcher = true;\n\n            $event = new StorageEvent($content, [\'contenttype\' => $contenttype, \'create\' => $create]);\n            $this->app[\'dispatcher\']->dispatch(StorageEvents::POST_SAVE, $event);\n\n            // Re-enable the dispatcher\n            $this->inDispatcher = false;\n        }\n\n        return $content->values[\'id\'];\n    }\n\n    /**\n     * Delete a record.\n     *\n     * @param string  $contenttype\n     * @param integer $id\n     *\n     * @throws \\Bolt\\Exception\\StorageException\n     *\n     * @return integer The number of affected rows.\n     */\n    public function deleteContent($contenttype, $id)\n    {\n        if (empty($contenttype)) {\n            $this->app[\'logger.system\']->error(\'Contenttype is required for\' . __FUNCTION__, [\'event\' => \'exception\']);\n            throw new StorageException(\'Contenttype is required for \' . __FUNCTION__);\n        }\n\n        // Make sure $contenttype is a \'slug\'\n        if (is_array($contenttype)) {\n            $contenttype = $contenttype[\'slug\'];\n        }\n\n        // Get the old content record\n        $tablename = $this->getContenttypeTablename($contenttype);\n        $oldContent = $this->findContent($tablename, $id);\n\n        // Dispatch pre-delete event\n        if ($this->app[\'dispatcher\']->hasListeners(StorageEvents::PRE_DELETE)) {\n            $event = new StorageEvent($oldContent, [\'contenttype\' => $contenttype]);\n            $this->app[\'dispatcher\']->dispatch(StorageEvents::PRE_DELETE, $event);\n        }\n\n        $this->logDelete($contenttype, $id, $oldContent);\n\n        $res = $this->app[\'db\']->delete($tablename, [\'id\' => $id]);\n\n        // Make sure relations and taxonomies are deleted as well.\n        if ($res) {\n            $this->app[\'db\']->delete($this->getTablename(\'relations\'), [\'from_contenttype\' => $contenttype, \'from_id\' => $id]);\n            $this->app[\'db\']->delete($this->getTablename(\'relations\'), [\'to_contenttype\' => $contenttype, \'to_id\' => $id]);\n            $this->app[\'db\']->delete($this->getTablename(\'taxonomy\'), [\'contenttype\' => $contenttype, \'content_id\' => $id]);\n        }\n\n        // Dispatch post-delete event\n        if ($this->app[\'dispatcher\']->hasListeners(StorageEvents::POST_DELETE)) {\n            $event = new StorageEvent($oldContent, [\'contenttype\' => $contenttype]);\n            $this->app[\'dispatcher\']->dispatch(StorageEvents::POST_DELETE, $event);\n        }\n\n        return $res;\n    }\n\n    /**\n     * Insert a new contenttype record in the database.\n     *\n     * @param \\Bolt\\Legacy\\Content $content Record content to insert\n     * @param string               $comment Editor\'s comment\n     *\n     * @return boolean\n     */\n    protected function insertContent(Content $content, $comment = null)\n    {\n        $tablename = $this->getContenttypeTablename($content->contenttype);\n\n        // Set creation and update dates\n        $content->setValue(\'datecreated\', date(\'Y-m-d H:i:s\'));\n        $content->setValue(\'datechanged\', date(\'Y-m-d H:i:s\'));\n\n        // id is set to autoincrement, so let the DB handle it\n        unset($content->values[\'id\']);\n\n        // Get the JSON database prepared values and make sure it\'s valid\n        $fieldvalues = $this->getValidSaveData($content->getValues(true), $content->contenttype);\n\n        $this->app[\'db\']->insert($tablename, $fieldvalues);\n\n        $seq = null;\n        if ($this->app[\'db\']->getDatabasePlatform() instanceof PostgreSqlPlatform) {\n            $seq = $tablename . \'_id_seq\';\n        }\n\n        $id = $this->app[\'db\']->lastInsertId($seq);\n\n        if ($id > 0) {\n            $content->setValue(\'id\', $id);\n            $this->logInsert($content->contenttype[\'slug\'], $id, $fieldvalues, $comment);\n\n            return true;\n        }\n    }\n\n    /**\n     * Update a Bolt contenttype record.\n     *\n     * @param \\Bolt\\Legacy\\Content $content The content object to be updated\n     * @param string               $comment Add a comment to save with change.\n     *\n     * @throws \\Bolt\\Exception\\StorageException\n     *\n     * @return bool\n     */\n    private function updateContent(Content $content, $comment = null)\n    {\n        $tablename = $this->getContenttypeTablename($content->contenttype);\n\n        // Set the date the record was changed\n        $content->setValue(\'datechanged\', date(\'Y-m-d H:i:s\'));\n\n        // Test that the record exists in the database\n        $oldContent = $this->findContent($tablename, $content[\'id\']);\n        if (empty($oldContent)) {\n            throw new StorageException(\'Attempted to update a non-existent record\');\n        }\n\n        // Get the JSON database prepared values and make sure it\'s valid\n        $fieldvalues = $this->getValidSaveData($content->getValues(true), $content->contenttype);\n\n        // Do the actual update, and log it.\n        $res = $this->app[\'db\']->update($tablename, $fieldvalues, [\'id\' => $content[\'id\']]);\n        if ($res > 0) {\n            $this->logUpdate($content->contenttype[\'slug\'], $content[\'id\'], $fieldvalues, $oldContent, $comment);\n\n            return true;\n        }\n    }\n\n    /**\n     * Get a valid array to commit.\n     *\n     * @param array $fieldvalues\n     * @param array $contenttype\n     *\n     * @return array\n     */\n    private function getValidSaveData(array $fieldvalues, array $contenttype)\n    {\n        // Clean up fields, check unneeded columns.\n        foreach (array_keys($fieldvalues) as $key) {\n            if ($this->isValidColumn($key, $contenttype)) {\n                if (is_string($fieldvalues[$key])) {\n                    // Trim strings\n                    $fieldvalues[$key] = trim($fieldvalues[$key]);\n                } elseif (is_bool($fieldvalues[$key])) {\n                    // Convert literal booleans to 0/1 to ensure cross-db consistency\n                    $fieldvalues[$key] = (int) $fieldvalues[$key];\n                }\n            } else {\n                // unset columns we don\'t need to store.\n                unset($fieldvalues[$key]);\n            }\n        }\n\n        return $fieldvalues;\n    }\n\n    /**\n     * Update a single value from content.\n     *\n     * It is called in list of contents.\n     *\n     * @param string $contenttype Content Type to be edited.\n     * @param int    $id          Id of content to be updated.\n     * @param string $field       Field name of content to be changed.\n     * @param mixed  $value       New value to be defined on field.\n     *\n     * @return bool Returns true when update is done or false if not.\n     */\n    public function updateSingleValue($contenttype, $id, $field, $value)\n    {\n        $id = intval($id);\n\n        if (!$this->isValidColumn($field, $contenttype)) {\n            $error = Trans::__(\'contenttypes.generic.invalid-field\', [\'%field%\' => $field, \'%contenttype%\' => $contenttype]);\n            $this->app[\'logger.flash\']->danger($error);\n\n            return false;\n        }\n\n        $content = $this->getContent("$contenttype/$id");\n\n        $content->setValue($field, $value);\n\n        $comment = Trans::__(\n            \'The field %field% has been changed to "%newValue%"\',\n            [\n                \'%field%\'    => $field,\n                \'%newValue%\' => $value,\n            ]\n        );\n\n        $result = $this->saveContent($content, $comment);\n\n        return $result;\n    }\n\n    public function getEmptyContent($contenttypeslug)\n    {\n        $content = $this->getContentObject($contenttypeslug);\n\n        // don\'t use \'undefined contenttype\' as title/name\n        $content->setValues([\'name\' => \'\', \'title\' => \'\']);\n\n        return $content;\n    }\n\n    /**\n     * Decode search query into searchable parts.\n     *\n     * @param string $q\n     *\n     * @return array\n     */\n    private function decodeSearchQuery($q)\n    {\n        $words = preg_split(\'|[\\r\\n\\t ]+|\', trim($q));\n\n        $words = array_map(\n            function ($word) {\n                return mb_strtolower($word);\n            },\n            $words\n        );\n        $words = array_filter(\n            $words,\n            function ($word) {\n                return strlen($word) >= 2;\n            }\n        );\n\n        return [\n            \'valid\' => count($words) > 0,\n            \'in_q\'  => $q,\n            \'use_q\' => implode(\' \', $words),\n            \'sanitized_q\' => strip_tags($q),\n            \'words\' => $words,\n        ];\n    }\n\n    /**\n     * Search through a single contenttype.\n     *\n     * Search, weigh and return the results.\n     *\n     * @param       $query\n     * @param       $contenttype\n     * @param       $fields\n     * @param array $filter\n     * @param bool  $implode\n     *\n     * @return Content\n     */\n    private function searchSingleContentType($query, $contenttype, $fields, array $filter = null, $implode = false)\n    {\n        // This could be even more configurable\n        // (see also Content->getFieldWeights)\n        $searchableTypes = [\'text\', \'textarea\', \'html\', \'markdown\'];\n        $table = $this->getContenttypeTablename($contenttype);\n\n        if ($implode) {\n            $query[\'words\'] = [ implode(\' \', $query[\'words\']) ];\n        }\n\n        // Build fields \'WHERE\'\n        $fieldsWhere = [];\n        foreach ($fields as $field => $fieldconfig) {\n            if (in_array($fieldconfig[\'type\'], $searchableTypes)) {\n                foreach ($query[\'words\'] as $word) {\n                    // Build the LIKE, lowering the searched field to cover case-sensitive database systems\n                    $fieldsWhere[] = sprintf(\'LOWER(%s.%s) LIKE LOWER(%s)\', $table, $field, $this->app[\'db\']->quote(\'%\' . $word . \'%\'));\n                }\n            }\n        }\n\n        // make taxonomies work\n        $taxonomytable = $this->getTablename(\'taxonomy\');\n        $taxonomies    = $this->getContentTypeTaxonomy($contenttype);\n        $tagsWhere     = [];\n        $tagsQuery     = \'\';\n        foreach ($taxonomies as $taxonomy) {\n            if ($taxonomy[\'behaves_like\'] == \'tags\') {\n                foreach ($query[\'words\'] as $word) {\n                    $tagsWhere[] = sprintf(\'%s.slug LIKE %s\', $taxonomytable, $this->app[\'db\']->quote(\'%\' . $word . \'%\'));\n                }\n            }\n        }\n        // only add taxonomies if they exist\n        if (!empty($taxonomies) && !empty($tagsWhere)) {\n            $tagsQueryA = sprintf("%s.contenttype = \'%s\'", $taxonomytable, $contenttype);\n            $tagsQueryB = implode(\' OR \', $tagsWhere);\n            $tagsQuery   = sprintf(\' OR (%s AND (%s))\', $tagsQueryA, $tagsQueryB);\n        }\n\n        // Build filter \'WHERE"\n        // @todo make relations work as well\n        $filterWhere = [];\n        if (!is_null($filter)) {\n            foreach ($fields as $field => $fieldconfig) {\n                if (isset($filter[$field])) {\n                    $filterWhere[] = $this->parseWhereParameter($table . \'.\' . $field, $filter[$field]);\n                }\n            }\n        }\n\n        // Build actual where\n        $where = [];\n        $where[] = sprintf("%s.status = \'published\'", $table);\n        $where[] = \'(( \' . implode(\' OR \', $fieldsWhere) . \' ) \' . $tagsQuery . \' )\';\n        $where = array_merge($where, $filterWhere);\n\n        // Build SQL query\n        $select = sprintf(\n            \'SELECT %s.id FROM %s LEFT JOIN %s ON %s.id = %s.content_id WHERE %s GROUP BY %s.id\',\n            $table,\n            $table,\n            $taxonomytable,\n            $table,\n            $taxonomytable,\n            implode(\' AND \', $where),\n            $table\n        );\n\n        // Run Query\n        $results = $this->app[\'db\']->fetchAll($select);\n\n        if (!empty($results)) {\n            $ids = implode(\' || \', Arr::column($results, \'id\'));\n\n            $results = $this->getContent($contenttype, [\'id\' => $ids, \'returnsingle\' => false]);\n\n            // Convert and weight\n            foreach ($results as $result) {\n                $result->weighSearchResult($query);\n            }\n        }\n\n        return $results;\n    }\n\n    /**\n     * Compare by search weights.\n     *\n     * Or fallback to dates or title\n     *\n     * @param \\Bolt\\Legacy\\Content $a\n     * @param \\Bolt\\Legacy\\Content $b\n     *\n     * @return int\n     */\n    private function compareSearchWeights(Content $a, Content $b)\n    {\n        if ($a->getSearchResultWeight() > $b->getSearchResultWeight()) {\n            return -1;\n        }\n        if ($a->getSearchResultWeight() < $b->getSearchResultWeight()) {\n            return 1;\n        }\n        if ($a[\'datepublish\'] > $b[\'datepublish\']) {\n            // later is more important\n            return -1;\n        }\n        if ($a[\'datepublish\'] < $b[\'datepublish\']) {\n            // earlier is less important\n            return 1;\n        }\n\n        return strcasecmp($a[\'title\'], $b[\'title\']);\n    }\n\n    /**\n     * Search through actual content.\n     *\n     * Unless the query is invalid it will always return a \'result array\'. It may\n     * complain in the log but it won\'t abort.\n     *\n     * @param string  $q            Search string\n     * @param array   $contenttypes Contenttype names to search for:\n     *                              - string: Specific contenttype\n     *                              - null:   Every searchable contenttype\n     * @param array   $filters      Additional filters for contenttypes\n     *                              - key is contenttype\n     *                              - value is filter\n     * @param integer $limit        limit the number of results\n     * @param integer $offset       skip this number of results\n     *\n     * @return mixed false if query is invalid, an array with results if query was executed\n     */\n    public function searchContent($q, array $contenttypes = null, array $filters = null, $limit = 9999, $offset = 0)\n    {\n        $query = $this->decodeSearchQuery($q);\n        if (!$query[\'valid\']) {\n            return false;\n        }\n\n        $appCt = $this->app[\'config\']->get(\'contenttypes\');\n\n        // By default we only search through searchable contenttypes\n        if (is_null($contenttypes)) {\n            $contenttypes = array_keys($appCt);\n            $contenttypes = array_filter(\n                $contenttypes,\n                function ($ct) use ($appCt) {\n                    if ((isset($appCt[$ct][\'searchable\']) && $appCt[$ct][\'searchable\'] === false) ||\n                        (isset($appCt[$ct][\'viewless\']) && $appCt[$ct][\'viewless\'] === true)\n                    ) {\n                        return false;\n                    }\n\n                    return true;\n                }\n            );\n            $contenttypes = array_map(\n                function ($ct) use ($appCt) {\n                    return $appCt[$ct][\'slug\'];\n                },\n                $contenttypes\n            );\n        }\n\n        // Build our search results array\n        $results = [];\n\n        // First, attempt to search for the literal string, eg. "Lorum Ipsum"\n        foreach ($contenttypes as $contenttype) {\n            $ctconfig = $this->getContentType($contenttype);\n\n            $fields = $ctconfig[\'fields\'];\n            $filter = null;\n\n            if (is_array($filters) && isset($filters[$contenttype])) {\n                $filter = $filters[$contenttype];\n            }\n\n            $subResults = $this->searchSingleContentType($query, $contenttype, $fields, $filter, true);\n\n            $results = array_merge($results, $subResults);\n        }\n\n        // If that didn\'t produce results, search for "Lorum" or "Ipsum"\n        if (empty($results)) {\n            foreach ($contenttypes as $contenttype) {\n                $ctconfig = $this->getContentType($contenttype);\n\n                $fields = $ctconfig[\'fields\'];\n                $filter = null;\n\n                if (is_array($filters) && isset($filters[$contenttype])) {\n                    $filter = $filters[$contenttype];\n                }\n\n                $subResults = $this->searchSingleContentType($query, $contenttype, $fields, $filter, false);\n\n                $results = array_merge($results, $subResults);\n            }\n        }\n\n        // Sort the results\n        usort($results, [$this, \'compareSearchWeights\']);\n\n        $noOfResults = count($results);\n\n        $pageResults = [];\n        if ($offset < $noOfResults) {\n            $pageResults = array_slice($results, $offset, $limit);\n        }\n\n        return [\n            \'query\'         => $query,\n            \'no_of_results\' => $noOfResults,\n            \'results\'       => $pageResults,\n        ];\n    }\n\n    public function searchAllContentTypes(array $parameters = [], &$pager = [])\n    {\n        // Note: we can only apply this kind of results aggregating when we don\'t\n        // use LIMIT and OFFSET! If we\'d want to use it, this should be rewritten.\n        // Results aggregator\n        $result = [];\n\n        foreach ($this->getContentTypes() as $contenttype) {\n            $contentTypeSearchResults = $this->searchContentType($contenttype, $parameters, $pager);\n            foreach ($contentTypeSearchResults as $searchresult) {\n                $result[] = $searchresult;\n            }\n        }\n\n        return $result;\n    }\n\n    public function searchContentType($contenttypename, array $parameters = [], &$pager = [])\n    {\n        $where = [];\n        $tablename = $this->getContenttypeTablename($contenttypename);\n\n        $contenttype = $this->app[\'config\']->get(\'contenttypes/\' . $contenttypename);\n\n        // If this contenttype has \'searchable: false\', we skip it.\n        if (isset($contenttype[\'searchable\']) && $contenttype[\'searchable\'] === false) {\n            return [];\n        }\n\n        // for all the non-reserved parameters that are fields, we assume people want to do a \'where\'\n        foreach ($parameters as $key => $value) {\n            if (in_array($key, [\'order\', \'where\', \'limit\', \'offset\'])) {\n                continue; // Skip this one.\n            }\n            if (!$this->isValidColumn($key, $contenttype)) {\n                continue; // Also skip if \'key\' isn\'t a field in the contenttype.\n            }\n\n            $where[] = $this->parseWhereParameter($key, $value);\n        }\n\n        // @todo update with nice search string\n        // If we need to filter, add the WHERE for that.\n        // Meh, InnoDB doesn\'t support full text search.\n        if (!empty($parameters[\'filter\'])) {\n            $filter = $this->app[\'db\']->quote($parameters[\'filter\']);\n\n            $filterWhere = [];\n\n            foreach ($contenttype[\'fields\'] as $key => $value) {\n                if (in_array($value[\'type\'], [\'text\', \'textarea\', \'html\', \'markdown\'])) {\n                    $filterWhere[] = sprintf("%s LIKE \'%%%s%%\'", $key, $filter);\n                }\n            }\n\n            if (!empty($filterWhere)) {\n                $where[] = \'(\' . implode(\' OR \', $filterWhere) . \')\';\n            }\n        }\n\n        $limit = isset($parameters[\'limit\']) && $parameters[\'limit\'] ? $parameters[\'limit\'] : 9999;\n        $page = isset($parameters[\'page\']) && $parameters[\'page\'] ? $parameters[\'page\'] : 1;\n\n        // If we\'re allowed to use pagination, use the \'page\' parameter.\n        if (!empty($parameters[\'paging\']) && $this->app[\'request_stack\']->getCurrentRequest() !== null) {\n            $page = $this->app[\'pager\']->getPager();\n        }\n\n        $queryparams = "";\n\n        // implode \'where\'\n        if (!empty($where)) {\n            $queryparams .= sprintf(\' WHERE (%s)\', implode(" AND ", $where));\n        }\n\n        // Order, with a special case for \'RANDOM\'.\n        if (!empty($parameters[\'order\'])) {\n            if ($parameters[\'order\'] == "RANDOM") {\n                $dboptions = $this->app[\'db\']->getParams();\n                $queryparams .= sprintf(\' ORDER BY %s\', $dboptions[\'randomfunction\']);\n            } else {\n                $order = $this->getEscapedSortorder($parameters[\'order\'], false);\n                if (!empty($order)) {\n                    $queryparams .= sprintf(\' ORDER BY %s\', $order);\n                }\n            }\n        }\n\n        // Make the query for the pager.\n        $pagerquery = sprintf(\'SELECT COUNT(*) AS count FROM %s %s\', $tablename, $queryparams);\n\n        // Add the limit\n        $queryparams = $this->app[\'db\']->getDatabasePlatform()->modifyLimitQuery($queryparams, $limit, ($page - 1) * $limit);\n\n        // Make the query to get the results.\n        $query = "SELECT * FROM $tablename" . $queryparams;\n\n        $rows = $this->app[\'db\']->fetchAll($query);\n\n        // Make sure content is set, and all content has information about its contenttype\n        $content = [];\n        foreach ($rows as $row) {\n            $content[$row[\'id\']] = $this->getContentObject($contenttype, $row);\n        }\n\n        // TODO: Check if we need to hydrate here!\n\n        // Make sure all content has their taxonomies and relations\n        $this->getTaxonomy($content);\n        $this->getRelation($content);\n        $this->getRepeaters($content);\n\n        // Set up the $pager array with relevant values.\n        $rowcount = $this->app[\'db\']->executeQuery($pagerquery)->fetch();\n\n        /** @var \\Bolt\\Pager\\PagerManager $manager */\n        $manager = $this->app[\'pager\'];\n        $manager->createPager(\'search\')\n            ->setCount($rowcount[\'count\'])\n            ->setTotalpages(ceil($rowcount[\'count\'] / $limit))\n            ->setCurrent($page)\n            ->setShowingFrom(($page - 1) * $limit + 1)\n            ->setShowingTo(($page - 1) * $limit + count($content));\n\n        return $content;\n    }\n\n    /**\n     * Retrieve content from the database, filtered on taxonomy.\n     *\n     * Note: we can NOT sort on anything meaningful. Records are fetched from multiple\n        ];\n    }\n\n    public function searchAllContentTypes(array $parameters = [], &$pager = [])\n    {\n        // Note: we can only apply this kind of results aggregating when we don\'t\n    {\n        $tablename = $this->getTablename(\'taxonomy\');\n\n        $slug = $this->app[\'slugify\']->slugify($name);\n\n        $limit = isset($parameters[\'limit\']) && $parameters[\'limit\'] ? $parameters[\'limit\'] : 9999;\n        $page = isset($parameters[\'page\']) && $parameters[\'page\'] ? $parameters[\'page\'] : 1;\n\n        $taxonomytype = $this->getTaxonomyType($taxonomyslug);\n\n        // No taxonomytype, no possible content.\n        return $result;\n    }\n\n    public function searchContentType($contenttypename, array $parameters = [], &$pager = [])\n    {\n        $where = [];\n        $orderby = (array_key_exists(\'has_sortorder\', $taxonomytype)\n            && $taxonomytype[\'has_sortorder\'] === true) ? \'sortorder\' : \'id\';\n\n        // Sort on either \'ascending\' or \'descending\'\n        // Make sure we set the order.\n        $order = \'ASC\';\n        $taxonomysort = strtoupper($this->app[\'config\']->get(\'theme/taxonomy_sort\') ?: $this->app[\'config\']->get(\'general/taxonomy_sort\'));\n        if ($taxonomysort == \'DESC\') {\n            $order = \'DESC\';\n        }\n\n        // Add the limit\n        $query = sprintf(\'SELECT * FROM %s %s ORDER BY %s %s\', $tablename, $where, $orderby, $order);\n        $query = $this->app[\'db\']->getDatabasePlatform()->modifyLimitQuery($query, $limit, ($page - 1) * $limit);\n\n        $taxorows = $this->app[\'db\']->fetchAll($query);\n\n        if (!empty($parameters[\'printquery\'])) {\n            // @todo formalize this\n            echo nl2br(htmlentities($query));\n        }\n\n        $content = [];\n\n        if (is_array($taxorows)) {\n            foreach ($taxorows as $row) {\n                $record = $this->getContent($row[\'contenttype\'] . \'/\' . $row[\'content_id\']);\n                if ($record instanceof Content && !empty($record->id)) {\n                    $content[] = $record;\n                }\n            }\n        }\n\n        // Set up the $pager array with relevant values.\n        $rowcount = $this->app[\'db\']->executeQuery($pagerquery)->fetch();\n\n        $pagefor = $taxonomytype[\'singular_slug\'].\'_\'.$slug;\n\n        /** @var \\Bolt\\Pager\\PagerManager $manager */\n        $manager = $this->app[\'pager\'];\n        $manager->createPager($pagefor)\n            ->setCount($rowcount[\'count\'])\n            ->setTotalpages(ceil($rowcount[\'count\'] / $limit))\n            ->setCurrent($page)\n            ->setShowingFrom(($page - 1) * $limit + 1)\n            ->setShowingTo(($page - 1) * $limit + count($taxorows));\n\n        return $content;\n    }\n\n    /**\n     * @deprecated Deprecated since 3.0, to be removed in 4.0.\n     * @see        \\Bolt\\Storage\\EventProcessor\\TimedRecord::publishTimedRecords()\n     */\n    public function publishTimedRecords()\n    {\n    }\n\n    /**\n     * @deprecated Deprecated since 3.0, to be removed in 4.0.\n     * @see        \\Bolt\\Storage\\EventProcessor\\TimedRecord::holdExpiredRecords()\n     */\n    public function depublishExpiredRecords()\n    {\n    }\n\n    /**\n     * Split into meta-parameters and contenttype parameters.\n     *\n     * This is tightly coupled to $this->getContent()\n     *\n     * @param array|string|null $inParameters\n     *\n     * @see $this->decodeContentQuery()\n     *\n     * @return array\n     */\n    private function organizeQueryParameters($inParameters = null)\n    {\n        $ctypeParameters = [];\n        $metaParameters = [];\n        if (is_array($inParameters)) {\n            foreach ($inParameters as $key => $value) {\n                if (in_array($key, [\'page\', \'limit\', \'offset\', \'returnsingle\', \'printquery\', \'paging\', \'order\'])) {\n                    $metaParameters[$key] = $value;\n                } else {\n                    $ctypeParameters[$key] = $value;\n                }\n            }\n        }\n\n        // Set up the $pager array with relevant values.\n        $rowcount = $this->app[\'db\']->executeQuery($pagerquery)->fetch();\n        $pager = [\n            \'for\'          => \'search\',\n            \'count\'        => $rowcount[\'count\'],\n            \'totalpages\'   => ceil($rowcount[\'count\'] / $limit),\n     * (entry,page) -> [\'entry\', \'page\']\n     * event -> [\'event\']\n     *\n     * @param string $text text with contenttypes\n     *\n     * @return array array with contenttype(slug)s\n     */\n    private function decodeContentTypesFromText($text)\n    {\n        $contenttypes = [];\n\n        if ((substr($text, 0, 1) == \'(\') &&\n            (substr($text, -1) == \')\')\n        ) {\n            $contenttypes = explode(\',\', substr($text, 1, -1));\n        } else {\n            $contenttypes[] = $text;\n        }\n\n        $instance = $this;\n        $contenttypes = array_map(\n            function ($name) use ($instance) {\n                $ct = $instance->getContentType($name);\n\n                return $ct[\'slug\'] ?? null;\n            },\n            $contenttypes\n        );\n\n        return $contenttypes;\n    }\n\n    /**\n     * Parse textquery into useable arguments.\n     *\n     * This is tightly coupled to $this->getContent()\n     *\n     * @see $this->decodeContentQuery()\n     *\n     * @param $textquery\n     * @param array $decoded         a pre-set decoded array to fill\n     * @param array $metaParameters  meta parameters\n     * @param array $ctypeParameters contenttype parameters\n     */\n    private function parseTextQuery($textquery, array &$decoded, array &$metaParameters, array &$ctypeParameters)\n    {\n        // Our default callback\n        $decoded[\'queries_callback\'] = [$this, \'executeGetContentQueries\'];\n\n        // Some special cases, like \'entry/1\' or \'page/about\' need to be caught before further processing.\n        if (preg_match(\'#^/?([a-z0-9_-]+)/([0-9]+)$#i\', $textquery, $match)) {\n            // like \'entry/12\' or \'/page/12345\'\n            $decoded[\'contenttypes\'] = $this->decodeContentTypesFromText($match[1]);\n            $decoded[\'return_single\'] = true;\n            // if allow_numeric_slug option is set on contenttype, interpret number as slug instead of id\n            $contenttype = $this->getContentType($decoded[\'contenttypes\'][0]);\n            $field = !empty($contenttype) ? ($contenttype[\'allow_numeric_slugs\'] === true ? \'slug\' : \'id\') : \'id\';\n            $ctypeParameters[$field] = $match[2];\n        } elseif (preg_match(\'#^/?([a-z0-9_(\\),-]+)/search(/([0-9]+))?$#i\', $textquery, $match)) {\n            // like \'page/search or \'(entry,page)/search\'\n            $decoded[\'contenttypes\'] = $this->decodeContentTypesFromText($match[1]);\n            $metaParameters[\'order\'] = [$this, \'compareSearchWeights\'];\n            if (count($match) >= 3) {\n                $metaParameters[\'limit\'] = $match[3];\n            }\n\n            $decoded[\'queries_callback\'] = [$this, \'executeGetContentSearch\'];\n        } elseif (preg_match(\'#^/?([a-z0-9_-]+)/([a-z0-9_-]+)$#i\', $textquery, $match)) {\n            // like \'page/lorem-ipsum-dolor\' or \'/page/home\'\n            $decoded[\'contenttypes\'] = $this->decodeContentTypesFromText($match[1]);\n            $decoded[\'return_single\'] = true;\n            $ctypeParameters[\'slug\'] = $match[2];\n        } elseif (preg_match(\'#^/?([a-z0-9_-]+)/(latest|first)/([0-9]+)$#i\', $textquery, $match)) {\n            // like \'page/latest/5\'\n            $decoded[\'contenttypes\'] = $this->decodeContentTypesFromText($match[1]);\n            if (!isset($metaParameters[\'order\']) || $metaParameters[\'order\'] === false) {\n                $metaParameters[\'order\'] = \'datepublish \' . ($match[2] == \'latest\' ? \'DESC\' : \'ASC\');\n            }\n            if (!isset($metaParameters[\'limit\'])) {\n                $metaParameters[\'limit\'] = $match[3];\n            }\n\n        // Set up the $pager array with relevant values.\n        $rowcount = $this->app[\'db\']->executeQuery($pagerquery)->fetch();\n        $pager = [\n            \'for\'          => $taxonomytype[\'singular_slug\'] . \'_\' . $slug,\n            \'count\'        => $rowcount[\'count\'],\n            \'totalpages\'   => ceil($rowcount[\'count\'] / $limit),\n            \'current\'      => $page,\n\n            \'showing_to\'   => ($page - 1) * $limit + count($taxorows),\n        ];\n\n        $this->app[\'storage\']->setPager($taxonomytype[\'singular_slug\'] . \'_\' . $slug, $pager);\n\n        return $content;\n    }\n\n        // unless something else is specified explicitly\n        $request = $this->app[\'request_stack\']->getCurrentRequest();\n        $isFrontend = $request ? Zone::isFrontend($request) : true;\n        if ($isFrontend && empty($ctypeParameters[\'status\'])) {\n            $ctypeParameters[\'status\'] = \'published\';\n        }\n\n        if (isset($metaParameters[\'returnsingle\'])) {\n            $decoded[\'return_single\'] = $metaParameters[\'returnsingle\'];\n            unset($metaParameters[\'returnsingle\']);\n        }\n    }\n\n    /**\n     * Prepare decoded for actual use.\n     *\n     * This is tightly coupled to $this->getContent()\n     *\n     * @see $this->decodeContentQuery()\n     *\n     * @param $decoded\n     * @param $metaParameters\n     * @param $ctypeParameters\n     */\n    private function prepareDecodedQueryForUse(&$decoded, &$metaParameters, &$ctypeParameters)\n    {\n        // If there is only 1 contenttype we assume the where is NOT nested\n        if (count($decoded[\'contenttypes\']) == 1) {\n            // So we need to add this nesting\n            $ctypeParameters = [$decoded[\'contenttypes\'][0] => $ctypeParameters];\n        } else {\n            // We need to set every non-contenttypeslug parameters to each individual contenttypes\n            $globalParameters = [];\n            foreach ($ctypeParameters as $key => $parameter) {\n                if (!in_array($key, $decoded[\'contenttypes\'])) {\n                    $globalParameters[$key] = $parameter;\n                }\n            }\n            foreach ($globalParameters as $key => $parameter) {\n                unset($ctypeParameters[$key]);\n                foreach ($decoded[\'contenttypes\'] as $contenttype) {\n                    if (!isset($ctypeParameters[$contenttype])) {\n                        $ctypeParameters[$contenttype] = [];\n                    }\n                    if (!isset($ctypeParameters[$contenttype][$key])) {\n                        $ctypeParameters[$contenttype][$key] = $parameter;\n                    }\n                }\n            }\n\n            // In this case query pagination never makes sense!\n            $decoded[\'self_paginated\'] = false;\n        }\n\n        if (($decoded[\'order_callback\'] !== false) || ($decoded[\'return_single\'] === true)) {\n            // Callback sorting disables pagination\n            $decoded[\'self_paginated\'] = false;\n        }\n\n        if (!isset($metaParameters[\'order\']) || $metaParameters[\'order\'] === false) {\n            if (count($decoded[\'contenttypes\']) == 1) {\n                if ($this->getContentTypeGrouping($decoded[\'contenttypes\'][0])) {\n                    $decoded[\'order_callback\'] = [$this, \'groupingSort\'];\n                }\n            }\n        }\n\n        if ($decoded[\'return_single\']) {\n            $metaParameters[\'limit\'] = 1;\n        } elseif (!isset($metaParameters[\'limit\'])) {\n            $metaParameters[\'limit\'] = 9999;\n        }\n    }\n\n    /**\n     * Get the parameter for the \'order by\' part of a query.\n     *\n     * This is tightly coupled to $this->getContent()\n     *\n     * @param array  $contenttype\n     * @param string $orderValue\n     *\n     * @return string\n     */\n    private function decodeQueryOrder($contenttype, $orderValue)\n    {\n        $order = false;\n\n        if (($orderValue === false) || ($orderValue === \'\')) {\n            if ($this->isValidColumn($contenttype[\'sort\'], $contenttype, true)) {\n                $order = $this->getEscapedSortorder($contenttype[\'sort\'], false);\n            }\n        } else {\n            $parOrder = Str::makeSafe($orderValue);\n            if ($parOrder == \'RANDOM\') {\n                $dboptions = $this->app[\'db\']->getParams();\n                $order = $dboptions[\'randomfunction\'];\n            } elseif ($this->isValidColumn($parOrder, $contenttype, true)) {\n                $order = $this->getEscapedSortorder($parOrder, false);\n            }\n        }\n\n        return $order;\n    }\n\n    /**\n     * Decode a content textquery\n     *\n     * This is tightly coupled to $this->getContent()\n     *\n     * @param string $textquery\n     * @param array  $inParameters\n     *\n     * @internal param string $query the query (eg. page/about, entries/latest/5)\n     * @internal param array  $parameters parameters to the query\n     *\n     * @return array decoded query, keys:\n     *               contenttypes   - array, contenttypeslugs that will be returned\n     *               return_single  - boolean, true if only 1 result should be returned\n     *               self_paginated - boolean, true if already be paginated\n     *               order_callback - callback, sort results post-hydration after everything is merged\n     *               queries        - array of SQL query parts:\n     *               * tablename   - tablename\n     *               * contenttype - contenttype array\n     *               * from        - from part\n     *               * where       - where part\n     *               * order       - order part\n     *               * params      - bind-parameters\n     *               parameters     - parameters to use after the queries\n     */\n    private function decodeContentQuery($textquery, $inParameters = null)\n    {\n        $decoded = [\n            \'contenttypes\'   => [],\n            \'return_single\'  => false,\n            \'self_paginated\' => true,\n            \'order_callback\' => false,\n            \'queries\'        => [],\n            \'parameters\'     => [],\n            \'hydrate\'        => true,\n        ];\n\n        list($metaParameters, $ctypeParameters) = $this->organizeQueryParameters($inParameters);\n\n        $this->parseTextQuery($textquery, $decoded, $metaParameters, $ctypeParameters);\n\n        // $decoded[\'contettypes\'] gotten here\n        // get page nr. from url if has\n\n        $metaParameters[\'page\'] = $this->app[\'pager\']->getCurrentPage(implode(\'_\', $decoded[\'contenttypes\']));\n\n        $this->prepareDecodedQueryForUse($decoded, $metaParameters, $ctypeParameters);\n\n        $decoded[\'parameters\'] = $metaParameters;\n\n        // for all the non-reserved parameters that are fields or taxonomies, we assume people want to do a \'where\'\n        foreach ($ctypeParameters as $contenttypeslug => $actualParameters) {\n            $contenttype = $this->getContentType($contenttypeslug);\n            $tablename = $this->getContenttypeTablename($contenttype);\n            $where = [];\n            $order = [];\n\n            // Set the \'order\', if specified in the meta_parameters.\n            if (!empty($metaParameters[\'order\'])) {\n                $order[] = $this->getEscapedSortorder($metaParameters[\'order\'], false);\n            }\n\n            $query = [\n                \'tablename\'   => $tablename,\n                \'contenttype\' => $contenttype,\n                \'from\'        => sprintf(\'FROM %s\', $tablename),\n                \'where\'       => \'\',\n                \'order\'       => \'\',\n                \'params\'      => [],\n            ];\n\n            if ($contenttype === false) {\n                /*\n                 * We were logging here, but a couple of places like\n                 * TwigExtension::menuHelper() would trigger lots of hits,\n                 * filling logs and impacting performance as a result.\n                 * @see #1799 https://github.com/bolt/bolt/issues/1799\n                 *\n                 * When we refactor we need to address the callers, as this is a\n                 * valid error state.\n                 */\n                continue;\n            }\n\n            if (is_array($actualParameters)) {\n                // Set the \'FROM\' part of the query, without the LEFT JOIN (i.e. no taxonomies..)\n                foreach ($actualParameters as $key => $value) {\n                    if ($key == \'order\') {\n                        $orderValue = $this->decodeQueryOrder($contenttype, $value);\n                        if ($orderValue !== false) {\n                            $order[] = $orderValue;\n                        }\n                        continue;\n                    }\n\n                    if ($key == \'filter\' && !empty($value)) {\n                        $filterWhere = [];\n                        foreach ($contenttype[\'fields\'] as $name => $fieldconfig) {\n                            if (in_array($fieldconfig[\'type\'], [\'text\', \'textarea\', \'html\', \'markdown\'])) {\n                                $filterWhere[] = sprintf(\n                                    \'%s.%s LIKE %s\',\n                                    $tablename,\n                                    $name,\n                                    $this->app[\'db\']->quote(\'%\' . $value . \'%\')\n                                );\n                            }\n                        }\n                        if (count($filterWhere) > 0) {\n                            $where[] = \'(\' . implode(\' OR \', $filterWhere) . \')\';\n                        }\n                        continue;\n                    }\n\n                    // build OR parts if key contains "|||"\n                    if (strpos($key, " ||| ") !== false) {\n                        $keyParts = explode(" ||| ", $key);\n                        $valParts = explode(" ||| ", $value);\n                        $orPart = \'( \';\n                        $countParts = count($keyParts);\n                        for ($i = 0; $i < $countParts; $i++) {\n                            if (in_array($keyParts[$i], $this->getContentTypeFields($contenttype[\'slug\'])) ||\n                                in_array($keyParts[$i], Content::getBaseColumns())) {\n                                $rkey = $tablename . \'.\' . $keyParts[$i];\n                                $fieldtype = $this->getContentTypeFieldType($contenttype[\'slug\'], $keyParts[$i]);\n                                $orPart .= \' (\' . $this->parseWhereParameter($rkey, $valParts[$i], $fieldtype) . \') OR \';\n                            }\n                        }\n                        if (strlen($orPart) > 2) {\n                            $where[] = substr($orPart, 0, -4) . \') \';\n                        }\n                    }\n\n                    // for all the parameters that are fields\n                    if (in_array($key, $this->getContentTypeFields($contenttype[\'slug\'])) ||\n                        in_array($key, Content::getBaseColumns())\n                    ) {\n                        $rkey = $tablename . \'.\' . $key;\n                        $fieldtype = $this->getContentTypeFieldType($contenttype[\'slug\'], $key);\n                        $where[] = $this->parseWhereParameter($rkey, $value, $fieldtype);\n                    }\n\n                    // for all the parameters that are taxonomies\n                    if (array_key_exists($key, $this->getContentTypeTaxonomy($contenttype[\'slug\']))) {\n                        // check if we\'re trying to use "!" as a way of \'not\'. If so, we need to do a \'NOT IN\', instead\n                        // of \'IN\'. And, the parameter in the subselect needs to be without "!" as a consequence.\n                        if (strpos($value, "!") !== false) {\n                            $notin = "NOT ";\n                            $value = str_replace("!", "", $value);\n                        } else {\n                            $notin = "";\n                        }\n\n                        // Set the extra \'$where\', with subselect for taxonomies.\n                        $where[] = sprintf(\n                            \'%s %s IN (SELECT content_id AS id FROM %s where %s AND ( %s OR %s ) AND %s)\',\n                            $this->app[\'db\']->quoteIdentifier(\'id\'),\n                            $notin,\n                            $this->getTablename(\'taxonomy\'),\n                            $this->parseWhereParameter($this->getTablename(\'taxonomy\') . \'.taxonomytype\', $key),\n                            $this->parseWhereParameter($this->getTablename(\'taxonomy\') . \'.slug\', $value),\n                            $this->parseWhereParameter($this->getTablename(\'taxonomy\') . \'.name\', $value),\n                            $this->parseWhereParameter($this->getTablename(\'taxonomy\') . \'.contenttype\', $contenttype[\'slug\'] . \' || \' . $contenttype[\'name\'])\n                        );\n                    }\n                }\n            }\n\n            if (count($order) == 0) {\n                $order[] = $this->decodeQueryOrder($contenttype, false) ?: \'datepublish DESC\';\n            }\n\n            if (count($where) > 0) {\n                $query[\'where\'] = sprintf(\'WHERE (%s)\', implode(\' AND \', $where));\n            }\n            if (count($order) > 0) {\n                $order = implode(\', \', $order);\n                if (!empty($order)) {\n                    $query[\'order\'] = sprintf(\'ORDER BY %s\', $order);\n                }\n            }\n\n            $decoded[\'queries\'][] = $query;\n\n            if (isset($inParameters[\'hydrate\'])) {\n                $decoded[\'hydrate\'] = $inParameters[\'hydrate\'];\n            }\n        }\n\n        return $decoded;\n    }\n\n    /**\n     * Decodes contextual page number from current request url if found.\n     *\n     * @param string $context      Pager id/name in url which value we find\n     * @param array  $inParameters\n     *\n     * @return mixed Page number in context\n     * @deprecated Not used in core anymore\n     */\n    protected function decodePageParameter($context = \'\', $inParameters = null)\n    {\n        if (isset($inParameters[\'page\']) && $inParameters[\'page\'] !== null) {\n            return $inParameters[\'page\'];\n        } else {\n            $param = Pager::makeParameterId($context);\n            /* @var $query \\Symfony\\Component\\HttpFoundation\\ParameterBag */\n            $query = $this->app[\'request\']->query;\n            $page = ($query) ? $query->get($param, $query->get(\'page\', 1)) : 1;\n        }\n\n        return $page;\n    }\n\n    /**\n     * Hydrate database rows into objects.\n     *\n     * @param array|string $contenttype\n     * @param array        $rows\n     * @param boolean      $getTaxoAndRel\n     *\n     * @throws \\Exception\n     *\n     * @return array\n     */\n    private function hydrateRows($contenttype, $rows, $getTaxoAndRel = true)\n    {\n        // Make sure content is set, and all content has information about its contenttype\n        $objects = [];\n        foreach ($rows as $row) {\n            $objects[$row[\'id\']] = $this->getContentObject($contenttype, $row);\n        }\n\n        if ($getTaxoAndRel) {\n            // Make sure all content has their taxonomies and relations\n            $this->getTaxonomy($objects);\n            $this->getRelation($objects);\n            $this->getRepeaters($objects);\n        }\n\n        return $objects;\n    }\n\n    /**\n     * Execute the content queries.\n     *\n     * This is tightly coupled to $this->getContent()\n     *\n     * @see $this->getContent()\n     *\n     * @param array $decoded\n     * @param array $parameters\n     *\n     * @return array\n     */\n    protected function executeGetContentSearch($decoded, $parameters)\n    {\n        $limit = 2000;\n        $offset = 0;\n\n        // set correct limit and offset if these are set.\n        if (isset($decoded[\'parameters\'][\'limit\'])) {\n            $limit = $decoded[\'parameters\'][\'limit\'];\n        }\n\n        if (isset($decoded[\'parameters\'][\'paging\']) && ($decoded[\'parameters\'][\'paging\'] === true) && isset($decoded[\'parameters\'][\'page\'])) {\n            // Pagenumbers are one-based, not zero-based.\n            $offset = $limit * ($decoded[\'parameters\'][\'page\'] - 1);\n        }\n\n        $results = $this->searchContent(\n            $parameters[\'filter\'],\n            $decoded[\'contenttypes\'],\n            null,\n            $limit,\n            $offset\n        );\n\n        return [\n            $results[\'results\'],\n            $results[\'no_of_results\'],\n        ];\n    }\n\n    /**\n     * Execute the content queries.\n     *\n     * This is tightly coupled to $this->getContent()\n     *\n     * @see $this->getContent()\n     *\n     * @param array $decoded\n     *\n     * @return array\n     */\n    protected function executeGetContentQueries($decoded)\n    {\n        // Perform actual queries and hydrate\n        $totalResults = false;\n        $results = false;\n        foreach ($decoded[\'queries\'] as $query) {\n            $statement = sprintf(\n                \'SELECT %s.* %s %s %s\',\n                $query[\'tablename\'],\n                $query[\'from\'],\n                $query[\'where\'],\n                $query[\'order\']\n            );\n\n            if ($decoded[\'self_paginated\'] === true) {\n                // self pagination requires an extra query to return the actual number of results\n                if ($decoded[\'return_single\'] === false) {\n                    $countStatement = sprintf(\n                        \'SELECT COUNT(*) as count %s %s\',\n                        $query[\'from\'],\n                        $query[\'where\']\n                    );\n                    $countRow = $this->app[\'db\']->executeQuery($countStatement)->fetch();\n                    $totalResults = $countRow[\'count\'];\n                }\n\n                if (isset($decoded[\'parameters\'][\'paging\']) && $decoded[\'parameters\'][\'paging\'] === true) {\n                    $offset = ($decoded[\'parameters\'][\'page\'] - 1) * $decoded[\'parameters\'][\'limit\'];\n                } else {\n                    $offset = null;\n                }\n                $limit = $decoded[\'parameters\'][\'limit\'];\n\n                // @todo this will fail when actually using params on certain databases\n                $statement = $this->app[\'db\']->getDatabasePlatform()->modifyLimitQuery($statement, $limit, $offset);\n            } elseif (!empty($decoded[\'parameters\'][\'limit\'])) {\n                // If we\'re not paging, but we _did_ provide a limit.\n                $limit = $decoded[\'parameters\'][\'limit\'];\n                $statement = $this->app[\'db\']->getDatabasePlatform()->modifyLimitQuery($statement, $limit);\n            }\n\n            if (!empty($decoded[\'parameters\'][\'printquery\'])) {\n                // @todo formalize this\n                echo nl2br(htmlentities($statement));\n            }\n\n            $rows = $this->app[\'db\']->fetchAll($statement, $query[\'params\']);\n\n            // Convert the row \'arrays\' into \\Bolt\\Legacy\\Content objects.\n            // Only get the Taxonomies and Relations if we have to.\n            $rows = $this->hydrateRows($query[\'contenttype\'], $rows, $decoded[\'hydrate\']);\n\n            if ($results === false) {\n                $results = $rows;\n            } else {\n                // We can no longer maintain keys when merging subresults\n                $results = array_merge($results, array_values($rows));\n            }\n        }\n\n        if ($totalResults === false) {\n            $totalResults = count($results);\n        }\n\n        return [$results, $totalResults];\n    }\n\n    /**\n     * getContent based on a \'human readable query\'.\n     *\n     * Used directly by {% setcontent %} but also in other parts.\n     *\n     * This code has been split into multiple methods in the spirit of\n     * separation of concerns, but the situation is still far from ideal.\n     *\n     * Where applicable each \'concern\' notes the coupling in the local\n     * documentation.\n     *\n     * @param string $textquery\n     * @param string $parameters\n     * @param array  $pager\n     * @param array  $whereparameters\n     *\n     * @return array\n     */\n    public function getContent($textquery, $parameters = \'\', &$pager = [], $whereparameters = [])\n    {\n        // Start the \'stopwatch\' for the profiler.\n        $this->app[\'stopwatch\']->start(\'bolt.getcontent\', \'doctrine\');\n\n        // $whereparameters is passed if called from a compiled template. If present, merge it with $parameters.\n        if (!empty($whereparameters)) {\n            $parameters = array_merge((array) $parameters, (array) $whereparameters);\n        }\n\n        $logNotFound = false;\n        if (isset($parameters[\'log_not_found\'])) {\n            $logNotFound = $parameters[\'log_not_found\'];\n            unset($parameters[\'log_not_found\']);\n        }\n\n        // Decode this textquery\n        $decoded = $this->decodeContentQuery($textquery, $parameters);\n        if ($decoded === false) {\n            $this->app[\'logger.system\']->error("Not a valid query: \'$textquery\'", [\'event\' => \'storage\']);\n            $this->app[\'stopwatch\']->stop(\'bolt.getcontent\');\n\n            return false;\n        }\n\n        // Run table checks\n        if (!$this->runContentTypeTableChecks($decoded[\'contenttypes\'])) {\n            $this->app[\'stopwatch\']->stop(\'bolt.getcontent\');\n\n            return false;\n        }\n\n        // Run the actual queries\n        list($results, $totalResults) = call_user_func(\n            $decoded[\'queries_callback\'],\n            $decoded,\n            $parameters\n        );\n\n        // Perform post hydration ordering\n        if ($decoded[\'order_callback\'] !== false) {\n            if (is_scalar($decoded[\'order_callback\']) && ($decoded[\'order_callback\'] == \'RANDOM\')) {\n                shuffle($results);\n            } else {\n                uasort($results, $decoded[\'order_callback\']);\n            }\n        }\n\n        // Perform pagination if necessary, but never paginate when \'returnsingle\' is used.\n        $offset = 0;\n        $limit = false;\n        if (($decoded[\'self_paginated\'] === false) && (isset($decoded[\'parameters\'][\'page\'])) && (!$decoded[\'return_single\'])) {\n            $offset = ($decoded[\'parameters\'][\'page\'] - 1) * $decoded[\'parameters\'][\'limit\'];\n            $limit = $decoded[\'parameters\'][\'limit\'];\n        }\n        if ($limit !== false) {\n            $results = array_slice($results, $offset, $limit);\n        }\n\n        // Return content\n        if ($decoded[\'return_single\']) {\n            if (count($results) > 0) {\n                $this->app[\'stopwatch\']->stop(\'bolt.getcontent\');\n\n                return reset($results);\n            }\n\n            if ($logNotFound) {\n                $msg = sprintf(\n                    "Requested specific query \'%s\', not found.",\n                    $textquery\n                );\n                $this->app[\'logger.system\']->error($msg, [\'event\' => \'storage\']);\n            }\n            $this->app[\'stopwatch\']->stop(\'bolt.getcontent\');\n\n            return false;\n        }\n\n        // Set up the $pager array with relevant values, but only if we requested paging.\n        if (isset($decoded[\'parameters\'][\'paging\'])) {\n            $pagerName = implode(\'_\', $decoded[\'contenttypes\']);\n\n            /** @var \\Bolt\\Pager\\PagerManager $manager */\n        if (isset($inParameters[\'page\']) && $inParameters[\'page\'] !== null) {\n            return $inParameters[\'page\'];\n        } else {\n            $param = Pager::makeParameterId($context);\n            /* @var $query \\Symfony\\Component\\HttpFoundation\\ParameterBag */\n            $query = $this->app[\'request\']->query;\n            $page = ($query) ? $query->get($param, $query->get(\'page\', 1)) : 1;\n        }\n\n        return $page;\n        $this->app[\'stopwatch\']->stop(\'bolt.getcontent\');\n\n        return $results;\n    }\n\n    /**\n     * Check for the existence of ContentType tables.\n     *\n     * @param array $contenttypes ContentType slugs to check\n     *\n     * @return boolean\n     */\n    private function runContentTypeTableChecks(array $contenttypes)\n    {\n        $checkedcontenttype = [];\n        foreach ($contenttypes as $contenttypeslug) {\n            // Make sure we do this only once per contenttype\n            if (isset($checkedcontenttype[$contenttypeslug])) {\n                continue;\n            }\n            $checkedcontenttype[$contenttypeslug] = true;\n\n            // If the table doesn\'t exist (yet), return false.\n            $contenttype = $this->getContentType($contenttypeslug);\n            $tablename = $this->getContenttypeTablename($contenttype);\n            if (!$this->tableExists($tablename)) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * Check if a given name is a valid column, and if it can be used in queries.\n     *\n     * @param string  $name\n     * @param array   $contenttype\n     * @param boolean $allowVariants\n     *\n     * @return boolean\n     */\n    private function isValidColumn($name, $contenttype, $allowVariants = false)\n    {\n        $isValidColumn = false;\n\n        if ($this->isMultiOrderQuery($name)) {\n            $separatedOrders = $this->getOrderBys($name);\n        } else {\n            $separatedOrders = [$name];\n        }\n\n        //Loop through each term if multiple\n        foreach ($separatedOrders as $index => $name) {\n            $name = trim($name);\n\n            // Strip the minus in \'-title\' if allowed.\n            if ($allowVariants) {\n                if ((strlen($name) > 0) && ($name[0] == "-")) {\n                    $name = substr($name, 1);\n                }\n                $name = $this->getFieldName($name);\n            }\n\n            // Check if the $name is in the contenttype\'s fields.\n            if (isset($contenttype[\'fields\'][$name])) {\n                $isValidColumn = true;\n            }\n\n            if (in_array($name, Content::getBaseColumns())) {\n                $isValidColumn = true;\n            }\n\n            // Repeating fields are handled differently so skip here\n            if (isset($contenttype[\'fields\'][$name]) && $contenttype[\'fields\'][$name][\'type\'] == \'repeater\') {\n                $isValidColumn = false;\n            }\n\n            if (! $isValidColumn) {\n                return false;\n            }\n        }\n\n        return true;\n    }\n\n    /**\n     * Get field name, stripping possible " DESC" " ASC" etc.\n     *\n     * @param string $name\n     *\n     * @return string\n     */\n    private function getFieldName($name)\n    {\n        return preg_replace("/ (desc|asc)$/i", "", $name);\n    }\n\n    /**\n     * Get an escaped sortorder for use in SQL, from a fieldname like \'title\' or \'-id\'.\n     *\n     * for example, -id returns `r`.`id` DESC\n     *\n     * @param string $name\n     * @param string $prefix\n     *\n     * @return string\n     */\n    private function getEscapedSortorder($name, $prefix = \'r\')\n    {\n        $order = \'\';\n\n        $separatedOrders = $this->getOrderBys($name);\n\n        $totalOrderByElements = count($separatedOrders);\n\n        foreach ($separatedOrders as $index => $name) {\n            list($name, $asc) = $this->getSortOrder($name);\n\n            // If we don\'t have a name, we can\'t determine a sortorder.\n            if (empty($name)) {\n                return false;\n            }\n            if (strpos($name, \'RAND\') !== false) {\n                $order .= $name;\n            } elseif ($prefix !== false) {\n                $order .= $this->app[\'db\']->quoteIdentifier($prefix . \'.\' . $name);\n            } else {\n                $order .= $this->app[\'db\']->quoteIdentifier($name);\n            }\n\n            if (! $asc) {\n                $order .= \' DESC\';\n            }\n\n            if ($this->isNotLastItemInArray($totalOrderByElements, $index)) {\n                $order .= \',\';\n            }\n        }\n\n        return $order;\n    }\n\n    /**\n     * Check if this is the last element in the array.\n     *\n     * @param $totalOrderByElements\n     * @param $index\n     *\n     * @return boolean\n     */\n    protected function isNotLastItemInArray($totalOrderByElements, $index)\n    {\n        return $totalOrderByElements !== ($index + 1);\n    }\n\n    /**\n     * Get the ORDER BY parameters for the query.\n     *\n     * @param $order\n     *\n     * @return array\n     */\n    protected function getOrderBys($order)\n    {\n        $separatedOrders = [$order];\n\n        if ($this->isMultiOrderQuery($order)) {\n            $separatedOrders = explode(\',\', $order);\n        }\n\n        return $separatedOrders;\n    }\n\n    /**\n     * Check if this query has multiple ORDER BY parameters.\n     *\n     * @param $order\n     *\n     * @return boolean\n     */\n    protected function isMultiOrderQuery($order)\n    {\n        return (is_string($order) && (strpos($order, \',\') !== false));\n    }\n\n    /**\n     * Get sorting order of name, stripping possible " DESC" " ASC" etc., and\n     * also return the sorting order.\n     *\n     * @param string $name\n     *\n     * @return string\n     */\n    public function getSortOrder($name = \'-datepublish\')\n    {\n        // If we don\'t get a string, we can\'t determine a sortorder.\n        if (!is_string($name)) {\n            return false;\n        }\n\n        $parts = explode(\' \', trim($name));\n        $fieldname = $parts[0];\n        $sort = \'ASC\';\n        if (isset($parts[1])) {\n            $sort = $parts[1];\n        }\n\n        if ($fieldname[0] == \'-\') {\n            $fieldname = substr($fieldname, 1);\n            $sort = \'DESC\';\n        }\n\n        return [$fieldname, (strtoupper($sort) == \'ASC\')];\n    }\n\n    /**\n     * Helper function for sorting Records of content that have a Grouping.\n     *\n     * @param Content $a\n     * @param Content $b\n     *\n     * @return int\n     */\n    private function groupingSort(Content $a, Content $b)\n    {\n        // Same group, sort within group.\n        if ($a->group[\'slug\'] == $b->group[\'slug\']) {\n            if (!empty($a->sortorder) || !empty($b->sortorder)) {\n                if (!isset($a->sortorder)) {\n                    return 1;\n                } elseif (!isset($b->sortorder)) {\n                    return -1;\n                } else {\n                    return ($a->sortorder < $b->sortorder) ? -1 : 1;\n                }\n            }\n\n            if (!empty($a->contenttype[\'sort\'])) {\n                // Same group, so we sort on contenttype[\'sort\']\n                list($secondSort, $order) = $this->getSortOrder($a->contenttype[\'sort\']);\n\n                $vala = strtolower($a->values[$secondSort]);\n                $valb = strtolower($b->values[$secondSort]);\n\n                if ($vala == $valb) {\n        // Set up the $pager array with relevant values, but only if we requested paging.\n        if (isset($decoded[\'parameters\'][\'paging\'])) {\n            $pagerName = implode(\'_\', $decoded[\'contenttypes\']);\n            $pager = [\n                \'for\'          => $pagerName,\n                \'count\'        => $totalResults,\n                \'totalpages\'   => ceil($totalResults / $decoded[\'parameters\'][\'limit\']),\n            }\n                \'showing_from\' => ($decoded[\'parameters\'][\'page\'] - 1) * $decoded[\'parameters\'][\'limit\'] + 1,\n                \'showing_to\'   => ($decoded[\'parameters\'][\'page\'] - 1) * $decoded[\'parameters\'][\'limit\'] + count($results),\n            ];\n            $this->setPager($pagerName, $pager);\n            $this->app[\'twig\']->addGlobal(\'pager\', $this->getPager());\n        }\n\n        $this->app[\'stopwatch\']->stop(\'bolt.getcontent\');\n     * Helper function to set the proper \'where\' parameter,\n     * when getting values like \'<2012\' or \'!bob\'.\n     *\n     * @param string $key\n     * @param string $value\n     * @param mixed  $fieldtype\n     *\n     * @return string\n     */\n    private function parseWhereParameter($key, $value, $fieldtype = false)\n    {\n        $value = trim($value);\n\n        // check if we need to split.\n        if (strpos($value, " || ") !== false) {\n            $values = explode(" || ", $value);\n            foreach ($values as $index => $value) {\n                $values[$index] = $this->parseWhereParameter($key, $value, $fieldtype);\n            }\n\n            return "( " . implode(" OR ", $values) . " )";\n        } elseif (strpos($value, " && ") !== false) {\n            $values = explode(" && ", $value);\n            foreach ($values as $index => $value) {\n                $values[$index] = $this->parseWhereParameter($key, $value, $fieldtype);\n            }\n\n            return "( " . implode(" AND ", $values) . " )";\n        }\n\n        // Set the correct operator for the where clause\n        $operator = "=";\n\n        $first = substr($value, 0, 1);\n\n        if ($first == "!") {\n            $operator = "!=";\n            $value = substr($value, 1);\n        } elseif (substr($value, 0, 2) == "<=") {\n            $operator = "<=";\n            $value = substr($value, 2);\n        } elseif (substr($value, 0, 2) == ">=") {\n            $operator = ">=";\n            $value = substr($value, 2);\n        } elseif ($first == "<") {\n            $operator = "<";\n            $value = substr($value, 1);\n        } elseif ($first == ">") {\n            $operator = ">";\n            $value = substr($value, 1);\n        } elseif ($first == "%" || substr($value, -1) == "%") {\n            $operator = "LIKE";\n        }\n\n        // Use strtotime to allow selections like "< last monday" or "this year"\n        if (in_array($fieldtype, [\'date\', \'datetime\']) && ($timestamp = strtotime($value)) !== false) {\n            $value = date(\'Y-m-d H:i:s\', $timestamp);\n        }\n\n        $parameter = sprintf("%s %s %s", $this->app[\'db\']->quoteIdentifier($key), $operator, $this->app[\'db\']->quote($value));\n\n        return $parameter;\n    }\n\n    /**\n     * Get the contenttype as an array, based on the given $contenttypeslug.\n     *\n     * @param string $contenttypeslug\n     *\n     * @return boolean|array\n     */\n    public function getContentType($contenttypeslug)\n    {\n        // Return false if empty, can\'t find it.\n        if (empty($contenttypeslug)) {\n            return false;\n        }\n\n        $contenttype = $this->app[\'config\']->get(\'contenttypes/\' . $contenttypeslug);\n\n        // See if we\'ve either given the correct contenttype, or try to find it by name or singular_name.\n        if (!$contenttype) {\n            // Also check for the slugified version of the content type\n            $slugifiedContentType = $this->app[\'slugify\']->slugify($contenttypeslug);\n            $contenttype = $this->app[\'config\']->get(\'contenttypes/\' . $slugifiedContentType);\n\n            if (!$contenttype) {\n                foreach ($this->app[\'config\']->get(\'contenttypes\') as $key => $ct) {\n                    if (\n                        (isset($ct[\'slug\']) &&\n                            (($contenttypeslug === $ct[\'slug\']) || ($slugifiedContentType === $ct[\'slug\']))\n                        ) ||\n                        (isset($ct[\'singular_slug\']) &&\n                            (($contenttypeslug === $ct[\'singular_slug\']) || ($slugifiedContentType === $ct[\'singular_slug\']))\n                        ) ||\n                        $slugifiedContentType === $this->app[\'slugify\']->slugify($ct[\'name\']) ||\n                        $slugifiedContentType === $this->app[\'slugify\']->slugify($ct[\'singular_name\'])\n                    ) {\n                        $contenttype = $ct;\n                        $contenttype[\'key\'] = $key;\n                        break;\n                    }\n                }\n            }\n        }\n\n        if (!empty($contenttype)) {\n            return $contenttype;\n        } else {\n            return false;\n        }\n    }\n\n    /**\n     * Get the taxonomy as an array, based on the given $taxonomyslug.\n     *\n     * @param string $taxonomyslug\n     *\n     * @return boolean|array\n     */\n    public function getTaxonomyType($taxonomyslug)\n    {\n        $taxonomyslug = $this->app[\'slugify\']->slugify($taxonomyslug);\n\n        // Return false if empty, can\'t find it.\n        if (empty($taxonomyslug)) {\n            return false;\n        }\n\n        // See if we\'ve either given the correct contenttype, or try to find it by name or singular_name.\n        if ($this->app[\'config\']->get(\'taxonomy/\' . $taxonomyslug)) {\n            $taxonomytype = $this->app[\'config\']->get(\'taxonomy/\' . $taxonomyslug);\n        } else {\n            foreach ($this->app[\'config\']->get(\'taxonomy\') as $key => $tt) {\n                if (isset($tt[\'singular_slug\']) && ($taxonomyslug == $tt[\'singular_slug\'])) {\n                    $taxonomytype = $this->app[\'config\']->get(\'taxonomy/\' . $key);\n                    break;\n                }\n            }\n        }\n\n        if (!empty($taxonomytype)) {\n            return $taxonomytype;\n        } else {\n            return false;\n        }\n    }\n\n    /**\n     * Get an array of the available contenttypes.\n     *\n     * @return array $contenttypes\n     */\n    public function getContentTypes()\n    {\n        return array_keys($this->app[\'config\']->get(\'contenttypes\'));\n    }\n\n    /**\n     * Get an array of the available fields for a given contenttype.\n     *\n     * @param string $contenttypeslug\n     *\n     * @return array $fields\n     */\n    public function getContentTypeFields($contenttypeslug)\n    {\n        $contenttype = $this->getContentType($contenttypeslug);\n\n        if (empty($contenttype[\'fields\'])) {\n            return [];\n        } else {\n            return array_keys($contenttype[\'fields\']);\n        }\n    }\n\n    /**\n     * Get the fieldtype for a given contenttype and fieldname.\n     *\n     * @param string $contenttypeslug\n     * @param string $fieldname\n     *\n     * @return array $fields\n     */\n    public function getContentTypeFieldType($contenttypeslug, $fieldname)\n    {\n        $contenttype = $this->getContentType($contenttypeslug);\n\n        if (in_array($fieldname, [\'datecreated\', \'datechanged\', \'datepublish\', \'datedepublish\'])) {\n            return "datetime";\n        } elseif (isset($contenttype[\'fields\'][$fieldname][\'type\'])) {\n            return $contenttype[\'fields\'][$fieldname][\'type\'];\n        } else {\n            return false;\n        }\n    }\n\n    /**\n     * Check if a given contenttype has a grouping, and if it does, return it.\n     *\n     * @param string $contenttypeslug\n     *\n     * @return mixed $grouping\n     */\n    public function getContentTypeGrouping($contenttypeslug)\n    {\n        $grouping = false;\n        $taxonomy = $this->getContentTypeTaxonomy($contenttypeslug);\n        foreach ($taxonomy as $tax) {\n            if ($tax[\'behaves_like\'] === \'grouping\') {\n                $grouping = $tax[\'slug\'];\n                break;\n            }\n        }\n\n        return $grouping;\n    }\n\n    /**\n     * Get an array of the available taxonomytypes for a given contenttype.\n     *\n     * @param string $contenttypeslug\n     *\n     * @return array $taxonomy\n     */\n    public function getContentTypeTaxonomy($contenttypeslug)\n    {\n        $contenttype = $this->getContentType($contenttypeslug);\n\n        if (empty($contenttype[\'taxonomy\'])) {\n            return [];\n        } else {\n            $taxokeys = $contenttype[\'taxonomy\'];\n\n            $taxonomy = [];\n\n            foreach ($taxokeys as $key) {\n                if ($this->app[\'config\']->get(\'taxonomy/\' . $key)) {\n                    $taxonomy[$key] = $this->app[\'config\']->get(\'taxonomy/\' . $key);\n                }\n            }\n\n            return $taxonomy;\n        }\n    }\n\n    /**\n     * Get the taxonomy for one or more units of content, return the array with the taxonomy attached.\n     *\n     * @param \\Bolt\\Legacy\\Content[] $content\n     *\n     * @return array $content\n     */\n    protected function getTaxonomy($content)\n    {\n        $tablename = $this->getTablename("taxonomy");\n\n        $ids = Arr::column($content, \'id\');\n\n        if (empty($ids)) {\n            return;\n        }\n\n        // Get the contenttype from first $content\n        $first = reset($content);\n        $config = $first->contenttype;\n        $contenttype = (isset($config[\'key\'])) ? $config[\'key\'] : $config[\'slug\'];\n\n        $taxonomytypes = $this->app[\'config\']->get(\'taxonomy\');\n\n        // If there are no taxonomytypes, there won\'t be any results, so we return.\n        if (empty($taxonomytypes)) {\n            return;\n        }\n\n        $query = sprintf(\n            "SELECT * FROM %s WHERE content_id IN (?) AND contenttype=? AND taxonomytype IN (?)",\n            $tablename\n        );\n        $rows = $this->app[\'db\']->executeQuery(\n            $query,\n            [$ids, $contenttype, array_keys($taxonomytypes)],\n            [DoctrineConn::PARAM_INT_ARRAY, \\PDO::PARAM_STR, DoctrineConn::PARAM_STR_ARRAY]\n        )->fetchAll();\n\n        foreach ($rows as $row) {\n            $content[$row[\'content_id\']]->setTaxonomy($row[\'taxonomytype\'], $row[\'slug\'], $row[\'name\'], $row[\'sortorder\']);\n        }\n\n        foreach ($content as $key => $value) {\n            $content[$key]->sortTaxonomy();\n        }\n    }\n\n    /**\n     * Update / insert taxonomy for a given content-unit.\n     *\n     * @param string  $contenttype\n     * @param integer $contentId\n     * @param array   $taxonomy\n     */\n    protected function updateTaxonomy($contenttype, $contentId, $taxonomy)\n    {\n        $tablename = $this->getTablename("taxonomy");\n        $configTaxonomies = $this->app[\'config\']->get(\'taxonomy\');\n\n        // Make sure $contenttypeslug is a \'slug\'\n        if (is_array($contenttype)) {\n            $contenttypeslug = $contenttype[\'slug\'];\n        } else {\n            $contenttypeslug = $contenttype;\n        }\n\n        // If our contenttype has no taxonomies, there\'s nothing for us to do here.\n        if (!isset($contenttype[\'taxonomy\'])) {\n            return;\n        }\n\n        foreach ($contenttype[\'taxonomy\'] as $taxonomytype) {\n            // Set \'newvalues to \'empty array\' if not defined\n            if (!empty($taxonomy[$taxonomytype])) {\n                $newslugs = $taxonomy[$taxonomytype];\n            } else {\n                $newslugs = [];\n            }\n\n            // Get the current values from the DB.\n            $query = sprintf(\n                "SELECT id, slug, sortorder FROM %s WHERE content_id=? AND contenttype=? AND taxonomytype=?",\n                $tablename\n            );\n            $currentvalues = $this->app[\'db\']->executeQuery(\n                $query,\n                [$contentId, $contenttypeslug, $taxonomytype],\n                [\\PDO::PARAM_INT, \\PDO::PARAM_STR, \\PDO::PARAM_STR]\n            )->fetchAll();\n\n            if (!empty($currentvalues)) {\n                $currentsortorder = $currentvalues[0][\'sortorder\'];\n                $currentvalues = Arr::column($currentvalues, \'slug\', \'id\');\n            } else {\n                $currentsortorder = 0;\n                $currentvalues = [];\n            }\n\n            // Add the ones not yet present.\n            foreach ($newslugs as $slug) {\n                // If it\'s like \'desktop#10\', split it into value and sortorder.\n                list($slug, $sortorder) = explode(\'#\', $slug . "#");\n\n                // @todo clean up and/or refactor\n                // If you save this content via anything other than the Bolt\n                // backend (see Content->setFromPost), then taxonomies that\n                // behave like groupings, will have their sortorders reset to 0.\n                if ($configTaxonomies[$taxonomytype][\'behaves_like\'] == \'grouping\' && empty($sortorder) && $sortorder !== \'0\') {\n                    $sortorder = $currentsortorder;\n                }\n\n                if (empty($sortorder)) {\n                    $sortorder = 0;\n                }\n\n                // Make sure we have a \'name\'.\n                if (isset($configTaxonomies[$taxonomytype][\'options\'][$slug])) {\n                    $name = $configTaxonomies[$taxonomytype][\'options\'][$slug];\n                } else {\n                    $name = $slug;\n                }\n\n                // Make sure the slug is also set correctly\n                if (!isset($configTaxonomies[$taxonomytype][\'options\'][$slug])) {\n                    // Assume we passed a value, instead of a slug. Turn it back into a proper slug\n                    if (isset($configTaxonomies[$taxonomytype][\'options\']) &&\n                        is_array($configTaxonomies[$taxonomytype][\'options\']) &&\n                        array_search($slug, $configTaxonomies[$taxonomytype][\'options\'])) {\n                        $slug = array_search($slug, $configTaxonomies[$taxonomytype][\'options\']);\n                    } else {\n                        // make sure it\'s at least a slug-like value.\n                        $slug = $this->app[\'slugify\']->slugify($slug);\n                    }\n                }\n\n                if ((!in_array($slug, $currentvalues) || ($currentsortorder != $sortorder)) && (!empty($slug))) {\n                    // Insert it!\n                    $row = [\n                        \'content_id\'   => $contentId,\n                        \'contenttype\'  => $contenttypeslug,\n                        \'taxonomytype\' => $taxonomytype,\n                        \'slug\'         => $slug,\n                        \'name\'         => $name,\n                        \'sortorder\'    => (int) $sortorder,\n                    ];\n\n                    $this->app[\'db\']->insert($tablename, $row);\n                }\n            }\n\n            // Convert new slugs to lowercase to compare in the delete process\n            $newSlugsNormalised = [];\n            foreach ($newslugs as $slug) {\n                // If it\'s like \'desktop#10\', split it into value and sortorder.\n                list($slug, $sortorder) = explode(\'#\', $slug . "#");\n                $slug = $this->app[\'slugify\']->slugify($slug);\n\n                if (!empty($sortorder)) {\n                    $slug = $slug . \'#\' . $sortorder;\n                }\n\n                $newSlugsNormalised[] = $slug;\n            }\n\n            // Delete the ones that have been removed.\n            foreach ($currentvalues as $id => $slug) {\n                // Make it look like \'desktop#10\'\n                $valuewithorder = $slug . "#" . $currentsortorder;\n                $slugkey = \'/\' . $configTaxonomies[$taxonomytype][\'slug\'] . \'/\' . $slug;\n\n                if (!in_array($slug, $newSlugsNormalised) && !in_array($valuewithorder, $newSlugsNormalised) && !array_key_exists($slugkey, $newSlugsNormalised)) {\n                    $this->app[\'db\']->delete($tablename, [\'id\' => $id]);\n                }\n            }\n        }\n    }\n\n    /**\n     * Get the relations for one or more units of content, return the array with the taxonomy attached.\n     *\n     * @param array $content\n     *\n     * @return array $content\n     */\n    protected function getRelation($content)\n    {\n        $tablename = $this->getTablename("relations");\n\n        $ids = Arr::column($content, \'id\');\n\n        if (empty($ids)) {\n            return;\n        }\n\n        // Get the contenttype from first $content\n        $first = reset($content);\n        $contenttype = isset($first->contenttype[\'key\']) ? $first->contenttype[\'key\'] : $first->contenttype[\'slug\'];\n\n        $query = sprintf(\n            "SELECT * FROM %s WHERE from_contenttype=? AND from_id IN (?) ORDER BY id",\n            $tablename\n        );\n        $params = [$contenttype, $ids];\n        $paramTypes = [\\PDO::PARAM_STR, DoctrineConn::PARAM_INT_ARRAY];\n        $rows = $this->app[\'db\']->executeQuery($query, $params, $paramTypes)->fetchAll();\n\n        foreach ($rows as $row) {\n            $content[$row[\'from_id\']]->setRelation($row[\'to_contenttype\'], $row[\'to_id\']);\n        }\n\n        // switch it, flip it and reverse it. wop wop wop.\n        $query = sprintf(\n            "SELECT * FROM %s WHERE to_contenttype=? AND to_id IN (?) ORDER BY id",\n            $tablename\n        );\n        $params = [$contenttype, $ids];\n        $paramTypes = [\\PDO::PARAM_STR, DoctrineConn::PARAM_INT_ARRAY];\n        $rows = $this->app[\'db\']->executeQuery($query, $params, $paramTypes)->fetchAll();\n\n        foreach ($rows as $row) {\n            $content[$row[\'to_id\']]->setRelation($row[\'from_contenttype\'], $row[\'from_id\']);\n        }\n    }\n\n    public function getRepeaters($content)\n    {\n        $ids = Arr::column($content, \'id\');\n\n        if (empty($ids)) {\n            return;\n        }\n\n        // Get the contenttype from first $content\n        $first = reset($content);\n        // Try with the key first, if this isn\'t available then fall back to using the slug\n        $contenttypeslug = (isset($first->contenttype[\'key\'])) ? $first->contenttype[\'key\'] : $first->contenttype[\'slug\'] ;\n        $contenttype = $this->getContentType($contenttypeslug);\n        $repo = $this->app[\'storage\']->getRepository(FieldValue::class);\n\n        foreach ($ids as $id) {\n            foreach ($contenttype[\'fields\'] as $fieldkey => $field) {\n                if ($field[\'type\'] == \'repeater\' || $field[\'type\'] == \'block\') {\n                    $collection = new RepeatingFieldCollection($this->app[\'storage\'], $field);\n                    try {\n                        $existingFields = $repo->getExistingFields($id, $contenttypeslug, $fieldkey) ?: [];\n                        foreach ($existingFields as $group => $ids) {\n                            $collection->addFromReferences($ids, $group);\n                        }\n                    } catch (Exception $e) {\n                    }\n                    $content[$id]->setValue($fieldkey, $collection);\n                }\n            }\n\n        }\n\n\n\n    }\n\n    /**\n     * Update / insert relation for a given content-unit.\n     *\n     * $relation looks like:\n     * arr(2)\n     * [\n     *   "pages"        => arr(1)\n     *   [\n     *      0 => str(2) "22"\n     *   ]\n     *   "showcases" => arr(3)\n     *   [\n     *      0 => str(2) "15"\n     *      1 => str(1) "9"\n     *      2 => str(2) "13"\n     *   ]\n     * ]\n     *\n     * $currentvalues looks like\n     * arr(2)\n     * [\n     *   0 => arr(3)\n     *   [\n     *     "id"             => str(1) "5"\n     *     "to_contenttype" => str(12) "showcases"\n     *     "to_id"          => str(2) "15"\n     *   ]\n     *   1 => arr(3)\n     *   [\n     *     "id"             => str(1) "6"\n     *     "to_contenttype" => str(12) "showcases"\n     *     "to_id"          => str(1) "9"\n     *   ]\n     * ]\n     *\n     * @param string  $contenttype\n     * @param integer $contentId\n     * @param array   $relation\n     */\n    protected function updateRelation($contenttype, $contentId, $relation)\n    {\n        $tablename = $this->getTablename("relations");\n\n        // Get the current values from the DB.\n        $query = sprintf(\n            "SELECT id, to_contenttype, to_id FROM %s WHERE from_id=? AND from_contenttype=?",\n            $tablename\n        );\n        $currentvalues = $this->app[\'db\']->executeQuery(\n            $query,\n            [$contentId, $contenttype[\'slug\']],\n            [\\PDO::PARAM_INT, \\PDO::PARAM_STR]\n        )->fetchAll();\n\n        // And the other way around.\n        $query = sprintf(\n            "SELECT id, from_contenttype AS to_contenttype, from_id AS to_id FROM %s WHERE to_id=? AND to_contenttype=?",\n            $tablename\n        );\n        $currentvalues2 = $this->app[\'db\']->executeQuery(\n            $query,\n            [$contentId, $contenttype[\'slug\']],\n            [\\PDO::PARAM_INT, \\PDO::PARAM_STR]\n        )->fetchAll();\n\n        // Merge them.\n        $currentvalues = array_merge($currentvalues, $currentvalues2);\n\n        // Delete the ones that have been removed, but only if the contenttype defines the relations. For if we have\n        // example, if we have a relation from \'pages\' to \'entries\', do not delete them when editing an \'entry\'.\n        foreach ($currentvalues as $currentvalue) {\n            if ((!isset($relation[$currentvalue[\'to_contenttype\']]) ||\n                    !in_array($currentvalue[\'to_id\'], $relation[$currentvalue[\'to_contenttype\']])) &&\n                isset($contenttype[\'relations\'][$currentvalue[\'to_contenttype\']])\n            ) {\n                $this->app[\'db\']->delete($tablename, [\'id\' => $currentvalue[\'id\']]);\n            }\n        }\n\n        // Make an easier array out of $currentvalues.\n        $tempvalues = $currentvalues;\n        $currentvalues = [];\n        foreach ($tempvalues as $tempvalue) {\n            $currentvalues[] = $tempvalue[\'to_contenttype\'] . "/" . $tempvalue[\'to_id\'];\n        }\n\n        // Add the ones not yet present.\n        if (!empty($relation)) {\n            foreach ($relation as $toContenttype => $newvalues) {\n                foreach ($newvalues as $value) {\n                    if (!in_array($toContenttype . "/" . $value, $currentvalues) && (!empty($value))) {\n                        // Insert it!\n                        $row = [\n                            \'from_contenttype\' => $contenttype[\'slug\'],\n                            \'from_id\'          => $contentId,\n                            \'to_contenttype\'   => $toContenttype,\n                            \'to_id\'            => $value,\n                        ];\n                        $this->app[\'db\']->insert($tablename, $row);\n                    }\n                }\n            }\n        }\n    }\n\n    public function getLatestId($contenttypeslug)\n    {\n        $tablename = $this->getContenttypeTablename($contenttypeslug);\n\n        // Get the current values from the DB.\n        $query = sprintf(\n            "SELECT id FROM %s ORDER BY datecreated DESC LIMIT 1;",\n            $tablename\n        );\n        $id = $this->app[\'db\']->executeQuery($query)->fetch();\n\n        if (!empty($id[\'id\'])) {\n            return $id[\'id\'];\n        } else {\n            return false;\n        }\n    }\n\n    /**\n     * Get a unique URL for a record\n     *\n     * @param string  $title\n     * @param integer $id\n     * @param string  $contenttypeslug\n     * @param boolean $fulluri\n     * @param boolean $allowempty\n     * @param string  $slugfield\n     *\n     * @return string\n     */\n    public function getUri($title, $id = 0, $contenttypeslug = "", $fulluri = true, $allowempty = true, $slugfield = \'slug\')\n    {\n        $contenttype = $this->getContentType($contenttypeslug);\n        $tablename = $this->getContenttypeTablename($contenttype);\n        $id = intval($id);\n        $slug = $this->app[\'slugify\']->slugify($title);\n\n        // Don\'t allow strictly numeric slugs, unless allow_numeric_slugs options is set\n        if (is_numeric($slug) && $contenttype[\'allow_numeric_slugs\'] !== true) {\n            $slug = $contenttype[\'singular_slug\'] . "-" . $slug;\n        }\n\n        // Only add \'{contenttype}/\' if $full is requested.\n        if ($fulluri) {\n            $prefix = \'/\' . $contenttype[\'singular_slug\'] . \'/\';\n        } else {\n            $prefix = \'\';\n        }\n\n        $fields = $this->getContentTypeFields($contenttypeslug);\n        //check if the fieldname exists, otherwise use \'slug\' as fallback\n        if (!in_array($slugfield, $fields)) {\n            $slugfield = \'slug\';\n        }\n\n        $query = sprintf(\n            "SELECT id from %s WHERE %s=? and id!=?",\n            $tablename,\n            $slugfield\n        );\n\n        $res = $this->app[\'db\']->executeQuery(\n            $query,\n            [$slug, $id],\n            [\\PDO::PARAM_STR, \\PDO::PARAM_INT]\n        )->fetch();\n\n        if (!$res) {\n            $uri = $prefix . $slug;\n        } else {\n            for ($i = 1; $i <= 10; $i++) {\n                $newslug = Html::trimText($slug, 127 - strlen($i), false) . \'-\' . $i;\n                $res = $this->app[\'db\']->executeQuery(\n                    $query,\n                    [$newslug, $id],\n                    [\\PDO::PARAM_STR, \\PDO::PARAM_INT]\n                )->fetch();\n                if (!$res) {\n                    $uri = $prefix . $newslug;\n                    break;\n                }\n            }\n\n            // otherwise, just get a random slug.\n            if (empty($uri)) {\n                $suffix = \'-\' . $this->app[\'randomgenerator\']->generateString(6, \'abcdefghijklmnopqrstuvwxyz01234567890\');\n                $slug = Html::trimText($slug, 128 - strlen($suffix), false) . $suffix;\n                $uri = $prefix . $slug;\n            }\n        }\n\n        // When storing, we should never have an empty slug/URI. If we can\'t make a nice one, set it to \'slug-XXXX\'.\n        if (!$allowempty && empty($uri)) {\n            $uri = \'slug-\' . $this->app[\'randomgenerator\']->generateString(6, \'abcdefghijklmnopqrstuvwxyz01234567890\');\n        }\n\n        return $uri;\n    }\n\n    /**\n     * Check if the table $name exists. We use our own queries here, because it\'s _much_\n     * faster than Doctrine\'s getSchemaManager().\n     *\n     * @param $name\n     *\n     * @return bool\n     */\n    protected function tableExists($name)\n    {\n        // We only should check each table once.\n        if (isset($this->tables[$name])) {\n            return true;\n        }\n\n        if ($this->app[\'db\']->getSchemaManager()->tablesExist([$name]) === true) {\n            return $this->tables[$name] = true;\n        }\n\n        return false;\n    }\n\n    /**\n     * Get the table name with prefix from a given $name.\n     *\n     * @param string $name\n     *\n     * @return string\n     */\n    public function getTablename($name)\n    {\n        if ($this->prefix === null) {\n            $this->prefix = $this->app[\'config\']->get(\'general/database/prefix\', \'bolt_\');\n        }\n\n        $name = str_replace(\'-\', \'_\', $this->app[\'slugify\']->slugify($name));\n        $tablename = sprintf(\'%s%s\', $this->prefix, $name);\n\n        return $tablename;\n    }\n\n    /**\n     * Get the tablename with prefix from a given Contenttype.\n     *\n     * @param string|array $contenttype\n     *\n     * @return string\n     */\n    public function getContenttypeTablename($contenttype)\n    {\n        if (is_string($contenttype)) {\n            $contenttype = $this->getContentType($contenttype);\n        }\n\n        return $this->getTablename($contenttype[\'tablename\'] ?? null);\n    }\n\n    protected function hasRecords($tablename)\n    {\n        $count = $this->app[\'db\']->fetchColumn(sprintf(\'SELECT COUNT(id) FROM %s\', $tablename));\n\n        return intval($count) > 0;\n    }\n\n    /**\n     * Find record from Content Type and Content Id.\n     *\n     * @param string $tablename Table name\n     * @param int    $contentId Content Id\n     *\n     * @return array\n     */\n    protected function findContent($tablename, $contentId)\n    {\n        $oldContent = $this->app[\'db\']->fetchAssoc("SELECT * FROM $tablename WHERE id = ?", [$contentId]);\n\n        return $oldContent;\n    }\n\n    /**\n     * Setter for pager storage element.\n     *\n     * @deprecated Just for keep BC\n     *\n     * @param string      $name\n     * @param array|Pager $pager\n     *\n     * @return Storage\n     */\n    public function setPager($name, $pager)\n    {\n        $pg = $this->app[\'pager\']->createPager($name);\n\n        foreach ($pager as $prop => $value) {\n            $pg->$prop = $value;\n        }\n\n        return $this;\n    }\n\n    /**\n     * Getter of a pager element. Pager can hold a paging snapshot map.\n     *\n     * @deprecated Just for keep BC\n     *\n     * @param string $name Optional name of a pager element. Whole pager map returns if no name given.\n     *\n     * @return array\n     */\n    public function &getPager($name = null)\n    {\n        return $this->app[\'pager\']\n            ->getPager($name)\n            ->asArray();\n    }\n\n    /**\n     * @return bool\n     */\n    public function isEmptyPager()\n    {\n        return $this->app[\'pager\']->isEmptyPager();\n    }\n}\n    /**\n     * Setter for pager storage element.\n     *\n     * @param string      $name\n     * @param array|Pager $pager\n     */\n    public function setPager($name, $pager)\n    {\n     * @param string $name Optional name of a pager element. Whole pager map returns if no name given.\n     *\n     * @return array\n     */\n    public function &getPager($name = null)\n    {\n', 'message': 'Pager service in Storage\n', 'author_name': 'Rixbeck', 'lang': 'php'}